Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LS integration for Completion #1178

Merged
merged 1 commit into from
Dec 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 98 additions & 31 deletions spx-gui/src/components/editor/code-editor/code-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Disposable } from '@/utils/disposable'
import Emitter from '@/utils/emitter'
import { insertSpaces, tabSize } from '@/utils/spx/highlighter'
import type { I18n } from '@/utils/i18n'
import { packageSpx } from '@/utils/spx'
import type { Runtime } from '@/models/runtime'
import type { Project } from '@/models/project'
import { Copilot } from './copilot'
Expand All @@ -24,15 +25,19 @@ import {
type IContextMenuProvider,
type IHoverProvider,
builtInCommandRename,
type MenuItem
type MenuItem,
type ICompletionProvider,
type CompletionContext,
type CompletionItem,
InsertTextFormat,
CompletionItemKind
} from './ui/code-editor-ui'
import {
type Action,
type DefinitionDocumentationItem,
type DefinitionDocumentationString,
type Diagnostic,
makeAdvancedMarkdownString,
stringifyDefinitionId,
selection2Range,
toLSPPosition,
fromLSPRange,
Expand All @@ -49,19 +54,12 @@ import {
type Selection,
type CommandArgs,
getTextDocumentId,
containsPosition
containsPosition,
makeBasicMarkdownString
} from './common'
import * as spxDocumentationItems from './document-base/spx'
import * as gopDocumentationItems from './document-base/gop'
import { TextDocument, createTextDocument } from './text-document'
import { type Monaco } from './monaco'

// mock data for test
const allItems = Object.values({
...spxDocumentationItems,
...gopDocumentationItems
})

class ResourceReferencesProvider
extends Emitter<{
didChangeResourceReferences: [] // TODO
Expand Down Expand Up @@ -226,6 +224,94 @@ class HoverProvider implements IHoverProvider {
}
}

class CompletionProvider implements ICompletionProvider {
constructor(
private lspClient: SpxLSPClient,
private documentBase: DocumentBase
) {}

private getCompletionItemKind(kind: lsp.CompletionItemKind | undefined): CompletionItemKind {
switch (kind) {
case lsp.CompletionItemKind.Method:
case lsp.CompletionItemKind.Function:
case lsp.CompletionItemKind.Constructor:
return CompletionItemKind.Function
case lsp.CompletionItemKind.Field:
case lsp.CompletionItemKind.Variable:
case lsp.CompletionItemKind.Property:
return CompletionItemKind.Variable
case lsp.CompletionItemKind.Interface:
case lsp.CompletionItemKind.Enum:
case lsp.CompletionItemKind.Struct:
case lsp.CompletionItemKind.TypeParameter:
return CompletionItemKind.Type
case lsp.CompletionItemKind.Module:
return CompletionItemKind.Package
case lsp.CompletionItemKind.Keyword:
case lsp.CompletionItemKind.Operator:
return CompletionItemKind.Statement
case lsp.CompletionItemKind.EnumMember:
case lsp.CompletionItemKind.Text:
case lsp.CompletionItemKind.Constant:
return CompletionItemKind.Constant
default:
return CompletionItemKind.Unknown
}
}

private getInsertTextFormat(insertTextFormat: lsp.InsertTextFormat | undefined): InsertTextFormat {
switch (insertTextFormat) {
case lsp.InsertTextFormat.Snippet:
return InsertTextFormat.Snippet
default:
return InsertTextFormat.PlainText
}
}

async provideCompletion(ctx: CompletionContext, position: Position): Promise<CompletionItem[]> {
const items = await this.lspClient.getCompletionItems({
textDocument: ctx.textDocument.id,
position: toLSPPosition(position)
})
const maybeItems = await Promise.all(
items.map(async (item) => {
const result: CompletionItem = {
label: item.label,
kind: this.getCompletionItemKind(item.kind),
insertText: item.label,
insertTextFormat: InsertTextFormat.PlainText,
documentation: null
}

const defId = item.data?.definition
const definition = defId != null ? await this.documentBase.getDocumentation(defId) : null

// Skip APIs from spx while without documentation, they are assumed not recommended
if (defId != null && defId.package === packageSpx && definition == null) return null

if (definition != null) {
result.kind = definition.kind
result.insertText = definition.insertText
result.insertTextFormat = InsertTextFormat.Snippet
result.documentation = makeBasicMarkdownString(definition.overview)
}

if (item.documentation != null) {
const docStr = lsp.MarkupContent.is(item.documentation) ? item.documentation.value : item.documentation
result.documentation = makeAdvancedMarkdownString(docStr)
}

if (item.insertText != null) {
result.insertText = item.insertText
result.insertTextFormat = this.getInsertTextFormat(item.insertTextFormat)
}
return result
})
)
return maybeItems.filter((item) => item != null) as CompletionItem[]
}
}

class ContextMenuProvider implements IContextMenuProvider {
constructor(
private lspClient: SpxLSPClient,
Expand Down Expand Up @@ -435,26 +521,7 @@ export class CodeEditor extends Disposable {
}
})

ui.registerCompletionProvider({
async provideCompletion(ctx, position) {
console.warn('TODO', ctx, position)
await new Promise<void>((resolve) => setTimeout(resolve, 100))
ctx.signal.throwIfAborted()
return allItems.map((item) => ({
label: item.definition
.name!.split('.')
.pop()!
.replace(/^./, (c) => c.toLowerCase()),
kind: item.kind,
insertText: item.insertText,
documentation: makeAdvancedMarkdownString(`
<definition-item overview="${item.overview}" def-id="${stringifyDefinitionId(item.definition)}">
</definition-item>
`)
}))
}
})

ui.registerCompletionProvider(new CompletionProvider(this.lspClient, documentBase))
ui.registerContextMenuProvider(new ContextMenuProvider(lspClient, documentBase))
ui.registerCopilot(copilot)
ui.registerDiagnosticsProvider(this.diagnosticsProvider)
Expand Down
6 changes: 5 additions & 1 deletion spx-gui/src/components/editor/code-editor/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ export enum DefinitionKind {
/** Constant definition */
Constant,
/** Package definition */
Package
Package,
/** Type definition */
Type,
/** Unknown definition kind */
Unknown
}

export type DefinitionIdentifier = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { LocaleMessage } from '@/utils/i18n'
import { packageSpx } from '@/utils/spx'
import {
DefinitionKind,
type DefinitionDocumentationItem,
Expand All @@ -7,8 +8,6 @@ import {
type DefinitionDocumentationCategory
} from '../common'

const packageSpx = 'github.com/goplus/spx'

export const clone: DefinitionDocumentationItem = {
categories: [categories.motion.position],
kind: DefinitionKind.Command,
Expand Down
20 changes: 19 additions & 1 deletion spx-gui/src/components/editor/code-editor/lsp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import {
import { Spxlc } from './spxls/client'
import type { Files as SpxlsFiles } from './spxls'
import { spxGetDefinitions, spxRenameResources } from './spxls/commands'
import { isDocumentLinkForResourceReference, parseDocumentLinkForDefinition } from './spxls/methods'
import {
type CompletionItem,
isDocumentLinkForResourceReference,
parseDocumentLinkForDefinition
} from './spxls/methods'

function loadScript(url: string) {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -127,6 +131,13 @@ export class SpxLSPClient extends Disposable {
return spxlc.request<lsp.Hover | null>(lsp.HoverRequest.method, params)
}

async textDocumentCompletion(
params: lsp.CompletionParams
): Promise<lsp.CompletionList | lsp.CompletionItem[] | null> {
const spxlc = await this.prepareRequest()
return spxlc.request<lsp.CompletionList | lsp.CompletionItem[] | null>(lsp.CompletionRequest.method, params)
}

async textDocumentDefinition(params: lsp.DefinitionParams): Promise<lsp.Definition | null> {
const spxlc = await this.prepareRequest()
return spxlc.request<lsp.Definition | null>(lsp.DefinitionRequest.method, params)
Expand Down Expand Up @@ -181,4 +192,11 @@ export class SpxLSPClient extends Disposable {
}
return null
}

async getCompletionItems(params: lsp.CompletionParams) {
const completionResult = await this.textDocumentCompletion(params)
if (completionResult == null) return []
if (!Array.isArray(completionResult)) return [] // For now, we support CompletionItem[] only
return completionResult as CompletionItem[]
}
}
10 changes: 10 additions & 0 deletions spx-gui/src/components/editor/code-editor/lsp/spxls/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,13 @@ export function parseDocumentLinkForDefinition(link: lsp.DocumentLink): Definiti
return null
}
}

/** CompletionItemData represents data in a completion item. */
export type CompletionItemData = {
/** The corresponding definition of the completion item */
definition?: DefinitionIdentifier
}

export interface CompletionItem extends lsp.CompletionItem {
data?: CompletionItemData
}
10 changes: 8 additions & 2 deletions spx-gui/src/components/editor/code-editor/ui/code-editor-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,14 +237,20 @@ export class CodeEditorUI extends Disposable implements ICodeEditorUI {
return this.getTextDocument(this.mainTextDocumentId)
}

async insertText(text: string, range: Range) {
const editor = this.editor
const inserting = { range: toMonacoRange(range), text }
editor.executeEdits('insertText', [inserting])
}

async insertSnippet(snippet: string, range: Range) {
const editor = this.editor
// `executeEdits` does not support snippet, so we have to split the insertion into two steps:
// 1. remove the range with `executeEdits`
// 2. insert the snippet with `snippetController2`
if (!isRangeEmpty(range)) {
const removing = { range: toMonacoRange(range), text: '' }
editor.executeEdits('snippet', [removing])
editor.executeEdits('insertSnippet', [removing])
await timeout(0) // NOTE: the timeout is necessary, or the cursor position will be wrong after snippet inserted
}
// it's strange but it works, see details in https://github.com/Microsoft/monaco-editor/issues/342
Expand Down Expand Up @@ -375,7 +381,7 @@ export class CodeEditorUI extends Disposable implements ICodeEditorUI {
const selection = editor.getSelection()
if (selection == null) return
const text = await navigator.clipboard.readText()
editor.executeEdits('editor', [{ range: selection, text }])
editor.executeEdits('paste', [{ range: selection, text }])
editor.focus()
} catch (error) {
editor.focus()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { computed, ref, watch, watchEffect } from 'vue'
import { MonacoKeyCode, type monaco } from '../../monaco'
import MarkdownView from '../markdown/MarkdownView.vue'
import CodeEditorCard from '../CodeEditorCard.vue'
Expand All @@ -14,6 +14,10 @@ const props = defineProps<{
const activeIdx = ref(0)
const activeItem = computed<InternalCompletionItem | null>(() => props.items[activeIdx.value] ?? null)

watch(activeItem, (item) => {
if (item == null) activeIdx.value = 0
})

function moveActiveUp() {
const newIdx = activeIdx.value - 1
activeIdx.value = newIdx < 0 ? props.items.length - 1 : newIdx
Expand Down Expand Up @@ -71,8 +75,8 @@ function applyItem(item: InternalCompletionItem) {
@click="applyItem(item)"
/>
</ul>
<div v-if="activeItem != null" class="completion-item-detail">
<MarkdownView v-bind="activeItem.documentation" />
<div class="completion-item-detail">
<MarkdownView v-if="activeItem?.documentation != null" v-bind="activeItem.documentation" />
</div>
</CodeEditorCard>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type Part = {
}

const parts = computed(() => {
const matches = createMatches(props.item.score)
const matches = createMatches(props.item.score ?? undefined)
const parts: Part[] = []
let lastEnd = 0
for (const match of matches) {
Expand Down Expand Up @@ -51,6 +51,7 @@ watchEffect(() => {

<style lang="scss" scoped>
.completion-item {
min-width: 8em;
display: flex;
align-items: center;
padding: 8px;
Expand Down
Loading
Loading