Skip to content

Commit f2ee3fd

Browse files
authored
fix: recursively resolve custom properties in no-invalid-properties (eslint#237)
* fix: recursively resolve custom properties in no-invalid-properties * add algorithm explanation and cache documentation
1 parent 623ad8e commit f2ee3fd

File tree

2 files changed

+390
-89
lines changed

2 files changed

+390
-89
lines changed

src/rules/no-invalid-properties.js

Lines changed: 178 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,27 @@ import { isSyntaxMatchError, isSyntaxReferenceError } from "../util.js";
2525
// Helpers
2626
//-----------------------------------------------------------------------------
2727

28+
/**
29+
* Regex to match var() functional notation with optional fallback.
30+
*/
31+
const varFunctionPattern = /var\(\s*(--[^,\s)]+)\s*(?:,\s*(.+))?\)/iu;
32+
33+
/**
34+
* Parses a var() function text and extracts the custom property name and fallback.
35+
* @param {string} text
36+
* @returns {{ name: string, fallbackText: string | null } | null}
37+
*/
38+
function parseVarFunction(text) {
39+
const match = text.match(varFunctionPattern);
40+
if (!match) {
41+
return null;
42+
}
43+
return {
44+
name: match[1].trim(),
45+
fallbackText: match[2]?.trim(),
46+
};
47+
}
48+
2849
/**
2950
* Extracts the list of fallback value or variable name used in a `var()` that is used as fallback function.
3051
* For example, for `var(--my-color, var(--fallback-color, red));` it will return `["--fallback-color", "red"]`.
@@ -36,31 +57,26 @@ function getVarFallbackList(value) {
3657
let currentValue = value;
3758

3859
while (true) {
39-
const match = currentValue.match(
40-
/var\(\s*(--[^,\s)]+)\s*(?:,\s*(.+))?\)/iu,
41-
);
60+
const parsed = parseVarFunction(currentValue);
4261

43-
if (!match) {
62+
if (!parsed) {
4463
break;
4564
}
4665

47-
const prop = match[1].trim();
48-
const fallback = match[2]?.trim();
49-
50-
list.push(prop);
66+
list.push(parsed.name);
5167

52-
if (!fallback) {
68+
if (!parsed.fallbackText) {
5369
break;
5470
}
5571

5672
// If fallback is not another var(), we're done
57-
if (!fallback.toLowerCase().includes("var(")) {
58-
list.push(fallback);
73+
if (!parsed.fallbackText.toLowerCase().includes("var(")) {
74+
list.push(parsed.fallbackText);
5975
break;
6076
}
6177

6278
// Continue parsing from fallback
63-
currentValue = fallback;
79+
currentValue = parsed.fallbackText;
6480
}
6581

6682
return list;
@@ -124,6 +140,111 @@ export default {
124140

125141
const [{ allowUnknownVariables }] = context.options;
126142

143+
/**
144+
* Iteratively resolves CSS variable references until a value is found.
145+
* @param {string} variableName The variable name to resolve
146+
* @param {Map<string, string>} cache Cache for memoization within a single resolution scope
147+
* @param {Set<string>} [seen] Set of already seen variables to detect cycles
148+
* @returns {string|null} The resolved value or null if not found
149+
*/
150+
function resolveVariable(variableName, cache, seen = new Set()) {
151+
/** @type {Array<string>} */
152+
const fallbackStack = [];
153+
let currentVarName = variableName;
154+
155+
/*
156+
* Resolves a CSS variable by following its reference chain.
157+
*
158+
* Phase 1: Follow var() references
159+
* - Use `seen` to detect cycles
160+
* - Use `cache` for memoization
161+
* - If value is concrete: cache and return
162+
* - If value is another var(--next, <fallback>):
163+
* push fallback to stack and continue with --next
164+
* - If variable unknown: proceed to Phase 2
165+
*
166+
* Phase 2: Try fallback values (if Phase 1 failed)
167+
* - Process fallbacks in reverse order (LIFO)
168+
* - Resolve each via resolveFallback()
169+
* - Return first successful resolution
170+
*/
171+
while (true) {
172+
if (seen.has(currentVarName)) {
173+
break;
174+
}
175+
seen.add(currentVarName);
176+
177+
if (cache.has(currentVarName)) {
178+
return cache.get(currentVarName);
179+
}
180+
181+
const valueNode = vars.get(currentVarName);
182+
if (!valueNode) {
183+
break;
184+
}
185+
186+
const valueText = sourceCode.getText(valueNode).trim();
187+
const parsed = parseVarFunction(valueText);
188+
189+
if (!parsed) {
190+
cache.set(currentVarName, valueText);
191+
return valueText;
192+
}
193+
194+
if (parsed.fallbackText) {
195+
fallbackStack.push(parsed.fallbackText);
196+
}
197+
currentVarName = parsed.name;
198+
}
199+
200+
while (fallbackStack.length > 0) {
201+
const fallbackText = fallbackStack.pop();
202+
// eslint-disable-next-line no-use-before-define -- resolveFallback and resolveVariable are mutually recursive
203+
const resolvedFallback = resolveFallback(
204+
fallbackText,
205+
cache,
206+
seen,
207+
);
208+
if (resolvedFallback !== null) {
209+
return resolvedFallback;
210+
}
211+
}
212+
213+
return null;
214+
}
215+
216+
/**
217+
* Resolves a fallback text which can contain nested var() calls.
218+
* Returns the first resolvable value or null if none resolve.
219+
* @param {string} rawFallbackText
220+
* @param {Map<string, string>} cache Cache for memoization within a single resolution scope
221+
* @param {Set<string>} [seen] Set of already seen variables to detect cycles
222+
* @returns {string | null}
223+
*/
224+
function resolveFallback(rawFallbackText, cache, seen = new Set()) {
225+
const fallbackVarList = getVarFallbackList(rawFallbackText);
226+
if (fallbackVarList.length === 0) {
227+
return rawFallbackText;
228+
}
229+
230+
for (const fallbackCandidate of fallbackVarList) {
231+
if (fallbackCandidate.startsWith("--")) {
232+
const resolved = resolveVariable(
233+
fallbackCandidate,
234+
cache,
235+
seen,
236+
);
237+
if (resolved !== null) {
238+
return resolved;
239+
}
240+
continue;
241+
}
242+
return fallbackCandidate.trim();
243+
}
244+
245+
return null;
246+
}
247+
127248
return {
128249
"Rule > Block > Declaration"() {
129250
replacements.push(new Map());
@@ -161,6 +282,12 @@ export default {
161282

162283
if (usingVars) {
163284
const valueList = [];
285+
/**
286+
* Cache for resolved variable values within this single declaration.
287+
* Prevents re-resolving the same variable and re-walking long `var()` chains.
288+
* @type {Map<string,string>}
289+
*/
290+
const resolvedCache = new Map();
164291
const valueNodes = node.value.children;
165292

166293
// When `var()` is used, we store all the values to `valueList` with the replacement of `var()` with there values or fallback values
@@ -174,12 +301,28 @@ export default {
174301

175302
// If the variable is found, use its value, otherwise check for fallback values
176303
if (varValue) {
177-
const varValueText = sourceCode
178-
.getText(varValue)
179-
.trim();
180-
181-
valueList.push(varValueText);
182-
valuesWithVarLocs.set(varValueText, child.loc);
304+
const resolvedValue = resolveVariable(
305+
child.children[0].name,
306+
resolvedCache,
307+
);
308+
if (resolvedValue !== null) {
309+
valueList.push(resolvedValue);
310+
valuesWithVarLocs.set(
311+
resolvedValue,
312+
child.loc,
313+
);
314+
} else {
315+
if (!allowUnknownVariables) {
316+
context.report({
317+
loc: child.children[0].loc,
318+
messageId: "unknownVar",
319+
data: {
320+
var: child.children[0].name,
321+
},
322+
});
323+
return;
324+
}
325+
}
183326
} else {
184327
// If the variable is not found and doesn't have a fallback value, report it
185328
if (child.children.length === 1) {
@@ -197,81 +340,27 @@ export default {
197340
} else {
198341
// If it has a fallback value, use that
199342
if (child.children[2].type === "Raw") {
200-
const fallbackVarList =
201-
getVarFallbackList(
202-
child.children[2].value.trim(),
343+
const raw =
344+
child.children[2].value.trim();
345+
const resolvedFallbackValue =
346+
resolveFallback(raw, resolvedCache);
347+
if (resolvedFallbackValue !== null) {
348+
valueList.push(
349+
resolvedFallbackValue,
203350
);
204-
if (fallbackVarList.length > 0) {
205-
let gotFallbackVarValue = false;
206-
207-
for (const fallbackVar of fallbackVarList) {
208-
if (
209-
fallbackVar.startsWith("--")
210-
) {
211-
const fallbackVarValue =
212-
vars.get(fallbackVar);
213-
214-
if (!fallbackVarValue) {
215-
continue; // Try the next fallback
216-
}
217-
218-
valueList.push(
219-
sourceCode
220-
.getText(
221-
fallbackVarValue,
222-
)
223-
.trim(),
224-
);
225-
valuesWithVarLocs.set(
226-
sourceCode
227-
.getText(
228-
fallbackVarValue,
229-
)
230-
.trim(),
231-
child.loc,
232-
);
233-
gotFallbackVarValue = true;
234-
break; // Stop after finding the first valid variable
235-
} else {
236-
const fallbackValue =
237-
fallbackVar.trim();
238-
valueList.push(
239-
fallbackValue,
240-
);
241-
valuesWithVarLocs.set(
242-
fallbackValue,
243-
child.loc,
244-
);
245-
gotFallbackVarValue = true;
246-
break; // Stop after finding the first non-variable fallback
247-
}
248-
}
249-
250-
// If none of the fallback value is defined then report an error
251-
if (
252-
!allowUnknownVariables &&
253-
!gotFallbackVarValue
254-
) {
255-
context.report({
256-
loc: child.children[0].loc,
257-
messageId: "unknownVar",
258-
data: {
259-
var: child.children[0]
260-
.name,
261-
},
262-
});
263-
264-
return;
265-
}
266-
} else {
267-
// if it has a fallback value, use that
268-
const fallbackValue =
269-
child.children[2].value.trim();
270-
valueList.push(fallbackValue);
271351
valuesWithVarLocs.set(
272-
fallbackValue,
352+
resolvedFallbackValue,
273353
child.loc,
274354
);
355+
} else if (!allowUnknownVariables) {
356+
context.report({
357+
loc: child.children[0].loc,
358+
messageId: "unknownVar",
359+
data: {
360+
var: child.children[0].name,
361+
},
362+
});
363+
return;
275364
}
276365
}
277366
}

0 commit comments

Comments
 (0)