diff --git a/packages/language-services/src/features/__tests__/do-complete-modules.test.ts b/packages/language-services/src/features/__tests__/do-complete-modules.test.ts index 5b8be8cf..1e53e394 100644 --- a/packages/language-services/src/features/__tests__/do-complete-modules.test.ts +++ b/packages/language-services/src/features/__tests__/do-complete-modules.test.ts @@ -1060,8 +1060,6 @@ test("should suggest all symbols as legacy @import may be in use", async () => { { commitCharacters: [";", ","], documentation: "limegreen\n____\nVariable declared in one.scss", - filterText: undefined, - insertText: undefined, kind: CompletionItemKind.Color, label: "$primary", sortText: undefined, @@ -1127,8 +1125,6 @@ test("should suggest symbol from a different document via @use with wildcard ali { commitCharacters: [";", ","], documentation: "limegreen\n____\nVariable declared in one.scss", - filterText: undefined, - insertText: undefined, kind: CompletionItemKind.Color, label: "$primary", sortText: undefined, diff --git a/packages/language-services/src/features/do-complete.ts b/packages/language-services/src/features/do-complete.ts index 8bda7286..792efd93 100644 --- a/packages/language-services/src/features/do-complete.ts +++ b/packages/language-services/src/features/do-complete.ts @@ -133,14 +133,20 @@ export class DoComplete extends LanguageFeature { const isMixin = keyword === "@mixin"; if (isFunction || isMixin) { if (prevToken && prevToken.text.match(reNewSassdocBlock)) { - const node = getNodeAtOffset(stylesheet, token.offset); + // This makes heavy use of the snippet syntax if ( - node && - (node instanceof MixinDeclaration || - node instanceof FunctionDeclaration) + this.clientCapabilities.textDocument?.completion?.completionItem + ?.snippetSupport ) { - const item = this.doSassdocBlockCompletion(document, node); - result.items.push(item); + const node = getNodeAtOffset(stylesheet, token.offset); + if ( + node && + (node instanceof MixinDeclaration || + node instanceof FunctionDeclaration) + ) { + const item = this.doSassdocBlockCompletion(document, node); + result.items.push(item); + } } } } @@ -282,7 +288,6 @@ export class DoComplete extends LanguageFeature { // Legacy @import style suggestions if (!this.configuration.completionSettings?.suggestFromUseOnly) { - const currentWord = context.currentWord; const documents = this.cache.documents(); for (const currentDocument of documents) { if ( @@ -336,7 +341,7 @@ export class DoComplete extends LanguageFeature { const items = await this.doFunctionCompletion( document, currentDocument, - currentWord, + context, symbol, isPrivate, ); @@ -795,10 +800,9 @@ export class DoComplete extends LanguageFeature { const funcs = await this.doFunctionCompletion( document, currentDocument, - context.currentWord, + context, symbol, isPrivate, - context.namespace, prefix, ); if (funcs.length > 0) { @@ -964,6 +968,7 @@ export class DoComplete extends LanguageFeature { return [item]; } + // TODO: make this generate the replace textEdit instead, it's the same pattern everywhere getInsertRange(context: CompletionContext, insertText: string): Range { const { position, currentWord } = context; const start = Position.create( @@ -1037,6 +1042,10 @@ export class DoComplete extends LanguageFeature { let detail: string | undefined; let insert = label; + if (context.namespace && context.namespace !== "*") { + insert = `${context.namespace}.${label}`; + base.filterText = `${context.namespace}.${label}`; + } const makeCompletionVariants = () => { // Not all mixins have @content, but when they do, be smart about adding brackets @@ -1225,15 +1234,19 @@ export class DoComplete extends LanguageFeature { private async doFunctionCompletion( initialDocument: TextDocument, currentDocument: TextDocument, - currentWord: string, + context: CompletionContext, symbol: SassDocumentSymbol, isPrivate: boolean, - namespace = "", prefix = "", ): Promise { const items: CompletionItem[] = []; + const snippetSupport = + this.clientCapabilities.textDocument?.completion?.completionItem + ?.snippetSupport; const label = `${prefix}${symbol.name}`; + + const { namespace } = context; let filterText = symbol.name; if (namespace) { if (namespace === "*") { @@ -1243,29 +1256,6 @@ export class DoComplete extends LanguageFeature { } } - const isEmbedded = this.isEmbedded(initialDocument); - - const noDot = - namespace === "*" || - isEmbedded || - initialDocument.languageId === "sass" || - this.configuration.completionSettings?.afterModule === ""; - - let insertText = namespace - ? noDot - ? `${prefix}${symbol.name}` - : `.${prefix}${symbol.name}` - : symbol.name; - - if ( - namespace && - namespace !== "*" && - this.configuration.completionSettings?.afterModule && - this.configuration.completionSettings.afterModule.startsWith("{module}") - ) { - insertText = `${namespace}${insertText}`; - } - const sortText = isPrivate ? label.replace(/^$[_]/, "") : undefined; const documentation = { @@ -1289,37 +1279,143 @@ export class DoComplete extends LanguageFeature { .map((p) => mapParameterSignature(p)) .join(", "); - const item: CompletionItem = { - documentation, - filterText, - kind: CompletionItemKind.Function, - label, - labelDetails: { detail: `(${detail})` }, - insertText: `${insertText}(${parametersSnippet})`, - insertTextFormat: InsertTextFormat.Snippet, - sortText, - tags: symbol.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], - }; - items.push(item); + if ( + this.clientCapabilities.textDocument?.completion?.completionItem + ?.insertReplaceSupport + ) { + const base: CompletionItem = { + documentation, + filterText, + label, + labelDetails: { detail: `(${detail})` }, + sortText, + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Function, + tags: symbol.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], + }; - if (requiredParameters.length !== parameters.length) { - const parametersSnippet = parameters - .map((p, i) => mapParameterSnippet(p, i, symbol.sassdoc)) - .join(", "); - const detail = parameters.map((p) => mapParameterSignature(p)).join(", "); + let insert = label; + if (context.namespace && context.namespace !== "*") { + insert = `${context.namespace}.${label}`; + base.filterText = `${context.namespace}.${label}`; + } + + if (!snippetSupport) { + const insertText = `${insert}()`; + const item: CompletionItem = { + ...base, + textEdit: TextEdit.replace( + this.getInsertRange(context, insertText), + insertText, + ), + insertTextFormat: InsertTextFormat.PlainText, + }; + items.push(item); + } else { + const insertText = `${insert}(${parametersSnippet})`; + const item: CompletionItem = { + ...base, + textEdit: TextEdit.replace( + this.getInsertRange(context, insertText), + insertText, + ), + }; + items.push(item); + + if (requiredParameters.length !== parameters.length) { + const parametersSnippet = parameters + .map((p, i) => mapParameterSnippet(p, i, symbol.sassdoc)) + .join(", "); + const detail = parameters + .map((p) => mapParameterSignature(p)) + .join(", "); + + const insertText = `${insert}(${parametersSnippet})`; + const item: CompletionItem = { + ...base, + labelDetails: { detail: `(${detail})` }, + textEdit: TextEdit.replace( + this.getInsertRange(context, insertText), + insertText, + ), + }; + items.push(item); + } + } + } else { + // This method is kept for backwards compatibility. + // It might be removed or simplified in a major version, placing + // the responsibility for correctly treating the word boundary + // on the editor. + // 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 isEmbedded = this.isEmbedded(initialDocument); + + const noDot = + namespace === "*" || + isEmbedded || + initialDocument.languageId === "sass" || + this.configuration.completionSettings?.afterModule === ""; - const item: CompletionItem = { + let insertText = namespace + ? noDot + ? `${prefix}${symbol.name}` + : `.${prefix}${symbol.name}` + : symbol.name; + + if ( + namespace && + namespace !== "*" && + this.configuration.completionSettings?.afterModule && + this.configuration.completionSettings.afterModule.startsWith("{module}") + ) { + insertText = `${namespace}${insertText}`; + } + + const base: CompletionItem = { documentation, filterText, - kind: CompletionItemKind.Function, label, labelDetails: { detail: `(${detail})` }, - insertText: `${insertText}(${parametersSnippet})`, - insertTextFormat: InsertTextFormat.Snippet, sortText, + kind: CompletionItemKind.Function, tags: symbol.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], }; - items.push(item); + + if (!snippetSupport) { + const item: CompletionItem = { + ...base, + insertText: `${insertText}()`, + insertTextFormat: InsertTextFormat.PlainText, + }; + items.push(item); + } else { + const item: CompletionItem = { + ...base, + insertText: `${insertText}(${parametersSnippet})`, + insertTextFormat: InsertTextFormat.Snippet, + }; + items.push(item); + + if (requiredParameters.length !== parameters.length) { + const parametersSnippet = parameters + .map((p, i) => mapParameterSnippet(p, i, symbol.sassdoc)) + .join(", "); + const detail = parameters + .map((p) => mapParameterSignature(p)) + .join(", "); + + const item: CompletionItem = { + ...base, + labelDetails: { detail: `(${detail})` }, + insertText: `${insertText}(${parametersSnippet})`, + insertTextFormat: InsertTextFormat.Snippet, + }; + items.push(item); + } + } } return items; @@ -1332,6 +1428,10 @@ export class DoComplete extends LanguageFeature { prefix: string = "", ): CompletionItem[] { const items: CompletionItem[] = []; + const snippetSupport = + this.clientCapabilities.textDocument?.completion?.completionItem + ?.snippetSupport; + for (const [name, docs] of Object.entries(moduleDocs.exports)) { const { description, signature, parameterSnippet, returns } = docs; const kind = signature @@ -1349,39 +1449,16 @@ export class DoComplete extends LanguageFeature { // Client needs the namespace as part of the text that is matched, const filterText = `${context.namespace}.${label}`; - // Inserted text needs to include the `.` which will otherwise - // be replaced (except when we're embedded in Vue, Svelte or Astro). - // Example result: .floor(${1:number}) - const isEmbedded = this.isEmbedded(document); - - const noDot = - isEmbedded || - document.languageId === "sass" || - this.configuration.completionSettings?.afterModule === ""; - - let insertText = context.currentWord.includes(".") - ? `${noDot ? "" : "."}${label}${ - signature ? `(${parameterSnippet})` : "" - }` - : label; - - if ( - this.configuration.completionSettings?.afterModule && - this.configuration.completionSettings.afterModule.startsWith("{module}") - ) { - insertText = `${context.namespace}${insertText}`; - } - - items.push({ + const base: CompletionItem = { documentation: { kind: MarkupKind.Markdown, value: `${description}\n\n[Sass documentation](${moduleDocs.reference}#${name})`, }, filterText, - insertText, - insertTextFormat: parameterSnippet - ? InsertTextFormat.Snippet - : InsertTextFormat.PlainText, + insertTextFormat: + parameterSnippet && snippetSupport + ? InsertTextFormat.Snippet + : InsertTextFormat.PlainText, kind: signature ? CompletionItemKind.Function : CompletionItemKind.Variable, @@ -1390,7 +1467,59 @@ export class DoComplete extends LanguageFeature { detail: signature && returns ? `${signature} => ${returns}` : signature, }, - }); + }; + + if ( + this.clientCapabilities.textDocument?.completion?.completionItem + ?.insertReplaceSupport + ) { + let insert = `${context.namespace}.${label}`; + items.push({ + ...base, + textEdit: TextEdit.replace( + this.getInsertRange(context, insert), + insert, + ), + }); + } else { + // This method is kept for backwards compatibility. + // It might be removed or simplified in a major version, placing + // the responsibility for correctly treating the word boundary + // on the editor. + // 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. + + // Inserted text needs to include the `.` which will otherwise + // be replaced (except when we're embedded in Vue, Svelte or Astro). + // Example result: .floor(${1:number}) + const isEmbedded = this.isEmbedded(document); + + const noDot = + isEmbedded || + document.languageId === "sass" || + this.configuration.completionSettings?.afterModule === ""; + + let insertText = context.currentWord.includes(".") + ? `${noDot ? "" : "."}${label}${ + signature ? `(${snippetSupport ? parameterSnippet : ""})` : "" + }` + : label; + + if ( + this.configuration.completionSettings?.afterModule && + this.configuration.completionSettings.afterModule.startsWith( + "{module}", + ) + ) { + insertText = `${context.namespace}${insertText}`; + } + + items.push({ + ...base, + insertText, + }); + } } return items; diff --git a/packages/language-services/src/utils/test-helpers.ts b/packages/language-services/src/utils/test-helpers.ts index 792624cb..b45671e9 100644 --- a/packages/language-services/src/utils/test-helpers.ts +++ b/packages/language-services/src/utils/test-helpers.ts @@ -126,7 +126,10 @@ export function getOptions(): LanguageServiceOptions & { clientCapabilities: { textDocument: { completion: { - completionItem: { documentationFormat: ["markdown", "plaintext"] }, + completionItem: { + snippetSupport: true, + documentationFormat: ["markdown", "plaintext"], + }, }, hover: { contentFormat: ["markdown", "plaintext"],