Skip to content

Commit a6b1cef

Browse files
authored
LS integration for completion (#1178)
1 parent 4308b3d commit a6b1cef

File tree

12 files changed

+189
-54
lines changed

12 files changed

+189
-54
lines changed

spx-gui/src/components/editor/code-editor/code-editor.ts

Lines changed: 98 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Disposable } from '@/utils/disposable'
33
import Emitter from '@/utils/emitter'
44
import { insertSpaces, tabSize } from '@/utils/spx/highlighter'
55
import type { I18n } from '@/utils/i18n'
6+
import { packageSpx } from '@/utils/spx'
67
import type { Runtime } from '@/models/runtime'
78
import type { Project } from '@/models/project'
89
import { Copilot } from './copilot'
@@ -24,15 +25,19 @@ import {
2425
type IContextMenuProvider,
2526
type IHoverProvider,
2627
builtInCommandRename,
27-
type MenuItem
28+
type MenuItem,
29+
type ICompletionProvider,
30+
type CompletionContext,
31+
type CompletionItem,
32+
InsertTextFormat,
33+
CompletionItemKind
2834
} from './ui/code-editor-ui'
2935
import {
3036
type Action,
3137
type DefinitionDocumentationItem,
3238
type DefinitionDocumentationString,
3339
type Diagnostic,
3440
makeAdvancedMarkdownString,
35-
stringifyDefinitionId,
3641
selection2Range,
3742
toLSPPosition,
3843
fromLSPRange,
@@ -49,19 +54,12 @@ import {
4954
type Selection,
5055
type CommandArgs,
5156
getTextDocumentId,
52-
containsPosition
57+
containsPosition,
58+
makeBasicMarkdownString
5359
} from './common'
54-
import * as spxDocumentationItems from './document-base/spx'
55-
import * as gopDocumentationItems from './document-base/gop'
5660
import { TextDocument, createTextDocument } from './text-document'
5761
import { type Monaco } from './monaco'
5862

59-
// mock data for test
60-
const allItems = Object.values({
61-
...spxDocumentationItems,
62-
...gopDocumentationItems
63-
})
64-
6563
class ResourceReferencesProvider
6664
extends Emitter<{
6765
didChangeResourceReferences: [] // TODO
@@ -226,6 +224,94 @@ class HoverProvider implements IHoverProvider {
226224
}
227225
}
228226

227+
class CompletionProvider implements ICompletionProvider {
228+
constructor(
229+
private lspClient: SpxLSPClient,
230+
private documentBase: DocumentBase
231+
) {}
232+
233+
private getCompletionItemKind(kind: lsp.CompletionItemKind | undefined): CompletionItemKind {
234+
switch (kind) {
235+
case lsp.CompletionItemKind.Method:
236+
case lsp.CompletionItemKind.Function:
237+
case lsp.CompletionItemKind.Constructor:
238+
return CompletionItemKind.Function
239+
case lsp.CompletionItemKind.Field:
240+
case lsp.CompletionItemKind.Variable:
241+
case lsp.CompletionItemKind.Property:
242+
return CompletionItemKind.Variable
243+
case lsp.CompletionItemKind.Interface:
244+
case lsp.CompletionItemKind.Enum:
245+
case lsp.CompletionItemKind.Struct:
246+
case lsp.CompletionItemKind.TypeParameter:
247+
return CompletionItemKind.Type
248+
case lsp.CompletionItemKind.Module:
249+
return CompletionItemKind.Package
250+
case lsp.CompletionItemKind.Keyword:
251+
case lsp.CompletionItemKind.Operator:
252+
return CompletionItemKind.Statement
253+
case lsp.CompletionItemKind.EnumMember:
254+
case lsp.CompletionItemKind.Text:
255+
case lsp.CompletionItemKind.Constant:
256+
return CompletionItemKind.Constant
257+
default:
258+
return CompletionItemKind.Unknown
259+
}
260+
}
261+
262+
private getInsertTextFormat(insertTextFormat: lsp.InsertTextFormat | undefined): InsertTextFormat {
263+
switch (insertTextFormat) {
264+
case lsp.InsertTextFormat.Snippet:
265+
return InsertTextFormat.Snippet
266+
default:
267+
return InsertTextFormat.PlainText
268+
}
269+
}
270+
271+
async provideCompletion(ctx: CompletionContext, position: Position): Promise<CompletionItem[]> {
272+
const items = await this.lspClient.getCompletionItems({
273+
textDocument: ctx.textDocument.id,
274+
position: toLSPPosition(position)
275+
})
276+
const maybeItems = await Promise.all(
277+
items.map(async (item) => {
278+
const result: CompletionItem = {
279+
label: item.label,
280+
kind: this.getCompletionItemKind(item.kind),
281+
insertText: item.label,
282+
insertTextFormat: InsertTextFormat.PlainText,
283+
documentation: null
284+
}
285+
286+
const defId = item.data?.definition
287+
const definition = defId != null ? await this.documentBase.getDocumentation(defId) : null
288+
289+
// Skip APIs from spx while without documentation, they are assumed not recommended
290+
if (defId != null && defId.package === packageSpx && definition == null) return null
291+
292+
if (definition != null) {
293+
result.kind = definition.kind
294+
result.insertText = definition.insertText
295+
result.insertTextFormat = InsertTextFormat.Snippet
296+
result.documentation = makeBasicMarkdownString(definition.overview)
297+
}
298+
299+
if (item.documentation != null) {
300+
const docStr = lsp.MarkupContent.is(item.documentation) ? item.documentation.value : item.documentation
301+
result.documentation = makeAdvancedMarkdownString(docStr)
302+
}
303+
304+
if (item.insertText != null) {
305+
result.insertText = item.insertText
306+
result.insertTextFormat = this.getInsertTextFormat(item.insertTextFormat)
307+
}
308+
return result
309+
})
310+
)
311+
return maybeItems.filter((item) => item != null) as CompletionItem[]
312+
}
313+
}
314+
229315
class ContextMenuProvider implements IContextMenuProvider {
230316
constructor(
231317
private lspClient: SpxLSPClient,
@@ -435,26 +521,7 @@ export class CodeEditor extends Disposable {
435521
}
436522
})
437523

438-
ui.registerCompletionProvider({
439-
async provideCompletion(ctx, position) {
440-
console.warn('TODO', ctx, position)
441-
await new Promise<void>((resolve) => setTimeout(resolve, 100))
442-
ctx.signal.throwIfAborted()
443-
return allItems.map((item) => ({
444-
label: item.definition
445-
.name!.split('.')
446-
.pop()!
447-
.replace(/^./, (c) => c.toLowerCase()),
448-
kind: item.kind,
449-
insertText: item.insertText,
450-
documentation: makeAdvancedMarkdownString(`
451-
<definition-item overview="${item.overview}" def-id="${stringifyDefinitionId(item.definition)}">
452-
</definition-item>
453-
`)
454-
}))
455-
}
456-
})
457-
524+
ui.registerCompletionProvider(new CompletionProvider(this.lspClient, documentBase))
458525
ui.registerContextMenuProvider(new ContextMenuProvider(lspClient, documentBase))
459526
ui.registerCopilot(copilot)
460527
ui.registerDiagnosticsProvider(this.diagnosticsProvider)

spx-gui/src/components/editor/code-editor/common.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,11 @@ export enum DefinitionKind {
9999
/** Constant definition */
100100
Constant,
101101
/** Package definition */
102-
Package
102+
Package,
103+
/** Type definition */
104+
Type,
105+
/** Unknown definition kind */
106+
Unknown
103107
}
104108

105109
export type DefinitionIdentifier = {

spx-gui/src/components/editor/code-editor/document-base/spx.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { LocaleMessage } from '@/utils/i18n'
2+
import { packageSpx } from '@/utils/spx'
23
import {
34
DefinitionKind,
45
type DefinitionDocumentationItem,
@@ -7,8 +8,6 @@ import {
78
type DefinitionDocumentationCategory
89
} from '../common'
910

10-
const packageSpx = 'github.com/goplus/spx'
11-
1211
export const clone: DefinitionDocumentationItem = {
1312
categories: [categories.motion.position],
1413
kind: DefinitionKind.Command,

spx-gui/src/components/editor/code-editor/lsp/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import {
1818
import { Spxlc } from './spxls/client'
1919
import type { Files as SpxlsFiles } from './spxls'
2020
import { spxGetDefinitions, spxRenameResources } from './spxls/commands'
21-
import { isDocumentLinkForResourceReference, parseDocumentLinkForDefinition } from './spxls/methods'
21+
import {
22+
type CompletionItem,
23+
isDocumentLinkForResourceReference,
24+
parseDocumentLinkForDefinition
25+
} from './spxls/methods'
2226

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

134+
async textDocumentCompletion(
135+
params: lsp.CompletionParams
136+
): Promise<lsp.CompletionList | lsp.CompletionItem[] | null> {
137+
const spxlc = await this.prepareRequest()
138+
return spxlc.request<lsp.CompletionList | lsp.CompletionItem[] | null>(lsp.CompletionRequest.method, params)
139+
}
140+
130141
async textDocumentDefinition(params: lsp.DefinitionParams): Promise<lsp.Definition | null> {
131142
const spxlc = await this.prepareRequest()
132143
return spxlc.request<lsp.Definition | null>(lsp.DefinitionRequest.method, params)
@@ -181,4 +192,11 @@ export class SpxLSPClient extends Disposable {
181192
}
182193
return null
183194
}
195+
196+
async getCompletionItems(params: lsp.CompletionParams) {
197+
const completionResult = await this.textDocumentCompletion(params)
198+
if (completionResult == null) return []
199+
if (!Array.isArray(completionResult)) return [] // For now, we support CompletionItem[] only
200+
return completionResult as CompletionItem[]
201+
}
184202
}

spx-gui/src/components/editor/code-editor/lsp/spxls/methods.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,13 @@ export function parseDocumentLinkForDefinition(link: lsp.DocumentLink): Definiti
2828
return null
2929
}
3030
}
31+
32+
/** CompletionItemData represents data in a completion item. */
33+
export type CompletionItemData = {
34+
/** The corresponding definition of the completion item */
35+
definition?: DefinitionIdentifier
36+
}
37+
38+
export interface CompletionItem extends lsp.CompletionItem {
39+
data?: CompletionItemData
40+
}

spx-gui/src/components/editor/code-editor/ui/code-editor-ui.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,14 +237,20 @@ export class CodeEditorUI extends Disposable implements ICodeEditorUI {
237237
return this.getTextDocument(this.mainTextDocumentId)
238238
}
239239

240+
async insertText(text: string, range: Range) {
241+
const editor = this.editor
242+
const inserting = { range: toMonacoRange(range), text }
243+
editor.executeEdits('insertText', [inserting])
244+
}
245+
240246
async insertSnippet(snippet: string, range: Range) {
241247
const editor = this.editor
242248
// `executeEdits` does not support snippet, so we have to split the insertion into two steps:
243249
// 1. remove the range with `executeEdits`
244250
// 2. insert the snippet with `snippetController2`
245251
if (!isRangeEmpty(range)) {
246252
const removing = { range: toMonacoRange(range), text: '' }
247-
editor.executeEdits('snippet', [removing])
253+
editor.executeEdits('insertSnippet', [removing])
248254
await timeout(0) // NOTE: the timeout is necessary, or the cursor position will be wrong after snippet inserted
249255
}
250256
// it's strange but it works, see details in https://github.com/Microsoft/monaco-editor/issues/342
@@ -375,7 +381,7 @@ export class CodeEditorUI extends Disposable implements ICodeEditorUI {
375381
const selection = editor.getSelection()
376382
if (selection == null) return
377383
const text = await navigator.clipboard.readText()
378-
editor.executeEdits('editor', [{ range: selection, text }])
384+
editor.executeEdits('paste', [{ range: selection, text }])
379385
editor.focus()
380386
} catch (error) {
381387
editor.focus()

spx-gui/src/components/editor/code-editor/ui/completion/CompletionCard.vue

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { computed, ref, watchEffect } from 'vue'
2+
import { computed, ref, watch, watchEffect } from 'vue'
33
import { MonacoKeyCode, type monaco } from '../../monaco'
44
import MarkdownView from '../markdown/MarkdownView.vue'
55
import CodeEditorCard from '../CodeEditorCard.vue'
@@ -14,6 +14,10 @@ const props = defineProps<{
1414
const activeIdx = ref(0)
1515
const activeItem = computed<InternalCompletionItem | null>(() => props.items[activeIdx.value] ?? null)
1616
17+
watch(activeItem, (item) => {
18+
if (item == null) activeIdx.value = 0
19+
})
20+
1721
function moveActiveUp() {
1822
const newIdx = activeIdx.value - 1
1923
activeIdx.value = newIdx < 0 ? props.items.length - 1 : newIdx
@@ -71,8 +75,8 @@ function applyItem(item: InternalCompletionItem) {
7175
@click="applyItem(item)"
7276
/>
7377
</ul>
74-
<div v-if="activeItem != null" class="completion-item-detail">
75-
<MarkdownView v-bind="activeItem.documentation" />
78+
<div class="completion-item-detail">
79+
<MarkdownView v-if="activeItem?.documentation != null" v-bind="activeItem.documentation" />
7680
</div>
7781
</CodeEditorCard>
7882
</template>

spx-gui/src/components/editor/code-editor/ui/completion/CompletionItem.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type Part = {
1515
}
1616
1717
const parts = computed(() => {
18-
const matches = createMatches(props.item.score)
18+
const matches = createMatches(props.item.score ?? undefined)
1919
const parts: Part[] = []
2020
let lastEnd = 0
2121
for (const match of matches) {
@@ -51,6 +51,7 @@ watchEffect(() => {
5151

5252
<style lang="scss" scoped>
5353
.completion-item {
54+
min-width: 8em;
5455
display: flex;
5556
align-items: center;
5657
padding: 8px;

0 commit comments

Comments
 (0)