@codemirror/lsp-client
Advanced tools
+8
-0
@@ -0,1 +1,9 @@ | ||
| ## 6.2.5 (2026-06-09) | ||
| ### Bug fixes | ||
| Strip backslash escapes in snippets in places where CodeMirror doesn't support them. | ||
| Support the `extraTextEdits` field in completions. | ||
| ## 6.2.4 (2026-05-15) | ||
@@ -2,0 +10,0 @@ |
+1
-1
| { | ||
| "name": "@codemirror/lsp-client", | ||
| "version": "6.2.4", | ||
| "version": "6.2.5", | ||
| "description": "Language server protocol client for CodeMirror", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+60
-5
| 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 {EditorState, Extension, Facet, ChangeDesc, ChangeSpec} from "@codemirror/state" | ||
| import {EditorView} from "@codemirror/view" | ||
| import {CompletionSource, Completion, CompletionResult, CompletionContext, | ||
| snippet, autocompletion, insertCompletionText} from "@codemirror/autocomplete" | ||
| import {LSPPlugin} from "./plugin" | ||
| import {fromPositionChecked} from "./pos" | ||
| // Convert from LSP's snippet syntax to @codemirror/autocompletion's | ||
| // one. Remove backslashes before `$}]`, which don't support backslash | ||
| // escapes in CodeMirror, and expand `$1` to `${1}`. | ||
| function lspToSnippet(text: string): string { | ||
| return text.replace(/\\([$}\\])|\$(\d+)/g, (_m, esc, field) => esc || `\${${field}}`) | ||
| } | ||
| /// Register the [language server completion | ||
@@ -72,2 +82,37 @@ /// source](#lsp-client.serverCompletionSource) as an autocompletion | ||
| type TextEdit = {from: number, to: number, text: string} | ||
| type ExtraEdits = {index: number, edits: readonly TextEdit[], text: string} | ||
| function resultMapper(changes: ChangeDesc | null, extraEdits: ExtraEdits[]) { | ||
| return (result: CompletionResult, newChanges: ChangeDesc): CompletionResult => { | ||
| changes = changes ? changes.composeDesc(newChanges) : newChanges | ||
| let options = result.options.slice() | ||
| for (let {index, edits, text} of extraEdits) | ||
| options[index] = {...options[index], apply: applyEdits(edits, text, changes)} | ||
| return { | ||
| ...result, | ||
| options, | ||
| map: resultMapper(changes, extraEdits) | ||
| } | ||
| } | ||
| } | ||
| function applyEdits(edits: readonly TextEdit[], text: string, mapped: ChangeDesc | null) { | ||
| return (view: EditorView, completion: Completion, from: number, to: number) => { | ||
| let base = insertCompletionText(view.state, text, from, to) | ||
| let changes: ChangeSpec[] = [] | ||
| for (let {from, to, text} of edits) { | ||
| if (mapped) { | ||
| if (mapped.touchesRange(from, to)) continue | ||
| let len = to - from | ||
| from = mapped.mapPos(from, 1) | ||
| to = from + len | ||
| } | ||
| changes.push({from, to, insert: text}) | ||
| } | ||
| view.dispatch(base, {changes}) | ||
| } | ||
| } | ||
| /// A completion source that requests completions from a language | ||
@@ -90,6 +135,7 @@ /// server. | ||
| let config = context.state.facet(completionConfig) | ||
| let extraEdits: ExtraEdits[] = [] | ||
| return { | ||
| from, to, | ||
| options: result.items.map<Completion>(item => { | ||
| options: result.items.map<Completion>((item, i) => { | ||
| let text = item.textEdit?.newText || item.textEditText || item.insertText || item.label | ||
@@ -107,3 +153,12 @@ let option: Completion = { | ||
| if (insertTextFormat == 2 /* Snippet */) { | ||
| option.apply = (view, c, from, to) => snippet(text.replace(/\$(\d+)/g, "${$1}"))(view, c, from, to) | ||
| option.apply = (view, c, from, to) => snippet(lspToSnippet(text))(view, c, from, to) | ||
| } else if (item.additionalTextEdits) { | ||
| let edits: TextEdit[] = [] | ||
| for (let edit of item.additionalTextEdits) { | ||
| let from = fromPositionChecked(context.state.doc, edit.range.start) | ||
| let to = fromPositionChecked(context.state.doc, edit.range.end) | ||
| if (from != null && to != null) edits.push({from, to, text: edit.newText}) | ||
| } | ||
| extraEdits.push({index: i, text, edits}) | ||
| option.apply = applyEdits(edits, text, null) | ||
| } else if (option.label != text) { | ||
@@ -117,3 +172,3 @@ option.apply = text | ||
| validFor: result.isIncomplete ? undefined : (config.validFor ?? prefixRegexp(result.items)), | ||
| map: (result, changes) => ({...result, from: changes.mapPos(result.from)}), | ||
| map: extraEdits.length ? resultMapper(null, extraEdits) : undefined | ||
| } | ||
@@ -120,0 +175,0 @@ }, err => { |
+7
-0
@@ -14,1 +14,8 @@ import type * as lsp from "vscode-languageserver-protocol" | ||
| export function fromPositionChecked(doc: Text, pos: lsp.Position): number | null { | ||
| if (pos.line < 0 || pos.line >= doc.lines) return null | ||
| let line = doc.line(pos.line + 1) | ||
| if (pos.character < 0 || pos.character > line.length) return null | ||
| return line.from + pos.character | ||
| } | ||
+7
-0
@@ -58,2 +58,9 @@ import type * as lsp from "vscode-languageserver-protocol" | ||
| {label: "ookay", kind: 7, documentation: "`code` stuff", insertText: "okay"}, | ||
| {label: "fn", kind: 3, insertText: "fn(${1:\\$arg})$0", insertTextFormat: 2}, | ||
| {label: "Bar", kind: 7, insertText: "Bar", additionalTextEdits: [ | ||
| {range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, newText: "use Bar;\n"}, | ||
| ]}, | ||
| {label: "Zap", kind: 7, insertText: "Zap", additionalTextEdits: [ | ||
| {range: {start: {line: 2, character: 0}, end: {line: 2, character: 0}}, newText: "MID\n"}, | ||
| ]}, | ||
| ] | ||
@@ -60,0 +67,0 @@ } |
@@ -284,2 +284,58 @@ import type * as lsp from "vscode-languageserver-protocol" | ||
| }) | ||
| it("expands snippet completions and unescapes LSP escapes", async () => { | ||
| let {client} = setup() | ||
| let cm = ed(client, {doc: "..f", selection: {anchor: 3}, extensions: [ | ||
| serverCompletion(), | ||
| autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) | ||
| ]}) | ||
| startCompletion(cm) | ||
| await wait(60) | ||
| let cs = currentCompletions(cm.state) | ||
| ist(cs.length, 1) | ||
| ist(cs[0].label, "fn") | ||
| acceptCompletion(cm) | ||
| // `\$arg` must come through as a literal `$arg`, not the raw `\$arg`. | ||
| ist(cm.state.sliceDoc(), "..fn($arg)") | ||
| }) | ||
| it("applies additionalTextEdits (e.g. an auto-import)", async () => { | ||
| let {client} = setup() | ||
| let cm = ed(client, {doc: "..b", selection: {anchor: 3}, extensions: [ | ||
| serverCompletion(), | ||
| autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) | ||
| ]}) | ||
| startCompletion(cm) | ||
| await wait(60) | ||
| let cs = currentCompletions(cm.state) | ||
| ist(cs.length, 1) | ||
| ist(cs[0].label, "Bar") | ||
| acceptCompletion(cm) | ||
| // The completion inserts "Bar" *and* the item's additionalTextEdits add | ||
| // the `use Bar;` line at the top. | ||
| ist(cm.state.sliceDoc(), "use Bar;\n..Bar") | ||
| }) | ||
| it("maps additionalTextEdit positions through changes that arrive while the completion is active", async () => { | ||
| let {client} = setup() | ||
| // Server's "Zap" completion ships an additionalTextEdit at line 2, | ||
| // char 0 — i.e. the start of "L2" in this document. | ||
| let cm = ed(client, {doc: "L0\nL1\nL2\n..z", selection: {anchor: 12}, extensions: [ | ||
| serverCompletion(), | ||
| autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) | ||
| ]}) | ||
| startCompletion(cm) | ||
| await wait(60) | ||
| let cs = currentCompletions(cm.state) | ||
| ist(cs.length, 1) | ||
| ist(cs[0].label, "Zap") | ||
| // Insert a line at the very top. This is far from the cursor, so CM | ||
| // keeps the completion alive — but the LSP edit's `line: 2` now refers | ||
| // to a different line in the new document. | ||
| cm.dispatch({changes: {from: 0, insert: "HEADER\n"}}) | ||
| acceptCompletion(cm) | ||
| // The "MID\n" insertion should land before the *original* L2 line, | ||
| // which after the HEADER insertion is the third line. | ||
| ist(cm.state.sliceDoc(), "HEADER\nL0\nL1\nMID\nL2\n..Zap") | ||
| }) | ||
| }) | ||
@@ -286,0 +342,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
291017
3.61%6685
3.53%