diff --git a/src/app/lsp.ts b/src/app/lsp.ts index d03cbb2..4a5316c 100644 --- a/src/app/lsp.ts +++ b/src/app/lsp.ts @@ -8,11 +8,11 @@ import { LSPClient, LSPPlugin, Workspace, - serverCompletion, hoverTooltips, signatureHelp, } from "@codemirror/lsp-client"; +import { serverCompletion } from "./lsp/completion"; import { OpenFile } from "./filestate"; import { serverDiagnostics } from "./lsp/diagnostics"; diff --git a/src/app/lsp/completion.ts b/src/app/lsp/completion.ts new file mode 100644 index 0000000..f97fa92 --- /dev/null +++ b/src/app/lsp/completion.ts @@ -0,0 +1,183 @@ +import type * as lsp from "vscode-languageserver-protocol" +import { EditorState, Extension, Facet } from "@codemirror/state" +import { CompletionSource, Completion, CompletionContext, snippet, autocompletion } from "@codemirror/autocomplete" +import { LSPPlugin, LSPClientExtension } from "@codemirror/lsp-client" + +/// Register the [language server completion +/// source](#lsp-client.serverCompletionSource) as an autocompletion +/// source. +export function serverCompletion(config: { + /// By default, the completion source that asks the language server + /// for completions is added as a regular source, in addition to any + /// other sources. Set this to true to make it replace all + /// completion sources. + override?: boolean + /// Set a custom + /// [`validFor`](#autocomplete.CompletionResult.validFor) expression + /// to use in the completion results. By default, the library uses an + /// expression that accepts word characters, optionally prefixed by + /// any non-word prefixes found in the results. + validFor?: RegExp +} = {}): LSPClientExtension { + let result: Extension[] + if (config.override) { + result = [autocompletion({ override: [serverCompletionSource] })] + } else { + let data = [{ autocomplete: serverCompletionSource }] + result = [autocompletion(), EditorState.languageData.of(() => data)] + } + if (config.validFor) result.push(completionConfig.of({ validFor: config.validFor })) + return { + clientCapabilities: { + textDocument: { + completion: { + completionItem: { + snippetSupport: true, + // documentationFormat: ["markdown", "plaintext"], + resolveSupport: ["documentation"], + labelDetailsSupport: true, + } + } + } + }, + editorExtension: result, + }; +} + +const completionConfig = Facet.define<{ validFor: RegExp }, { validFor: RegExp | null }>({ + combine: results => results.length ? results[0] : { validFor: null } +}) + +function getCompletions(plugin: LSPPlugin, pos: number, context: lsp.CompletionContext, abort?: CompletionContext) { + const hasCapability = plugin.client.serverCapabilities ? !!plugin.client.serverCapabilities["completionProvider"] : null; + if (hasCapability === false) return Promise.resolve(null) + plugin.client.sync() + let params: lsp.CompletionParams = { + position: plugin.toPosition(pos), + textDocument: { uri: plugin.uri }, + context + } + if (abort) abort.addEventListener("abort", () => plugin.client.cancelRequest(params)) + return plugin.client.request( + "textDocument/completion", params) +} + +async function resolveDocs(plugin: LSPPlugin, item: lsp.CompletionItem, abort?: CompletionContext): Promise { + const hasCapability = plugin.client.serverCapabilities ? !!plugin.client.serverCapabilities["completionProvider"]["resolveProvider"] : null; + if (hasCapability === false) { + console.warn("No resolving support but also no docs given!"); + return Promise.resolve(null); + } + if (abort) abort.addEventListener("abort", () => plugin.client.cancelRequest(item)); + return await plugin.client.request("completionItem/resolve", item); +} + +// Look for non-alphanumeric prefixes in the completions, and return a +// regexp that matches them, to use in validFor +function prefixRegexp(items: readonly lsp.CompletionItem[]) { + let step = Math.ceil(items.length / 50), prefixes: string[] = [] + for (let i = 0; i < items.length; i += step) { + let item = items[i], text = item.textEdit?.newText || item.textEditText || item.insertText || item.label + if (!/^\w/.test(text)) { + let prefix = /^[^\w]*/.exec(text)![0] + if (prefixes.indexOf(prefix) < 0) prefixes.push(prefix) + } + } + if (!prefixes.length) return /^\w*$/ + return new RegExp("^(?:" + prefixes.map((RegExp as any).escape || (s => s.replace(/[^\w\s]/g, "\\$&"))).join("|") + ")?\\w*$") +} + +/// A completion source that requests completions from a language +/// server. +export const serverCompletionSource: CompletionSource = context => { + const plugin = context.view && LSPPlugin.get(context.view) + if (!plugin) return null + let triggerChar = "" + if (!context.explicit) { + triggerChar = context.view.state.sliceDoc(context.pos - 1, context.pos) + let triggers = plugin.client.serverCapabilities?.completionProvider?.triggerCharacters + if (!/[a-zA-Z_]/.test(triggerChar) && !(triggers && triggers.indexOf(triggerChar) > -1)) return null + } + return getCompletions(plugin, context.pos, { + triggerCharacter: triggerChar, + triggerKind: context.explicit ? 1 /* Invoked */ : 2 /* TriggerCharacter */ + }, context).then(result => { + if (!result) return null + if (Array.isArray(result)) result = { items: result } as lsp.CompletionList + let { from, to } = completionResultRange(context, result) + let defaultCommitChars = result.itemDefaults?.commitCharacters + let config = context.state.facet(completionConfig) + + return { + from, to, + options: result.items.map((item: lsp.CompletionItem) => { + let text = item.textEdit?.newText || item.textEditText || item.insertText || item.label + let option: Completion = { + label: text, + type: item.kind && kindToType[item.kind], + } + if (item.commitCharacters && item.commitCharacters != defaultCommitChars) + option.commitCharacters = item.commitCharacters + if (item.detail) option.detail = item.detail; else option.detail = "No details available" + if (item.sortText) option.sortText = item.sortText + if (item.insertTextFormat == 2 /* Snippet */) { + option.apply = (view, c, from, to) => snippet(text)(view, c, from, to) + option.label = item.label + } + option.info = item.documentation ? () => renderDocInfo(plugin, item.documentation!) : async () => { + const newItem = await resolveDocs(plugin, item); + return newItem.documentation ? renderDocInfo(plugin, newItem.documentation!) : null; + } + // if (item.documentation) option.info = () => renderDocInfo(plugin, item.documentation!); + return option + }), + commitCharacters: defaultCommitChars, + validFor: config.validFor ?? prefixRegexp(result.items), + map: (result, changes) => ({ ...result, from: changes.mapPos(result.from) }), + } + }, err => { + if ("code" in err && (err as lsp.ResponseError).code == -32800 /* RequestCancelled */) + return null + throw err + }) +} + +function completionResultRange(cx: CompletionContext, result: lsp.CompletionList): { from: number, to: number } { + if (!result.items.length) return { from: cx.pos, to: cx.pos } + let defaultRange = result.itemDefaults?.editRange, item0 = result.items[0] + let range = defaultRange ? ("insert" in defaultRange ? defaultRange.insert : defaultRange) + : item0.textEdit ? ("range" in item0.textEdit ? item0.textEdit.range : item0.textEdit.insert) + : null + if (!range) return cx.state.wordAt(cx.pos) || { from: cx.pos, to: cx.pos } + let line = cx.state.doc.lineAt(cx.pos) + return { from: line.from + range.start.character, to: line.from + range.end.character } +} + +function renderDocInfo(plugin: LSPPlugin, doc: string | lsp.MarkupContent) { + let elt = document.createElement("div") + elt.className = "cm-lsp-documentation cm-lsp-completion-documentation" + elt.innerHTML = plugin.docToHTML(doc) + return elt +} + +const kindToType: { [kind: number]: string } = { + 1: "text", // Text + 2: "method", // Method + 3: "function", // Function + 4: "class", // Constructor + 5: "property", // Field + 6: "variable", // Variable + 7: "class", // Class + 8: "interface", // Interface + 9: "namespace", // Module + 10: "property", // Property + 11: "keyword", // Unit + 12: "constant", // Value + 13: "constant", // Enum + 14: "keyword", // Keyword + 16: "constant", // Color + 20: "constant", // EnumMember + 21: "constant", // Constant + 22: "class", // Struct + 25: "type" // TypeParameter +} diff --git a/src/main/langserver.ts b/src/main/langserver.ts index f227adb..a5bdc39 100644 --- a/src/main/langserver.ts +++ b/src/main/langserver.ts @@ -57,8 +57,6 @@ function ensureLspForKey( serverKey, ); - console.log("Current environment: ", process.env); - // Spawn using shell:true so PATH and shell resolution behave like a user shell. const proc = spawn([cmd].concat(args).join(" "), { stdio: ["pipe", "pipe", "pipe"],