Skip to content

Commit

Permalink
fix: use textEdit for variable insertion
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
wkillerud committed Sep 14, 2024
1 parent 22a1e91 commit 3ebd861
Show file tree
Hide file tree
Showing 2 changed files with 152 additions and 98 deletions.
233 changes: 150 additions & 83 deletions packages/language-services/src/features/do-complete.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -300,7 +306,7 @@ export class DoComplete extends LanguageFeature {
const items = await this.doVariableCompletion(
document,
currentDocument,
currentWord,
context,
symbol,
isPrivate,
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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);
}
}
Expand All @@ -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;
Expand All @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<CompletionItem[]> {
// 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 ||
[
Expand All @@ -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) {
Expand Down
17 changes: 2 additions & 15 deletions packages/language-services/src/language-services-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ export interface ClientCapabilities {
completion?: {
completionItem?: {
documentationFormat?: MarkupKind[];
insertReplaceSupport?: boolean;
snippetSupport?: boolean;
};
};
hover?: {
Expand All @@ -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;
/**
Expand Down

0 comments on commit 3ebd861

Please sign in to comment.