Skip to content

Commit afd688b

Browse files
committed
support nested variables
resolve issue #100
1 parent cfee583 commit afd688b

File tree

6 files changed

+299
-15
lines changed

6 files changed

+299
-15
lines changed

packages/css-variables-language-server/src/CSSVariableManager.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import postcssLESS from 'postcss-less';
1111
import CacheManager from './CacheManager';
1212
import isColor from './utils/isColor';
1313
import { culoriColorToVscodeColor } from './utils/culoriColorToVscodeColor';
14+
import { resolveVariableValue } from './utils/resolveVariableValue';
1415

1516
export type CSSSymbol = {
1617
name: string
@@ -174,8 +175,42 @@ export default class CSSVariableManager {
174175
);
175176
});
176177
}
178+
179+
// After all files are parsed, resolve nested variable references
180+
this.resolveAllVariableReferences();
177181
};
178182

183+
/**
184+
* Resolves nested variable references (var(--name)) for all cached variables
185+
* and updates their color property if the resolved value is a color
186+
*/
187+
private resolveAllVariableReferences() {
188+
const allVariables = this.cacheManager.getAll();
189+
190+
// Iterate through all variables and resolve their values
191+
allVariables.forEach((cssVariable, varName) => {
192+
const originalValue = cssVariable.symbol.value;
193+
194+
// Skip if already has a color (direct color value)
195+
if (cssVariable.color) {
196+
return;
197+
}
198+
199+
// Try to resolve any var() references
200+
const resolvedValue = resolveVariableValue(originalValue, allVariables);
201+
202+
// If the value was resolved (changed), try to parse it as a color
203+
if (resolvedValue !== originalValue) {
204+
const culoriColor = culori.parse(resolvedValue);
205+
206+
if (culoriColor) {
207+
// Update the color property for this variable
208+
cssVariable.color = culoriColorToVscodeColor(culoriColor);
209+
}
210+
}
211+
});
212+
}
213+
179214
public getAll() {
180215
return this.cacheManager.getAll();
181216
}
@@ -187,4 +222,12 @@ export default class CSSVariableManager {
187222
public clearAllCache() {
188223
this.cacheManager.clearAllCache();
189224
}
225+
226+
/**
227+
* Public method to trigger variable resolution
228+
* Should be called after parsing files to resolve nested variable references
229+
*/
230+
public resolveVariableReferences() {
231+
this.resolveAllVariableReferences();
232+
}
190233
}

packages/css-variables-language-server/src/index.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -151,23 +151,28 @@ documents.onDidClose((e) => {
151151
documentSettings.delete(e.document.uri);
152152
});
153153

154-
connection.onDidChangeWatchedFiles((_change) => {
154+
connection.onDidChangeWatchedFiles(async (_change) => {
155155
// update cached variables
156-
_change.changes.forEach((change) => {
157-
const filePath = uriToPath(change.uri);
158-
if (filePath) {
159-
// remove variables from cache
160-
if (change.type === FileChangeType.Deleted) {
161-
cssVariableManager.clearFileCache(filePath);
162-
} else {
163-
const content = fs.readFileSync(filePath, 'utf8');
164-
cssVariableManager.parseCSSVariablesFromText({
165-
content,
166-
filePath,
167-
});
156+
await Promise.all(
157+
_change.changes.map(async (change) => {
158+
const filePath = uriToPath(change.uri);
159+
if (filePath) {
160+
// remove variables from cache
161+
if (change.type === FileChangeType.Deleted) {
162+
cssVariableManager.clearFileCache(filePath);
163+
} else {
164+
const content = fs.readFileSync(filePath, 'utf8');
165+
await cssVariableManager.parseCSSVariablesFromText({
166+
content,
167+
filePath,
168+
});
169+
}
168170
}
169-
}
170-
});
171+
})
172+
);
173+
174+
// After all file changes are processed, resolve variable references
175+
cssVariableManager.resolveVariableReferences();
171176
});
172177

173178
// This handler provides the initial list of the completion items.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
:root {
2+
/* Cross-file references (referencing variables from main.css) */
3+
--child-color-red: var(--color-red);
4+
--child-color-blue-alias: var(--color-blue-alias);
5+
6+
/* Multi-level cross-file nesting */
7+
--child-nested: var(--color-red-alias-3);
8+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
:root {
2+
/* Direct color values */
3+
--color-red: rgb(255, 0, 0);
4+
--color-blue: #0000ff;
5+
--color-green: hsl(120, 100%, 50%);
6+
7+
/* Single-level nesting */
8+
--color-red-alias: var(--color-red);
9+
--color-blue-alias: var(--color-blue);
10+
11+
/* Two-level nesting */
12+
--color-red-alias-2: var(--color-red-alias);
13+
14+
/* Three-level nesting */
15+
--color-red-alias-3: var(--color-red-alias-2);
16+
17+
/* Four-level nesting */
18+
--color-red-alias-4: var(--color-red-alias-3);
19+
20+
/* Five-level nesting (max depth) */
21+
--color-red-alias-5: var(--color-red-alias-4);
22+
23+
/* Six-level nesting (should stop at 5) */
24+
--color-red-alias-6: var(--color-red-alias-5);
25+
26+
/* Fallback values */
27+
--undefined-with-fallback: var(--does-not-exist, #ff00ff);
28+
--nested-with-fallback: var(--also-undefined, var(--color-green));
29+
30+
/* Circular references (should not resolve) */
31+
--circular-a: var(--circular-b);
32+
--circular-b: var(--circular-a);
33+
34+
/* Complex values with multiple var() references */
35+
--shadow-x: 2px;
36+
--shadow-y: 4px;
37+
--shadow-blur: 8px;
38+
--shadow-color: rgba(0, 0, 0, 0.3);
39+
--box-shadow: var(--shadow-x) var(--shadow-y) var(--shadow-blur) var(--shadow-color);
40+
41+
/* Non-color variables (should not get color property) */
42+
--spacing: 16px;
43+
--font-size: 14px;
44+
}

packages/css-variables-language-server/src/tests/unit-tests/CSSVariableManager.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ async function runTest(
1919
expect(allVars.get('--carousel-bg').symbol.value).toEqual(
2020
'var(--main-bg-color)'
2121
);
22+
// After resolution, --carousel-bg should have a color property
23+
expect(allVars.get('--carousel-bg').color).toBeDefined();
24+
expect(allVars.get('--carousel-bg').color).toEqual(allVars.get('--main-bg-color').color);
25+
2226
expect(allVars.get('--child-main-bg-color').symbol.value).toEqual('brown');
2327
expect(allVars.get('--child-h1').symbol.value).toEqual('26px');
2428
expect(allVars.get('--child-h2').symbol.value).toEqual('22px');
@@ -27,6 +31,9 @@ async function runTest(
2731
expect(allVars.get('--child-carousel-bg').symbol.value).toEqual(
2832
'var(--main-bg-color)'
2933
);
34+
// After resolution, --child-carousel-bg should have a color property
35+
expect(allVars.get('--child-carousel-bg').color).toBeDefined();
36+
expect(allVars.get('--child-carousel-bg').color).toEqual(allVars.get('--main-bg-color').color);
3037

3138
if (typeof additionalChecks === 'function') {
3239
await additionalChecks(allVars);
@@ -108,4 +115,93 @@ describe('CSS Variable Manager', () => {
108115
expect(h1Var.symbol.value).toEqual('26px');
109116
expect(h1Var.color).toBeUndefined();
110117
});
118+
119+
test('can resolve nested variable references and detect colors', async () => {
120+
const cssManager = new CSSVariableManager();
121+
122+
await cssManager.parseAndSyncVariables([
123+
path.join(__dirname, '../fixtures/nested-var-resolution'),
124+
]);
125+
126+
const allVars = cssManager.getAll();
127+
128+
// Test direct color values
129+
expect(allVars.get('--color-red').symbol.value).toEqual('rgb(255, 0, 0)');
130+
expect(allVars.get('--color-red').color).toBeDefined();
131+
132+
expect(allVars.get('--color-blue').symbol.value).toEqual('#0000ff');
133+
expect(allVars.get('--color-blue').color).toBeDefined();
134+
135+
expect(allVars.get('--color-green').symbol.value).toEqual('hsl(120, 100%, 50%)');
136+
expect(allVars.get('--color-green').color).toBeDefined();
137+
138+
// Test single-level nesting - value is still var() but should have color property
139+
expect(allVars.get('--color-red-alias').symbol.value).toEqual('var(--color-red)');
140+
expect(allVars.get('--color-red-alias').color).toBeDefined();
141+
expect(allVars.get('--color-red-alias').color).toEqual(allVars.get('--color-red').color);
142+
143+
expect(allVars.get('--color-blue-alias').symbol.value).toEqual('var(--color-blue)');
144+
expect(allVars.get('--color-blue-alias').color).toBeDefined();
145+
expect(allVars.get('--color-blue-alias').color).toEqual(allVars.get('--color-blue').color);
146+
147+
// Test two-level nesting
148+
expect(allVars.get('--color-red-alias-2').symbol.value).toEqual('var(--color-red-alias)');
149+
expect(allVars.get('--color-red-alias-2').color).toBeDefined();
150+
expect(allVars.get('--color-red-alias-2').color).toEqual(allVars.get('--color-red').color);
151+
152+
// Test three-level nesting
153+
expect(allVars.get('--color-red-alias-3').symbol.value).toEqual('var(--color-red-alias-2)');
154+
expect(allVars.get('--color-red-alias-3').color).toBeDefined();
155+
expect(allVars.get('--color-red-alias-3').color).toEqual(allVars.get('--color-red').color);
156+
157+
// Test four-level nesting
158+
expect(allVars.get('--color-red-alias-4').symbol.value).toEqual('var(--color-red-alias-3)');
159+
expect(allVars.get('--color-red-alias-4').color).toBeDefined();
160+
expect(allVars.get('--color-red-alias-4').color).toEqual(allVars.get('--color-red').color);
161+
162+
// Test five-level nesting (max depth)
163+
expect(allVars.get('--color-red-alias-5').symbol.value).toEqual('var(--color-red-alias-4)');
164+
expect(allVars.get('--color-red-alias-5').color).toBeDefined();
165+
expect(allVars.get('--color-red-alias-5').color).toEqual(allVars.get('--color-red').color);
166+
167+
// Test six-level nesting (should stop at depth 5, cannot resolve to a color)
168+
expect(allVars.get('--color-red-alias-6').symbol.value).toEqual('var(--color-red-alias-5)');
169+
// At 6 levels, we hit the depth limit and cannot fully resolve, so no color
170+
expect(allVars.get('--color-red-alias-6').color).toBeUndefined();
171+
172+
// Test fallback values
173+
expect(allVars.get('--undefined-with-fallback').symbol.value).toEqual('var(--does-not-exist, #ff00ff)');
174+
expect(allVars.get('--undefined-with-fallback').color).toBeDefined();
175+
176+
expect(allVars.get('--nested-with-fallback').symbol.value).toEqual('var(--also-undefined, var(--color-green))');
177+
expect(allVars.get('--nested-with-fallback').color).toBeDefined();
178+
expect(allVars.get('--nested-with-fallback').color).toEqual(allVars.get('--color-green').color);
179+
180+
// Test circular references (should not have color)
181+
expect(allVars.get('--circular-a').symbol.value).toEqual('var(--circular-b)');
182+
expect(allVars.get('--circular-a').color).toBeUndefined();
183+
184+
expect(allVars.get('--circular-b').symbol.value).toEqual('var(--circular-a)');
185+
expect(allVars.get('--circular-b').color).toBeUndefined();
186+
187+
// Test non-color variables (should not have color)
188+
expect(allVars.get('--spacing').symbol.value).toEqual('16px');
189+
expect(allVars.get('--spacing').color).toBeUndefined();
190+
191+
expect(allVars.get('--font-size').symbol.value).toEqual('14px');
192+
expect(allVars.get('--font-size').color).toBeUndefined();
193+
194+
// Test cross-file resolution
195+
expect(allVars.get('--child-color-red').symbol.value).toEqual('var(--color-red)');
196+
expect(allVars.get('--child-color-red').color).toBeDefined();
197+
expect(allVars.get('--child-color-red').color).toEqual(allVars.get('--color-red').color);
198+
199+
expect(allVars.get('--child-color-blue-alias').symbol.value).toEqual('var(--color-blue-alias)');
200+
expect(allVars.get('--child-color-blue-alias').color).toBeDefined();
201+
expect(allVars.get('--child-color-blue-alias').color).toEqual(allVars.get('--color-blue').color);
202+
203+
expect(allVars.get('--child-nested').symbol.value).toEqual('var(--color-red-alias-3)');
204+
expect(allVars.get('--child-nested').color).toBeDefined();
205+
expect(allVars.get('--child-nested').color).toEqual(allVars.get('--color-red').color);
206+
});
111207
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { CSSVariable } from '../CSSVariableManager';
2+
3+
const MAX_DEPTH = 5;
4+
5+
/**
6+
* Parses a var() reference and extracts the variable name and fallback value
7+
* Examples:
8+
* "var(--color)" => { varName: "--color", fallback: undefined }
9+
* "var(--color, blue)" => { varName: "--color", fallback: "blue" }
10+
* "var(--color, var(--other))" => { varName: "--color", fallback: "var(--other)" }
11+
*/
12+
function parseVarReference(varRef: string): { varName: string; fallback?: string } | null {
13+
const match = varRef.match(/^var\(\s*(--[a-zA-Z0-9-_]+)\s*(?:,\s*(.+))?\s*\)$/);
14+
if (!match) {
15+
return null;
16+
}
17+
18+
return {
19+
varName: match[1],
20+
fallback: match[2]?.trim(),
21+
};
22+
}
23+
24+
/**
25+
* Resolves CSS variable references (var(--name)) recursively up to a maximum depth
26+
*
27+
* @param value - The CSS value that may contain var() references
28+
* @param variableMap - Map of all CSS variables for lookup
29+
* @param depth - Current recursion depth (starts at 0)
30+
* @param visited - Set of variable names already visited (for circular reference detection)
31+
* @returns The resolved value or the original value if unresolvable
32+
*/
33+
export function resolveVariableValue(
34+
value: string,
35+
variableMap: Map<string, CSSVariable>,
36+
depth: number = 0,
37+
visited: Set<string> = new Set()
38+
): string {
39+
// Stop if we've reached max depth
40+
if (depth >= MAX_DEPTH) {
41+
return value;
42+
}
43+
44+
// Check if the entire value is a single var() reference
45+
const parsed = parseVarReference(value);
46+
47+
if (parsed) {
48+
const { varName, fallback } = parsed;
49+
50+
// Check for circular reference
51+
if (visited.has(varName)) {
52+
// Circular reference detected, try fallback or return original
53+
return fallback ? resolveVariableValue(fallback, variableMap, depth + 1, visited) : value;
54+
}
55+
56+
// Look up the variable
57+
const referencedVar = variableMap.get(varName);
58+
59+
if (referencedVar) {
60+
// Add to visited set to detect cycles
61+
const newVisited = new Set(visited);
62+
newVisited.add(varName);
63+
64+
// Recursively resolve the referenced variable's value
65+
return resolveVariableValue(referencedVar.symbol.value, variableMap, depth + 1, newVisited);
66+
} else if (fallback) {
67+
// Variable not found, use fallback
68+
return resolveVariableValue(fallback, variableMap, depth + 1, visited);
69+
}
70+
71+
// Variable not found and no fallback
72+
return value;
73+
}
74+
75+
// Handle complex values with multiple var() references
76+
// e.g., "rgba(var(--r), var(--g), var(--b), 0.5)"
77+
const varPattern = /var\(\s*--[a-zA-Z0-9-_]+(?:\s*,\s*[^)]+)?\s*\)/g;
78+
79+
if (varPattern.test(value)) {
80+
return value.replace(varPattern, (match) => {
81+
// Recursively resolve each var() reference
82+
return resolveVariableValue(match, variableMap, depth + 1, visited);
83+
});
84+
}
85+
86+
// No var() references found, return as-is
87+
return value;
88+
}

0 commit comments

Comments
 (0)