From 3ebd8612b66d01ac5302386e9adb8f4579197b09 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sat, 14 Sep 2024 13:59:36 +0200 Subject: [PATCH] fix: use textEdit for variable insertion The edge cases in different editors with regards to word boundaries has been too high (even for syntaxes in the same editor). Since perhaps not all clients support it, adopting this without checking for clientCapabilities would be a breaking change. We keep the existing behavior for backwards compatibility. --- .../src/features/do-complete.ts | 233 +++++++++++------- .../src/language-services-types.ts | 17 +- 2 files changed, 152 insertions(+), 98 deletions(-) diff --git a/packages/language-services/src/features/do-complete.ts b/packages/language-services/src/features/do-complete.ts index 3a2718d8..d7a56e5c 100644 --- a/packages/language-services/src/features/do-complete.ts +++ b/packages/language-services/src/features/do-complete.ts @@ -1,4 +1,8 @@ -import { getNodeAtOffset } from "@somesass/vscode-css-languageservice"; +import { + getNodeAtOffset, + Range, + TextEdit, +} from "@somesass/vscode-css-languageservice"; import ColorDotJS from "colorjs.io"; import { ParseResult } from "sassdoc-parser"; import { SassBuiltInModule, sassBuiltInModules } from "../facts/sass"; @@ -67,7 +71,9 @@ const rePlaceholderDeclaration = /^\s*%/; const rePartialModuleAtRule = /@(?:use|forward|import) ["']/; type CompletionContext = { + position: Position; currentWord: string; + lineBeforePosition: string; namespace?: string; isMixinContext?: boolean; isFunctionContext?: boolean; @@ -300,7 +306,7 @@ export class DoComplete extends LanguageFeature { const items = await this.doVariableCompletion( document, currentDocument, - currentWord, + context, symbol, isPrivate, ); @@ -378,48 +384,40 @@ export class DoComplete extends LanguageFeature { } const currentWord = text.substring(i + 1, offset); + const context: CompletionContext = { + position, + currentWord, + lineBeforePosition, + }; + if (rePartialModuleAtRule.test(lineBeforePosition)) { - return { - currentWord, - isImportContext: true, - }; + context.isImportContext = true; + return context; } if (reSassDoc.test(lineBeforePosition)) { - return { - currentWord, - isSassdocContext: true, - }; + context.isSassdocContext = true; + return context; } if (reComment.test(lineBeforePosition)) { - return { - currentWord, - isCommentContext: true, - }; + context.isCommentContext = true; + return context; } if (rePlaceholder.test(lineBeforePosition)) { - return { - currentWord, - isPlaceholderContext: true, - }; + context.isPlaceholderContext = true; + return context; } if (rePlaceholderDeclaration.test(lineBeforePosition)) { - return { - currentWord, - isPlaceholderDeclarationContext: true, - }; + context.isPlaceholderDeclarationContext = true; + return context; } const isInterpolation = currentWord.includes("#{") || lineBeforePosition.includes("#{"); - const context: CompletionContext = { - currentWord, - }; - if (isInterpolation) { context.isFunctionContext = true; context.isVariableContext = true; @@ -528,7 +526,10 @@ export class DoComplete extends LanguageFeature { const items: CompletionItem[] = []; for (const symbol of symbols) { if (symbol.kind === SymbolKind.Class && symbol.name.startsWith("%")) { - const item: CompletionItem = this.toCompletionItem(document, symbol); + const item: CompletionItem = this.toPlaceholderCompletionItem( + document, + symbol, + ); items.push(item); } } @@ -549,7 +550,10 @@ export class DoComplete extends LanguageFeature { const symbols = this.ls.findDocumentSymbols(current); for (const symbol of symbols) { if (symbol.kind === SymbolKind.Class && symbol.name.startsWith("%")) { - const item: CompletionItem = this.toCompletionItem(current, symbol); + const item: CompletionItem = this.toPlaceholderCompletionItem( + current, + symbol, + ); items.push(item); } } @@ -559,7 +563,10 @@ export class DoComplete extends LanguageFeature { return items; } - private toCompletionItem(document: TextDocument, symbol: SassDocumentSymbol) { + private toPlaceholderCompletionItem( + document: TextDocument, + symbol: SassDocumentSymbol, + ) { const filterText = symbol.name.substring(1); let documentation = symbol.name; @@ -568,6 +575,8 @@ export class DoComplete extends LanguageFeature { documentation += `\n____\n${sassdoc}`; } + // TODO: textedit-variant + const detail = `Placeholder declared in ${this.getFileName(document.uri)}`; const item: CompletionItem = { @@ -605,6 +614,7 @@ export class DoComplete extends LanguageFeature { // if the node parent is an @extend reference, meaning a placeholder usage. for (const child of symbol.children) { if (child.kind === SymbolKind.Class && child.name.startsWith("%")) { + // TODO: textedit-variant const filterText = child.name.substring(1); items.push({ filterText, @@ -722,10 +732,9 @@ export class DoComplete extends LanguageFeature { const vars = await this.doVariableCompletion( document, currentDocument, - context.currentWord, + context, symbol, isPrivate, - context.namespace, prefix, ); if (vars.length > 0) { @@ -805,25 +814,25 @@ export class DoComplete extends LanguageFeature { private async doVariableCompletion( initialDocument: TextDocument, currentDocument: TextDocument, - currentWord: string, + context: CompletionContext, symbol: SassDocumentSymbol, isPrivate: boolean, - namespace = "", prefix = "", ): Promise { - // Avoid ending up with namespace.prefix-$variable - const label = `$${prefix}${asDollarlessVariable(symbol.name)}`; const rawValue = this.getVariableValue(currentDocument, symbol); let value = await this.findValue( currentDocument, symbol.selectionRange.start, ); value = value || rawValue; + const color = value ? getColorValue(value) : null; const completionKind = color ? CompletionItemKind.Color : CompletionItemKind.Variable; + // Avoid ending up with namespace.prefix-$variable + const label = `$${prefix}${asDollarlessVariable(symbol.name)}`; let documentation = color || [ @@ -838,65 +847,123 @@ export class DoComplete extends LanguageFeature { } documentation += `\n____\nVariable declared in ${this.getFileName(currentDocument.uri)}`; + let insertText: string = label; + let filterText: string | undefined; const sortText = isPrivate ? label.replace(/^$[_]/, "") : undefined; - const dotExt = initialDocument.uri.slice( - Math.max(0, initialDocument.uri.lastIndexOf(".")), - ); - const isEmbedded = !dotExt.match(reSassDotExt); - let insertText: string | undefined; - let filterText: string | undefined; + if ( + this.options.clientCapabilities?.textDocument?.completion?.completionItem + ?.insertReplaceSupport + ) { + if (context.namespace && context.namespace !== "*") { + insertText = `${context.namespace}.${label}`; + filterText = `${context.namespace}.${label}`; + } - if (namespace && namespace !== "*") { - const noDot = - isEmbedded || - dotExt === ".sass" || - this.configuration.completionSettings?.afterModule === ""; + const range = this.getInsertRange(context, insertText); + + const item: CompletionItem = { + commitCharacters: [";", ","], + documentation: + completionKind === CompletionItemKind.Color + ? documentation + : { + kind: MarkupKind.Markdown, + value: documentation, + }, + filterText, + kind: completionKind, + textEdit: TextEdit.replace(range, insertText), + label, + sortText, + tags: symbol.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], + }; + return [item]; + } else { + // This method is kept for backwards compatibility only. + // It might be removed in a major version. + // If editors are having trouble with the completion items made + // by this code, they should update to support completion items + // with text edits added in 3.16 of the LSP spec. + + const dotExt = initialDocument.uri.slice( + Math.max(0, initialDocument.uri.lastIndexOf(".")), + ); + const isEmbedded = !dotExt.match(reSassDotExt); + let insertText: string | undefined; + let filterText: string | undefined; - const noDollar = isEmbedded; + const { namespace, currentWord } = context; - insertText = currentWord.endsWith(".") - ? `${noDot ? "" : "."}${label}` - : noDollar - ? asDollarlessVariable(label) - : label; + if (namespace && namespace !== "*") { + const noDot = + isEmbedded || + dotExt === ".sass" || + this.configuration.completionSettings?.afterModule === ""; - if ( - this.configuration.completionSettings?.afterModule && - this.configuration.completionSettings.afterModule.startsWith("{module}") + const noDollar = isEmbedded; + + insertText = currentWord.endsWith(".") + ? `${noDot ? "" : "."}${label}` + : noDollar + ? asDollarlessVariable(label) + : label; + + if ( + this.configuration.completionSettings?.afterModule && + this.configuration.completionSettings.afterModule.startsWith( + "{module}", + ) + ) { + insertText = `${namespace}${insertText}`; + } + + filterText = currentWord.endsWith(".") + ? `${namespace}.${label}` + : label; + } else if ( + dotExt === ".vue" || + dotExt === ".astro" || + dotExt === ".sass" || + this.configuration.completionSettings?.beforeVariable === "" ) { - insertText = `${namespace}${insertText}`; + // In these languages the $ does not get replaced by the suggestion, + // so exclude it from the insertText. + insertText = asDollarlessVariable(label); } - filterText = currentWord.endsWith(".") ? `${namespace}.${label}` : label; - } else if ( - dotExt === ".vue" || - dotExt === ".astro" || - dotExt === ".sass" || - this.configuration.completionSettings?.beforeVariable === "" - ) { - // In these languages the $ does not get replaced by the suggestion, - // so exclude it from the insertText. - insertText = asDollarlessVariable(label); + const item: CompletionItem = { + commitCharacters: [";", ","], + documentation: + completionKind === CompletionItemKind.Color + ? documentation + : { + kind: MarkupKind.Markdown, + value: documentation, + }, + filterText, + insertText, + kind: completionKind, + label, + sortText, + tags: symbol.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], + }; + + return [item]; } + } - const item: CompletionItem = { - commitCharacters: [";", ","], - documentation: - completionKind === CompletionItemKind.Color - ? documentation - : { - kind: MarkupKind.Markdown, - value: documentation, - }, - filterText, - kind: completionKind, - label, - insertText, - sortText, - tags: symbol.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], - }; - return [item]; + getInsertRange(context: CompletionContext, insertText: string): Range { + const { position, currentWord } = context; + const start = Position.create( + position.line, + position.character - currentWord.length, + ); + const end = Position.create( + position.line, + start.character + insertText.length, + ); + return Range.create(start, end); } private isEmbedded(initialDocument: TextDocument) { diff --git a/packages/language-services/src/language-services-types.ts b/packages/language-services/src/language-services-types.ts index f86d97ca..85da7f85 100644 --- a/packages/language-services/src/language-services-types.ts +++ b/packages/language-services/src/language-services-types.ts @@ -286,6 +286,8 @@ export interface ClientCapabilities { completion?: { completionItem?: { documentationFormat?: MarkupKind[]; + insertReplaceSupport?: boolean; + snippetSupport?: boolean; }; }; hover?: { @@ -294,21 +296,6 @@ export interface ClientCapabilities { }; } -export namespace ClientCapabilities { - export const LATEST: ClientCapabilities = { - textDocument: { - completion: { - completionItem: { - documentationFormat: [MarkupKind.Markdown, MarkupKind.PlainText], - }, - }, - hover: { - contentFormat: [MarkupKind.Markdown, MarkupKind.PlainText], - }, - }, - }; -} - export interface LanguageServiceOptions { clientCapabilities: ClientCapabilities; /**