diff --git a/__snapshots__/packages/language-server/test-out/util/toLS.spec.js b/__snapshots__/packages/language-server/test-out/util/toLS.spec.js index 856d1bc5a..e5cd1e502 100644 --- a/__snapshots__/packages/language-server/test-out/util/toLS.spec.js +++ b/__snapshots__/packages/language-server/test-out/util/toLS.spec.js @@ -1,4 +1,150 @@ -exports['semanticTokens Tokenize "foo" with multiline token support 1'] = [ +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": "" + } + ] +} +exports['toLS semanticTokens Tokenize "foo" with multiline token support 1'] = [ { "deltaLine": 0, "deltaStartChar": 0, @@ -8,7 +154,7 @@ exports['semanticTokens Tokenize "foo" with multiline token support 1'] = [ } ] -exports['semanticTokens Tokenize "foo" without multiline token support 1'] = [ +exports['toLS semanticTokens Tokenize "foo" without multiline token support 1'] = [ { "deltaLine": 0, "deltaStartChar": 0, @@ -18,7 +164,7 @@ exports['semanticTokens Tokenize "foo" without multiline token support 1'] = [ } ] -exports['semanticTokens Tokenize "foo↓bar" with multiline token support 1'] = [ +exports['toLS semanticTokens Tokenize "foo↓bar" with multiline token support 1'] = [ { "deltaLine": 0, "deltaStartChar": 0, @@ -28,7 +174,7 @@ exports['semanticTokens Tokenize "foo↓bar" with multiline token support 1'] = } ] -exports['semanticTokens Tokenize "foo↓bar" without multiline token support 1'] = [ +exports['toLS semanticTokens Tokenize "foo↓bar" without multiline token support 1'] = [ { "deltaLine": 0, "deltaStartChar": 0, @@ -45,7 +191,7 @@ exports['semanticTokens Tokenize "foo↓bar" without multiline token support 1'] } ] -exports['semanticTokens Tokenize "foo↓bar↓qux" with multiline token support 1'] = [ +exports['toLS semanticTokens Tokenize "foo↓bar↓qux" with multiline token support 1'] = [ { "deltaLine": 0, "deltaStartChar": 0, @@ -55,7 +201,7 @@ exports['semanticTokens Tokenize "foo↓bar↓qux" with multiline token support } ] -exports['semanticTokens Tokenize "foo↓bar↓qux" without multiline token support 1'] = [ +exports['toLS semanticTokens Tokenize "foo↓bar↓qux" without multiline token support 1'] = [ { "deltaLine": 0, "deltaStartChar": 0, diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 23b78516d..1cd194a79 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -31,6 +31,7 @@ let capabilities!: ls.ClientCapabilities let workspaceFolders!: ls.WorkspaceFolder[] let hasShutdown = false let progressReporter: ls.WorkDoneProgressReporter | undefined +let initializationOptions: CustomInitializationOptions | undefined const externals = NodeJsExternals const logger: core.Logger = { @@ -46,7 +47,7 @@ const logger: core.Logger = { let service!: core.Service connection.onInitialize(async (params) => { - const initializationOptions = params.initializationOptions as + initializationOptions = params.initializationOptions as | CustomInitializationOptions | undefined @@ -261,6 +262,7 @@ connection.onCompletion( offset, capabilities.textDocument?.completion?.completionItem ?.insertReplaceSupport, + initializationOptions?.hasFlawedMultiLineCompletionItemFiltering, ) ) }, diff --git a/packages/language-server/src/util/toLS.ts b/packages/language-server/src/util/toLS.ts index f083d4a99..7b4d879b4 100644 --- a/packages/language-server/src/util/toLS.ts +++ b/packages/language-server/src/util/toLS.ts @@ -216,30 +216,133 @@ export function completionItem( doc: TextDocument, requestedOffset: number, insertReplaceSupport: boolean | undefined, + hasFlawedMultiLineSupport: 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) + + if (!hasFlawedMultiLineSupport) { + // No need to break down the range. + return { filterRange: fullRange } + } + + // 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 for editors with flawed + * multi-line support. + */ + filterText: hasFlawedMultiLineSupport && 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/src/util/types.ts b/packages/language-server/src/util/types.ts index cb51bf696..1f96790e6 100644 --- a/packages/language-server/src/util/types.ts +++ b/packages/language-server/src/util/types.ts @@ -1,4 +1,9 @@ export interface CustomInitializationOptions { + /** + * Set to `true` if the text editor cannot filter `CompletionItem`s properly + * if the range of the item spans multiple lines. + */ + hasFlawedMultiLineCompletionItemFiltering?: boolean inDevelopmentMode?: boolean } diff --git a/packages/language-server/test/util/toLS.spec.ts b/packages/language-server/test/util/toLS.spec.ts index 090783ca3..816d220c2 100644 --- a/packages/language-server/test/util/toLS.spec.ts +++ b/packages/language-server/test/util/toLS.spec.ts @@ -1,9 +1,9 @@ -import type * as core from '@spyglassmc/core' +import * as core from '@spyglassmc/core' import { showWhitespaceGlyph } from '@spyglassmc/core/test-out/utils.js' import snapshot from 'snap-shot-it' import { TextDocument } from 'vscode-languageserver-textdocument' import type * as ls from 'vscode-languageserver/node.js' -import { semanticTokens } from '../../lib/util/toLS.js' +import * as toLS from '../../lib/util/toLS.js' /** * The result of decoding a semantic token from an integer list. @@ -37,7 +37,7 @@ const decodeSemanticTokens = ( return decodedTokens } -describe('semanticTokens', () => { +describe('toLS semanticTokens', () => { const tokens: core.ColorToken[] = [ { range: { @@ -62,7 +62,7 @@ describe('semanticTokens', () => { showWhitespaceGlyph(content) }" ${multilineStr}` it(itTitle, () => { - const { data } = semanticTokens( + const { data } = toLS.semanticTokens( tokens, doc, hasMultilineTokenSupport, @@ -72,3 +72,25 @@ describe('semanticTokens', () => { } } }) + +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, true)) + }) + it('Should map correctly when cursor is in second line', () => { + snapshot(toLS.completionItem(item, doc, 6, true, true)) + }) + it('Should map correctly when cursor is in third line', () => { + snapshot(toLS.completionItem(item, doc, 10, true, true)) + }) +}) diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index a139fe131..cdb7dcb5a 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": { diff --git a/packages/vscode-extension/src/extension.mts b/packages/vscode-extension/src/extension.mts index ecf50a305..31599a55c 100644 --- a/packages/vscode-extension/src/extension.mts +++ b/packages/vscode-extension/src/extension.mts @@ -53,6 +53,7 @@ export function activate(context: vsc.ExtensionContext) { ] const initializationOptions: server.CustomInitializationOptions = { + hasFlawedMultiLineCompletionItemFiltering: true, inDevelopmentMode: context.extensionMode === vsc.ExtensionMode.Development, }