Completion resolving

This commit is contained in:
Quinten Kock 2025-12-22 00:26:53 +01:00
parent b058ae8a6f
commit c2ebaa17b8
3 changed files with 184 additions and 3 deletions

View File

@ -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";

183
src/app/lsp/completion.ts Normal file
View File

@ -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<lsp.CompletionParams, lsp.CompletionItem[] | lsp.CompletionList | null>(
"textDocument/completion", params)
}
async function resolveDocs(plugin: LSPPlugin, item: lsp.CompletionItem, abort?: CompletionContext): Promise<lsp.CompletionItem> {
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
}

View File

@ -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"],