Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 74 additions & 22 deletions src/colorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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",
Expand Down
151 changes: 151 additions & 0 deletions src/colorVariableFeature.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading