From ddc88a62d0a3f7e59a1dfc32902a15363fc897ac Mon Sep 17 00:00:00 2001 From: SPGoding Date: Mon, 20 May 2024 06:00:32 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20VS=20Code=20not=20showing?= =?UTF-8?q?=20multi-line=20completions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test-out/util/toLS.spec.js | 146 ++++++++++++++++++ packages/language-server/src/util/toLS.ts | 116 ++++++++++++-- .../language-server/test/util/toLS.spec.ts | 27 ++++ packages/vscode-extension/package.json | 6 +- 4 files changed, 283 insertions(+), 12 deletions(-) create mode 100644 __snapshots__/packages/language-server/test-out/util/toLS.spec.js create mode 100644 packages/language-server/test/util/toLS.spec.ts diff --git a/__snapshots__/packages/language-server/test-out/util/toLS.spec.js b/__snapshots__/packages/language-server/test-out/util/toLS.spec.js new file mode 100644 index 000000000..fa2ed0635 --- /dev/null +++ b/__snapshots__/packages/language-server/test-out/util/toLS.spec.js @@ -0,0 +1,146 @@ +exports['toLS.completionItem() Should map correctly when cursor is in first line 1'] = { + "label": "advancement", + "textEdit": { + "newText": "advancement", + "insert": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 1 + } + }, + "replace": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 4 + } + } + }, + "insertTextFormat": 2, + "insertTextMode": 2, + "additionalTextEdits": [ + { + "range": { + "start": { + "line": 0, + "character": 4 + }, + "end": { + "line": 2, + "character": 2 + } + }, + "newText": "" + } + ] +} + +exports['toLS.completionItem() Should map correctly when cursor is in second line 1'] = { + "label": "advancement", + "filterText": "an\\", + "textEdit": { + "newText": "advancement", + "insert": { + "start": { + "line": 1, + "character": 0 + }, + "end": { + "line": 1, + "character": 1 + } + }, + "replace": { + "start": { + "line": 1, + "character": 0 + }, + "end": { + "line": 1, + "character": 3 + } + } + }, + "insertTextFormat": 2, + "insertTextMode": 2, + "additionalTextEdits": [ + { + "range": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 1, + "character": 0 + } + }, + "newText": "" + }, + { + "range": { + "start": { + "line": 1, + "character": 3 + }, + "end": { + "line": 2, + "character": 2 + } + }, + "newText": "" + } + ] +} + +exports['toLS.completionItem() Should map correctly when cursor is in third line 1'] = { + "label": "advancement", + "filterText": "ce", + "textEdit": { + "newText": "advancement", + "insert": { + "start": { + "line": 2, + "character": 0 + }, + "end": { + "line": 2, + "character": 1 + } + }, + "replace": { + "start": { + "line": 2, + "character": 0 + }, + "end": { + "line": 2, + "character": 2 + } + } + }, + "insertTextFormat": 2, + "insertTextMode": 2, + "additionalTextEdits": [ + { + "range": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 2, + "character": 0 + } + }, + "newText": "" + } + ] +} diff --git a/packages/language-server/src/util/toLS.ts b/packages/language-server/src/util/toLS.ts index 8fd4b5d47..baee52a9a 100644 --- a/packages/language-server/src/util/toLS.ts +++ b/packages/language-server/src/util/toLS.ts @@ -218,28 +218,124 @@ export function completionItem( insertReplaceSupport: boolean | undefined, ): ls.CompletionItem { const insertText = completion.insertText ?? completion.label - const canInsertReplace = insertReplaceSupport && - ![core.CR, core.LF, core.CRLF].includes(insertText) - const textEdit: ls.TextEdit | ls.InsertReplaceEdit = canInsertReplace + + /** + * When VS Code receives a list of `CompletionItem`s, it filters the items + * by checking if they start with a prefix. The prefix is the content between + * the start of the item's ranges (which is required to be the same position + * for insert range and replace range) and the cursor. It additionally + * imposes the restrictions that (1) the prefix must be within one line and + * (2) the prefix range must contain the cursor. + * (See also VS Code's documentation for [vscode.CompletionItem#range](https://github.com/microsoft/vscode/blob/47b1183dfda519c32d78b591cfa721d3b0b874ae/src/vs/vscode.d.ts#L3803)) + * + * Spyglass can provide `CompletionItem`s that have `textEdit` ranges + * spanning multiple lines in cases such as a mcfunction literal with a + * backslash and newline in itself. Such ranges cannot be used as the prefix + * by VS Code and will cause no completions getting displayed to the user. + * + * To fix the problem, this function breaks down the actual range of the + * `CompletionItem` into a `leadingRange` (that may be `undefined` or + * multi-line), a `filterRange` (that is for VS Code's prefix matching + * filtering, is single-line and contains the cursor), and a `trailingRange` + * (that may be `undefined` or multi-line). The `filterRange` will be used as + * the ranges in the main `textEdit` property of the `CompletionItem`, where + * the `insertText` will be edited into, and the `leadingRange` and + * `trailingRange` will be used in `additionalTextEdits` to delete their + * contents. This will result in the same net edit as a single `TextEdit` + * that replaces the content of the actual range with `insertText`. + */ + function breakDownRange(): { + leadingRange: ls.Range | undefined + filterRange: ls.Range + trailingRange: ls.Range | undefined + } { + const fullRange = range(completion.range, doc) + + // Find the `ls.Position` of the last and the next newline characters + // before the cursor within the replace range, if any. + const originalText = doc.getText(fullRange) + // Indexing (instead of iterating) here is appropriate, since LSP offset + // is calculated by UTF-16 code units, not by Unicode code points. + const lastLFRelativeOffset = originalText.slice(0, requestedOffset) + .lastIndexOf(core.LF) + const filterLineStartPosition = lastLFRelativeOffset === -1 + ? undefined + : doc.positionAt(completion.range.start + lastLFRelativeOffset + 1) + const nextCRLFRelativeOffset = originalText.indexOf( + core.CRLF, + requestedOffset, + ) + const nextLFRelativeOffset = originalText.indexOf( + core.LF, + requestedOffset, + ) + const nextNewlineRelativeOffset = nextCRLFRelativeOffset !== -1 && + nextCRLFRelativeOffset < nextLFRelativeOffset + ? nextCRLFRelativeOffset + : nextLFRelativeOffset + const filterLineEndPosition = nextNewlineRelativeOffset === -1 + ? undefined + : doc.positionAt(completion.range.start + nextNewlineRelativeOffset) + + // Break down the full range according to the two newline positions. + return { + leadingRange: filterLineStartPosition + ? ls.Range.create(fullRange.start, filterLineStartPosition) + : undefined, + // Clamp `filterRange` between the two newlines to make sure it is + // single-line. + filterRange: ls.Range.create( + filterLineStartPosition ?? fullRange.start, + filterLineEndPosition ?? fullRange.end, + ), + trailingRange: filterLineEndPosition + ? ls.Range.create(filterLineEndPosition, fullRange.end) + : undefined, + } + } + + const { leadingRange, filterRange, trailingRange } = breakDownRange() + const textEdit: ls.TextEdit | ls.InsertReplaceEdit = insertReplaceSupport ? ls.InsertReplaceEdit.create( insertText, - /* insert */ range( - core.Range.create(completion.range.start, requestedOffset), - doc, - ), - /* replace */ range(completion.range, doc), + ls.Range.create(filterRange.start, doc.positionAt(requestedOffset)), + filterRange, ) - : ls.TextEdit.replace(range(completion.range, doc), insertText) + : ls.TextEdit.replace(filterRange, insertText) const ans: ls.CompletionItem = { label: completion.label, kind: completion.kind, detail: completion.detail, documentation: completion.documentation, - filterText: completion.filterText, + /* + * When the `filterRange` starts at the start of the actual range, the + * text content in `filterRange` is the actual prefix of the completed + * value and VS Code filtering will work properly. + * + * However, when there is a `leadingRange`, the text content in the + * `filterRange` will be in the middle of the completed value. VS Code's + * prefix matching will fail, resulting in this `CompletionItem` not + * getting displayed to the user. This is fixed by overwriting the + * `filterText` to be the exact value as the content of `filterRange` so + * that the `CompletionItem`s will be shown. + * + * TODO: Improve mcfunction completers so that they keep the user's insane + * backslashes and we can implement our own proper prefix checking here + * to make the filtering and sorting more correct. + */ + filterText: leadingRange + ? doc.getText(filterRange) + : completion.filterText, sortText: completion.sortText, textEdit, insertTextFormat: InsertTextFormat.Snippet, insertTextMode: ls.InsertTextMode.adjustIndentation, + additionalTextEdits: (leadingRange || trailingRange) + ? [ + ...leadingRange ? [ls.TextEdit.del(leadingRange)] : [], + ...trailingRange ? [ls.TextEdit.del(trailingRange)] : [], + ] + : undefined, ...(completion.deprecated ? { tags: [ls.CompletionItemTag.Deprecated] } : {}), diff --git a/packages/language-server/test/util/toLS.spec.ts b/packages/language-server/test/util/toLS.spec.ts new file mode 100644 index 000000000..431836f4c --- /dev/null +++ b/packages/language-server/test/util/toLS.spec.ts @@ -0,0 +1,27 @@ +import * as core from '@spyglassmc/core' +import { toLS } from '@spyglassmc/language-server/lib/util/index.js' +import { describe, it } from 'mocha' +import snapshot from 'snap-shot-it' +import { TextDocument } from 'vscode-languageserver-textdocument' + +describe('toLS.completionItem()', () => { + const doc = TextDocument.create( + 'spyglassmc:///test.mcfunction', + 'mcfunction', + 0, + 'adv\\\nan\\\nce', + ) + const item = core.CompletionItem.create( + 'advancement', + core.Range.create(0, 11), + ) + it('Should map correctly when cursor is in first line', () => { + snapshot(toLS.completionItem(item, doc, 1, true)) + }) + it('Should map correctly when cursor is in second line', () => { + snapshot(toLS.completionItem(item, doc, 6, true)) + }) + it('Should map correctly when cursor is in third line', () => { + snapshot(toLS.completionItem(item, doc, 10, true)) + }) +}) diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 054dc11b8..4e38f6723 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -191,10 +191,12 @@ "editor.semanticHighlighting.enabled": true }, "[mcdoc]": { - "editor.semanticHighlighting.enabled": true + "editor.semanticHighlighting.enabled": true, + "editor.suggest.insertMode": "replace" }, "[mcfunction]": { - "editor.semanticHighlighting.enabled": true + "editor.semanticHighlighting.enabled": true, + "editor.suggest.insertMode": "replace" }, "editor.semanticTokenColorCustomizations": { "rules": {