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; /**