diff --git a/src/colorService.ts b/src/colorService.ts index 73b50fd..7b226a0 100644 --- a/src/colorService.ts +++ b/src/colorService.ts @@ -171,41 +171,85 @@ function parseHex(hex: string): Color | null { } function parseRgb(value: string): Color | null { - const match = value.match( - /rgba?\(([\d\s\.]+),?\s*([\d\s\.]+),?\s*([\d\s\.]+)(?:,?\s*\/?,?\s*([\d\s\.]+))?\)/, - ); - if (!match) return null; - - const r = parseFloat(match[1]) / 255; - const g = parseFloat(match[2]) / 255; - const b = parseFloat(match[3]) / 255; - let a = 1; + const start = value.indexOf("("); + const end = value.lastIndexOf(")"); + if (start === -1 || end === -1 || end <= start) { + return null; + } + + const inner = value.slice(start + 1, end).trim(); + const [rgbPart, alphaPart] = splitAlphaPart(inner); + const parts = rgbPart.split(/[\s,]+/).filter(Boolean); + if (parts.length !== 3 && parts.length !== 4) { + return null; + } + + const r = parseFloat(parts[0]) / 255; + const g = parseFloat(parts[1]) / 255; + const b = parseFloat(parts[2]) / 255; + const a = alphaPart + ? parseAlpha(alphaPart) + : parts.length === 4 + ? parseAlpha(parts[3]) + : 1; - if (match[4]) { - a = parseFloat(match[4]); + if ([r, g, b, a].some((component) => Number.isNaN(component))) { + return null; } return { red: r, green: g, blue: b, alpha: a }; } function parseHsl(value: string): Color | null { - const match = value.match( - /hsla?\(([\d\s\.]+)(?:deg)?,?\s*([\d\s\.]+)%?,?\s*([\d\s\.]+)%?(?:,?\s*\/?,?\s*([\d\s\.]+))?\)/, - ); - if (!match) return null; - - const h = parseFloat(match[1]) / 360; - const s = parseFloat(match[2]) / 100; - const l = parseFloat(match[3]) / 100; - let a = 1; + const start = value.indexOf("("); + const end = value.lastIndexOf(")"); + if (start === -1 || end === -1 || end <= start) { + return null; + } + + const inner = value.slice(start + 1, end).trim(); + const [hslPart, alphaPart] = splitAlphaPart(inner); + const parts = hslPart.split(/[\s,]+/).filter(Boolean); + if (parts.length !== 3 && parts.length !== 4) { + return null; + } - if (match[4]) { - a = parseFloat(match[4]); + const h = parseFloat(parts[0]) / 360; + const s = parseFloat(parts[1]) / 100; + const l = parseFloat(parts[2]) / 100; + const a = alphaPart + ? parseAlpha(alphaPart) + : parts.length === 4 + ? parseAlpha(parts[3]) + : 1; + + if ([h, s, l, a].some((component) => Number.isNaN(component))) { + return null; } return hslToRgb(h, s, l, a); } +function splitAlphaPart(value: string): [string, string | null] { + const slashIndex = value.indexOf("/"); + if (slashIndex === -1) { + return [value.trim(), null]; + } + + return [ + value.slice(0, slashIndex).trim(), + value.slice(slashIndex + 1).trim(), + ]; +} + +function parseAlpha(value: string): number { + if (value.endsWith("%")) { + return parseFloat(value) / 100; + } + + return parseFloat(value); +} + function hslToRgb(h: number, s: number, l: number, a: number): Color { let r, g, b; @@ -231,6 +275,14 @@ function hslToRgb(h: number, s: number, l: number, a: number): Color { return { red: r, green: g, blue: b, alpha: a }; } +export function getNormalizedColorKey(color: Color): string { + return formatColorAsHex(color).toLowerCase(); +} + +export function colorsEqual(a: Color, b: Color): boolean { + return getNormalizedColorKey(a) === getNormalizedColorKey(b); +} + function parseNamedColor(name: string): Color | null { const colors: { [key: string]: string } = { black: "#000000", diff --git a/src/colorVariableFeature.ts b/src/colorVariableFeature.ts new file mode 100644 index 0000000..1b77f92 --- /dev/null +++ b/src/colorVariableFeature.ts @@ -0,0 +1,151 @@ +import { + CodeAction, + CodeActionKind, + CompletionItem, + CompletionItemKind, + Diagnostic, + DiagnosticSeverity, + Position, + Range, + TextEdit, +} from "vscode-languageserver/node"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { CssColorLiteral, CssVariable, CssVariableManager } from "./cssVariableManager"; + +export const COLOR_REPLACEMENT_DIAGNOSTIC_CODE = "replace-with-css-variable"; + +export interface ColorReplacementDiagnosticData { + kind: typeof COLOR_REPLACEMENT_DIAGNOSTIC_CODE; + variableNames: string[]; +} + +export interface CompletionDisplayOptions { + formatLocation(uri: string): string; +} + +export function collectColorReplacementDiagnostics( + document: TextDocument, + cssVariableManager: CssVariableManager +): Diagnostic[] { + return cssVariableManager + .getDocumentColorLiterals(document.uri) + .flatMap((literal) => { + const matches = getMatchingVariables(literal, cssVariableManager); + if (matches.length === 0) { + return []; + } + + const variableNames = matches.map((match) => match.name); + const message = + variableNames.length === 1 + ? `Literal color can be replaced with matching CSS variable '${variableNames[0]}'` + : `Literal color can be replaced with ${variableNames.length} matching CSS variables`; + + return [ + { + severity: DiagnosticSeverity.Information, + range: literal.range, + message, + source: "css-variable-lsp", + code: COLOR_REPLACEMENT_DIAGNOSTIC_CODE, + data: { + kind: COLOR_REPLACEMENT_DIAGNOSTIC_CODE, + variableNames, + } satisfies ColorReplacementDiagnosticData, + }, + ]; + }); +} + +export function getColorReplacementCompletionItems( + document: TextDocument, + position: Position, + cssVariableManager: CssVariableManager, + displayOptions: CompletionDisplayOptions +): CompletionItem[] { + const literal = findColorLiteralAtPosition(document, position, cssVariableManager); + if (!literal) { + return []; + } + + return getMatchingVariables(literal, cssVariableManager).map((match) => + createColorReplacementCompletionItem(document, literal.range, match, displayOptions) + ); +} + +export function getColorReplacementCodeActions( + document: TextDocument, + diagnostics: Diagnostic[] +): CodeAction[] { + const actions: CodeAction[] = []; + + for (const diagnostic of diagnostics) { + if (diagnostic.code !== COLOR_REPLACEMENT_DIAGNOSTIC_CODE) { + continue; + } + + const data = diagnostic.data as ColorReplacementDiagnosticData | undefined; + const variableNames = data?.variableNames || []; + + for (const variableName of variableNames) { + actions.push({ + title: `Replace with var(${variableName})`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + edit: { + changes: { + [document.uri]: [ + TextEdit.replace(diagnostic.range, `var(${variableName})`), + ], + }, + }, + }); + } + } + + return actions; +} + +function findColorLiteralAtPosition( + document: TextDocument, + position: Position, + cssVariableManager: CssVariableManager +): CssColorLiteral | null { + const offset = document.offsetAt(position); + + for (const literal of cssVariableManager.getDocumentColorLiterals(document.uri)) { + const start = document.offsetAt(literal.range.start); + const end = document.offsetAt(literal.range.end); + if (offset >= start && offset <= end) { + return literal; + } + } + + return null; +} + +function getMatchingVariables( + literal: CssColorLiteral, + cssVariableManager: CssVariableManager +): CssVariable[] { + return cssVariableManager.getVariablesByColor(literal.color, { + excludeName: literal.variableName, + }); +} + +function createColorReplacementCompletionItem( + document: TextDocument, + range: Range, + match: CssVariable, + displayOptions: CompletionDisplayOptions +): CompletionItem { + return { + label: `var(${match.name})`, + kind: CompletionItemKind.Variable, + detail: match.value, + documentation: `Defined in ${displayOptions.formatLocation(match.uri)}`, + textEdit: TextEdit.replace(range, `var(${match.name})`), + filterText: `var(${match.name})`, + sortText: match.name, + }; +} diff --git a/src/cssVariableManager.ts b/src/cssVariableManager.ts index 1999a06..02abaa9 100644 --- a/src/cssVariableManager.ts +++ b/src/cssVariableManager.ts @@ -7,7 +7,7 @@ import * as csstree from "css-tree"; import { DOMTree, DOMNodeInfo } from "./domTree"; import { parse } from "node-html-parser"; import { Color } from "vscode-languageserver/node"; -import { parseColor } from "./colorService"; +import { getNormalizedColorKey, parseColor } from "./colorService"; import { calculateSpecificity, compareSpecificity } from "./specificity"; import * as path from "path"; @@ -33,6 +33,15 @@ export interface CssVariableUsage { domNode?: DOMNodeInfo; // DOM node if usage is in HTML } +export interface CssColorLiteral { + uri: string; + range: Range; + value: string; + color: Color; + propertyName: string; + variableName?: string; +} + export interface Logger { log(message: string): void; error(message: string): void; @@ -117,6 +126,7 @@ function normalizeUri(uri: string): string { export class CssVariableManager { private variables: Map = new Map(); private usages: Map = new Map(); + private colorLiterals: Map = new Map(); private domTrees: Map = new Map(); // URI -> DOM tree private logger: Logger; private lookupFiles: string[]; @@ -452,6 +462,16 @@ export class CssVariableManager { } } + if (node.type === "Declaration" && node.value) { + this.collectColorLiteralsFromDeclaration( + node, + uri, + document, + text, + offset + ); + } + if (node.type === "Function" && node.name === "var") { const children = node.children; if (children && children.first) { @@ -612,6 +632,16 @@ export class CssVariableManager { } } + if (node.type === "Declaration" && node.value) { + this.collectColorLiteralsFromDeclaration( + node, + uri, + document, + text, + offset + ); + } + if (node.type === "Function" && node.name === "var") { const children = node.children; if (children && children.first) { @@ -669,6 +699,111 @@ export class CssVariableManager { } } + private collectColorLiteralsFromDeclaration( + declaration: csstree.Declaration, + uri: string, + document: TextDocument, + text: string, + offset: number + ): void { + const literals = this.colorLiterals.get(normalizeUri(uri)) || []; + + this.collectColorLiteralsFromValue( + declaration.value, + uri, + document, + text, + offset, + declaration.property, + literals + ); + + if (declaration.value.type === "Raw" && declaration.value.loc) { + try { + const rawAst = csstree.parse(declaration.value.value, { + context: "value", + positions: true, + }); + this.collectColorLiteralsFromValue( + rawAst, + uri, + document, + declaration.value.value, + offset + declaration.value.loc.start.offset, + declaration.property, + literals + ); + } catch (error) { + this.logger.log( + `[css-lsp] Raw value parse error in ${uri}: ${String(error)}` + ); + } + } + + this.colorLiterals.set(normalizeUri(uri), literals); + } + + private collectColorLiteralsFromValue( + valueNode: csstree.CssNode, + uri: string, + document: TextDocument, + sourceText: string, + baseOffset: number, + propertyName: string, + literals: CssColorLiteral[] + ): void { + csstree.walk(valueNode, { + enter: (node: csstree.CssNode) => { + if (node.type === "Function" && node.name === "var") { + return csstree.walk.skip; + } + + if ( + node.type !== "Hash" && + node.type !== "Function" && + node.type !== "Identifier" + ) { + return; + } + + if (!node.loc) { + return; + } + + const value = csstree.generate(node).trim(); + const color = parseColor(value, { allowNamedColors: true }); + if (!color) { + return; + } + + const rawValueText = sourceText.substring( + node.loc.start.offset, + node.loc.end.offset + ); + const leadingWhitespace = + rawValueText.length - rawValueText.trimStart().length; + const trailingWhitespace = + rawValueText.length - rawValueText.trimEnd().length; + + const startOffset = + baseOffset + node.loc.start.offset + leadingWhitespace; + const endOffset = baseOffset + node.loc.end.offset - trailingWhitespace; + + literals.push({ + uri, + range: Range.create( + document.positionAt(startOffset), + document.positionAt(endOffset) + ), + value, + color, + propertyName, + variableName: propertyName.startsWith("--") ? propertyName : undefined, + }); + }, + }); + } + public async updateFile(uri: string): Promise { try { const filePath = URI.parse(uri).fsPath; @@ -703,6 +838,7 @@ export class CssVariableManager { const normalizedUri = normalizeUri(uri); this.clearDocumentVariables(normalizedUri); this.clearDocumentUsages(normalizedUri); + this.clearDocumentColorLiterals(normalizedUri); this.clearDocumentDOMTree(normalizedUri); } @@ -734,6 +870,10 @@ export class CssVariableManager { } } + public clearDocumentColorLiterals(uri: string): void { + this.colorLiterals.delete(normalizeUri(uri)); + } + public clearDocumentDOMTree(uri: string): void { this.domTrees.delete(uri); } @@ -755,6 +895,10 @@ export class CssVariableManager { return this.usages.get(name) || []; } + public getDocumentColorLiterals(uri: string): CssColorLiteral[] { + return this.colorLiterals.get(normalizeUri(uri)) || []; + } + /** * Get all references (definitions + usages) for a variable */ @@ -787,6 +931,32 @@ export class CssVariableManager { return this.domTrees.get(uri); } + public getVariablesByColor( + color: Color, + options: { excludeName?: string } = {} + ): CssVariable[] { + const key = getNormalizedColorKey(color); + const matches: CssVariable[] = []; + + for (const name of this.variables.keys()) { + if (options.excludeName && name === options.excludeName) { + continue; + } + + const resolvedColor = this.resolveVariableColor(name); + if (!resolvedColor || getNormalizedColorKey(resolvedColor) !== key) { + continue; + } + + const winningDefinition = this.getWinningVariableDefinition(name); + if (winningDefinition) { + matches.push(winningDefinition); + } + } + + return matches.toSorted((a, b) => a.name.localeCompare(b.name)); + } + /** * Resolve a variable name to a Color if possible. * Handles recursive variable references: var(--a) -> var(--b) -> #fff @@ -809,44 +979,49 @@ export class CssVariableManager { // Apply CSS cascade rules to find the winning definition // Sort by cascade rules: !important > specificity > source order - const sortedVars = [...variables].sort((a, b) => { - // !important always wins (unless both are !important) + const variable = this.getWinningVariableDefinition(name); + if (!variable) { + return null; + } + let value = variable.value; + + // Check if it's a reference to another variable + const recursiveMatch = value.match( + /var\(\s*(--[\w-]+)\s*(?:,\s*[^)]+)?\s*\)/ + ); + if (recursiveMatch) { + return this.resolveVariableColor(recursiveMatch[1], context, seen); + } + + return parseColor(value, { allowNamedColors: true }); + } + + private getWinningVariableDefinition(name: string): CssVariable | null { + const variables = this.getVariables(name); + if (variables.length === 0) { + return null; + } + + return [...variables].sort((a, b) => { if (a.important !== b.important) { return a.important ? -1 : 1; } - // Inline styles win over non-inline styles const aInline = a.inline ?? false; const bInline = b.inline ?? false; if (aInline !== bInline) { return aInline ? -1 : 1; } - // After !important, check specificity const specA = calculateSpecificity(a.selector); const specB = calculateSpecificity(b.selector); const specCompare = compareSpecificity(specA, specB); if (specCompare !== 0) { - return -specCompare; // Negative for descending order + return -specCompare; } - // Equal specificity - later in source wins return b.sourcePosition - a.sourcePosition; - }); - - // Use the winning definition (first after sort) - const variable = sortedVars[0]; - let value = variable.value; - - // Check if it's a reference to another variable - const recursiveMatch = value.match( - /var\(\s*(--[\w-]+)\s*(?:,\s*[^)]+)?\s*\)/ - ); - if (recursiveMatch) { - return this.resolveVariableColor(recursiveMatch[1], context, seen); - } - - return parseColor(value, { allowNamedColors: true }); + })[0]; } } diff --git a/src/initialize.ts b/src/initialize.ts index 4f2170e..b18ee10 100644 --- a/src/initialize.ts +++ b/src/initialize.ts @@ -12,7 +12,7 @@ export function buildInitializeResult( textDocumentSync: TextDocumentSyncKind.Incremental, completionProvider: { resolveProvider: true, - triggerCharacters: ["-"], + triggerCharacters: ["-", "#", "("], }, definitionProvider: true, hoverProvider: true, @@ -21,6 +21,7 @@ export function buildInitializeResult( documentSymbolProvider: true, workspaceSymbolProvider: true, colorProvider: enableColorProvider, + codeActionProvider: true, }, }; diff --git a/src/server.ts b/src/server.ts index 772cec1..c4f04ef 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { + CodeAction, createConnection, TextDocuments, Diagnostic, @@ -28,6 +29,11 @@ import { collectColorPresentations, collectDocumentColors, } from "./colorProvider"; +import { + collectColorReplacementDiagnostics, + getColorReplacementCodeActions, + getColorReplacementCompletionItems, +} from "./colorVariableFeature"; import { buildInitializeResult } from "./initialize"; import { formatUriForDisplay, toNormalizedFsPath } from "./pathDisplay"; import { buildRuntimeConfig } from "./runtimeConfig"; @@ -259,6 +265,10 @@ async function validateTextDocument(textDocument: TextDocument): Promise { } } + diagnostics.push( + ...collectColorReplacementDiagnostics(textDocument, cssVariableManager), + ); + // Send diagnostics to the client connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); } @@ -448,51 +458,60 @@ connection.onCompletion( document, textDocumentPosition.position, ); - if (!completionContext) { - return []; - } - - const propertyName = completionContext.propertyName; - const variables = cssVariableManager.getAllVariables(); - // Deduplicate by name - const uniqueVars = new Map(); - variables.forEach((v) => { - if (!uniqueVars.has(v.name)) { - uniqueVars.set(v.name, v); - } - }); + if (completionContext) { + const propertyName = completionContext.propertyName; - // Score and filter variables based on property context - const scoredVars = Array.from(uniqueVars.values()).map((v) => ({ - variable: v, - score: scoreVariableRelevance(v.name, propertyName), - })); - - // Filter out score 0 (not relevant) and sort by score (higher first) - const filteredAndSorted = scoredVars - .filter((sv) => sv.score !== 0) - .sort((a, b) => { - // Sort by score (descending) - if (a.score !== b.score) { - return b.score - a.score; + const variables = cssVariableManager.getAllVariables(); + const uniqueVars = new Map(); + variables.forEach((v) => { + if (!uniqueVars.has(v.name)) { + uniqueVars.set(v.name, v); } - // Same score: alphabetical order - return a.variable.name.localeCompare(b.variable.name); }); - return filteredAndSorted.map((sv) => ({ - label: sv.variable.name, - kind: CompletionItemKind.Variable, - detail: sv.variable.value, - documentation: `Defined in ${formatUriForDisplay(sv.variable.uri, { - mode: runtimeConfig.pathDisplayMode, - abbrevLength: runtimeConfig.pathDisplayAbbrevLength, - workspaceFolderPaths, - rootFolderPath, - })}`, - insertText: sv.variable.name, - })); + const scoredVars = Array.from(uniqueVars.values()).map((v) => ({ + variable: v, + score: scoreVariableRelevance(v.name, propertyName), + })); + + const filteredAndSorted = scoredVars + .filter((sv) => sv.score !== 0) + .sort((a, b) => { + if (a.score !== b.score) { + return b.score - a.score; + } + return a.variable.name.localeCompare(b.variable.name); + }); + + return filteredAndSorted.map((sv) => ({ + label: sv.variable.name, + kind: CompletionItemKind.Variable, + detail: sv.variable.value, + documentation: `Defined in ${formatUriForDisplay(sv.variable.uri, { + mode: runtimeConfig.pathDisplayMode, + abbrevLength: runtimeConfig.pathDisplayAbbrevLength, + workspaceFolderPaths, + rootFolderPath, + })}`, + insertText: sv.variable.name, + })); + } + + return getColorReplacementCompletionItems( + document, + textDocumentPosition.position, + cssVariableManager, + { + formatLocation: (uri) => + formatUriForDisplay(uri, { + mode: runtimeConfig.pathDisplayMode, + abbrevLength: runtimeConfig.pathDisplayAbbrevLength, + workspaceFolderPaths, + rootFolderPath, + }), + }, + ); }, ); @@ -768,6 +787,15 @@ connection.onRenameRequest((params) => { return null; }); +connection.onCodeAction((params): CodeAction[] => { + const document = documents.get(params.textDocument.uri); + if (!document) { + return []; + } + + return getColorReplacementCodeActions(document, params.context.diagnostics); +}); + // Document symbols handler connection.onDocumentSymbol((params) => { const document = documents.get(params.textDocument.uri); diff --git a/tests/colorVariableFeature.test.ts b/tests/colorVariableFeature.test.ts new file mode 100644 index 0000000..3137db3 --- /dev/null +++ b/tests/colorVariableFeature.test.ts @@ -0,0 +1,150 @@ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { DiagnosticSeverity } from "vscode-languageserver/node"; +import { CssVariableManager } from "../src/cssVariableManager"; +import { + collectColorReplacementDiagnostics, + getColorReplacementCodeActions, + getColorReplacementCompletionItems, +} from "../src/colorVariableFeature"; + +function createDoc(uri: string, content: string, languageId: string = "css") { + return TextDocument.create(uri, languageId, 1, content); +} + +test("variables match by normalized color value", () => { + const manager = new CssVariableManager(); + manager.parseContent( + ":root { --white: #fff; --paper: rgb(255 255 255); --accent: #0d6efd; }", + "file:///vars.css", + "css" + ); + + const matches = manager.getVariablesByColor( + { red: 1, green: 1, blue: 1, alpha: 1 }, + {} + ); + + assert.deepEqual( + matches.map((match) => match.name), + ["--paper", "--white"] + ); +}); + +test("document color literals are collected from compound values and skip var()", () => { + const manager = new CssVariableManager(); + const doc = createDoc( + "file:///test.css", + ".card { background: linear-gradient(#fff, rgb(255 255 255), var(--paper)); box-shadow: 0 0 2px white; }" + ); + + manager.parseDocument(doc); + const literals = manager.getDocumentColorLiterals(doc.uri); + + assert.deepEqual( + literals.map((literal) => literal.value), + ["#fff", "rgb(255 255 255)", "white"] + ); +}); + +test("diagnostics are informational and preserve multiple variable options", () => { + const manager = new CssVariableManager(); + manager.parseContent( + ":root { --white: #fff; --paper: rgb(255 255 255); }", + "file:///vars.css", + "css" + ); + const doc = createDoc("file:///test.css", ".title { color: white; }"); + + manager.parseDocument(doc); + const diagnostics = collectColorReplacementDiagnostics(doc, manager); + + assert.strictEqual(diagnostics.length, 1); + assert.strictEqual(diagnostics[0].severity, DiagnosticSeverity.Information); + assert.match(diagnostics[0].message, /2 matching CSS variables/); +}); + +test("diagnostics exclude self-references inside matching custom property definitions", () => { + const manager = new CssVariableManager(); + manager.parseContent( + ":root { --white: #fff; --paper: #fff; }", + "file:///vars.css", + "css" + ); + const doc = createDoc("file:///test.css", ":root { --white: #fff; }"); + + manager.parseDocument(doc); + const diagnostics = collectColorReplacementDiagnostics(doc, manager); + + assert.strictEqual(diagnostics.length, 1); + assert.match(diagnostics[0].message, /'--paper'/); + assert.doesNotMatch(diagnostics[0].message, /'--white'/); +}); + +test("completion items replace the full literal color token", () => { + const manager = new CssVariableManager(); + manager.parseContent(":root { --white: #fff; }", "file:///vars.css", "css"); + + const source = ".title { color: white; }"; + const doc = createDoc("file:///test.css", source); + manager.parseDocument(doc); + + const completions = getColorReplacementCompletionItems( + doc, + doc.positionAt(source.indexOf("white") + 2), + manager, + { formatLocation: (uri) => uri } + ); + + assert.strictEqual(completions.length, 1); + assert.strictEqual(completions[0].label, "var(--white)"); + assert.deepEqual(completions[0].textEdit, { + range: { + start: doc.positionAt(source.indexOf("white")), + end: doc.positionAt(source.indexOf("white") + "white".length), + }, + newText: "var(--white)", + }); +}); + +test("code actions return one quick fix per matching variable", () => { + const manager = new CssVariableManager(); + manager.parseContent( + ":root { --white: #fff; --paper: rgb(255 255 255); }", + "file:///vars.css", + "css" + ); + const source = ".title { color: white; }"; + const doc = createDoc("file:///test.css", source); + + manager.parseDocument(doc); + const diagnostics = collectColorReplacementDiagnostics(doc, manager); + const actions = getColorReplacementCodeActions(doc, diagnostics); + + assert.strictEqual(actions.length, 2); + assert.deepEqual( + actions.map((action) => action.title), + ["Replace with var(--paper)", "Replace with var(--white)"] + ); +}); + +test("literal detection works in html and less documents", () => { + const manager = new CssVariableManager(); + const htmlDoc = createDoc( + "file:///test.html", + '
', + "html" + ); + const lessDoc = createDoc( + "file:///test.less", + ".box { color: white; border-color: #fff; }", + "less" + ); + + manager.parseDocument(htmlDoc); + manager.parseDocument(lessDoc); + + assert.ok(manager.getDocumentColorLiterals(htmlDoc.uri).length >= 2); + assert.strictEqual(manager.getDocumentColorLiterals(lessDoc.uri).length, 2); +}); diff --git a/tests/initialize.test.ts b/tests/initialize.test.ts index aed779d..cb1e0f9 100644 --- a/tests/initialize.test.ts +++ b/tests/initialize.test.ts @@ -26,3 +26,9 @@ test("initialize result keeps incremental sync", () => { assert.equal(result.capabilities.textDocumentSync, TextDocumentSyncKind.Incremental); }); + +test("initialize result enables code actions", () => { + const result = buildInitializeResult(true, false); + + assert.equal(result.capabilities.codeActionProvider, true); +}); diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index ab2117f..055e9eb 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/colorprovider.ts","./src/colorservice.ts","./src/cssvariablemanager.ts","./src/domtree.ts","./src/initialize.ts","./src/pathdisplay.ts","./src/runtimeconfig.ts","./src/server.ts","./src/specificity.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/colorprovider.ts","./src/colorservice.ts","./src/colorvariablefeature.ts","./src/completioncontext.ts","./src/cssvariablemanager.ts","./src/domtree.ts","./src/initialize.ts","./src/pathdisplay.ts","./src/runtimeconfig.ts","./src/server.ts","./src/specificity.ts"],"version":"5.9.3"} \ No newline at end of file