From 2a7901374ee243f3bb4213f7e2b3e8e92493ad45 Mon Sep 17 00:00:00 2001 From: xbinaryx Date: Mon, 11 Aug 2025 17:20:55 +0300 Subject: [PATCH 1/2] fix: recursively resolve custom properties in no-invalid-properties --- src/rules/no-invalid-properties.js | 251 ++++++++++++++-------- tests/rules/no-invalid-properties.test.js | 212 ++++++++++++++++++ 2 files changed, 374 insertions(+), 89 deletions(-) diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js index 104d236b..7c020705 100644 --- a/src/rules/no-invalid-properties.js +++ b/src/rules/no-invalid-properties.js @@ -25,6 +25,27 @@ import { isSyntaxMatchError, isSyntaxReferenceError } from "../util.js"; // Helpers //----------------------------------------------------------------------------- +/** + * Regex to match var() functional notation with optional fallback. + */ +const varFunctionPattern = /var\(\s*(--[^,\s)]+)\s*(?:,\s*(.+))?\)/u; + +/** + * Parses a var() function text and extracts the custom property name and fallback. + * @param {string} text + * @returns {{ name: string, fallbackText: string | null } | null} + */ +function parseVarFunction(text) { + const match = text.match(varFunctionPattern); + if (!match) { + return null; + } + return { + name: match[1].trim(), + fallbackText: match[2]?.trim(), + }; +} + /** * Extracts the list of fallback value or variable name used in a `var()` that is used as fallback function. * For example, for `var(--my-color, var(--fallback-color, red));` it will return `["--fallback-color", "red"]`. @@ -36,31 +57,26 @@ function getVarFallbackList(value) { let currentValue = value; while (true) { - const match = currentValue.match( - /var\(\s*(--[^,\s)]+)\s*(?:,\s*(.+))?\)/u, - ); + const parsed = parseVarFunction(currentValue); - if (!match) { + if (!parsed) { break; } - const prop = match[1].trim(); - const fallback = match[2]?.trim(); - - list.push(prop); + list.push(parsed.name); - if (!fallback) { + if (!parsed.fallbackText) { break; } // If fallback is not another var(), we're done - if (!fallback.includes("var(")) { - list.push(fallback); + if (!parsed.fallbackText.includes("var(")) { + list.push(parsed.fallbackText); break; } // Continue parsing from fallback - currentValue = fallback; + currentValue = parsed.fallbackText; } return list; @@ -124,6 +140,99 @@ export default { const [{ allowUnknownVariables }] = context.options; + /** + * Iteratively resolves CSS variable references until a value is found. + * @param {string} variableName The variable name to resolve + * @param {Map} cache Cache for memoization within a single resolution scope + * @param {Set} [seen] Set of already seen variables to detect cycles + * @returns {string|null} The resolved value or null if not found + */ + function resolveVariable(variableName, cache, seen = new Set()) { + /** @type {Array} */ + const fallbackStack = []; + let currentVarName = variableName; + + while (true) { + if (seen.has(currentVarName)) { + break; + } + seen.add(currentVarName); + + if (cache.has(currentVarName)) { + return cache.get(currentVarName); + } + + const valueNode = vars.get(currentVarName); + if (!valueNode) { + break; + } + + const valueText = sourceCode.getText(valueNode).trim(); + const parsed = parseVarFunction(valueText); + + if (!parsed) { + cache.set(currentVarName, valueText); + return valueText; + } + + if (parsed.fallbackText) { + fallbackStack.push(parsed.fallbackText); + } + currentVarName = parsed.name; + } + + while (fallbackStack.length > 0) { + const fallbackText = fallbackStack.pop(); + if (!fallbackText) { + continue; + } + // eslint-disable-next-line no-use-before-define -- resolveFallback and resolveVariable are mutually recursive + const resolvedFallback = resolveFallback( + fallbackText, + cache, + seen, + ); + if (resolvedFallback !== null) { + return resolvedFallback; + } + } + + return null; + } + + /** + * Resolves a fallback text which can contain nested var() calls. + * Returns the first resolvable value or null if none resolve. + * @param {string} rawFallbackText + * @param {Map} cache Cache for memoization within a single resolution scope + * @param {Set} [seen] Set of already seen variables to detect cycles + * @returns {string | null} + */ + function resolveFallback(rawFallbackText, cache, seen = new Set()) { + const trimmedFallback = rawFallbackText.trim(); + const fallbackVarList = getVarFallbackList(trimmedFallback); + if (fallbackVarList.length === 0) { + return trimmedFallback; + } + + for (const fallbackCandidate of fallbackVarList) { + if (fallbackCandidate.startsWith("--")) { + const resolved = resolveVariable( + fallbackCandidate, + cache, + seen, + ); + if (resolved !== null) { + return resolved; + } + continue; + } + return fallbackCandidate.trim(); + } + + return null; + } + return { "Rule > Block > Declaration"() { replacements.push(new Map()); @@ -161,6 +270,8 @@ export default { if (usingVars) { const valueList = []; + /** @type {Map} */ + const resolvedCache = new Map(); const valueNodes = node.value.children; // When `var()` is used, we store all the values to `valueList` with the replacement of `var()` with there values or fallback values @@ -171,12 +282,28 @@ export default { // If the variable is found, use its value, otherwise check for fallback values if (varValue) { - const varValueText = sourceCode - .getText(varValue) - .trim(); - - valueList.push(varValueText); - valuesWithVarLocs.set(varValueText, child.loc); + const resolvedValue = resolveVariable( + child.children[0].name, + resolvedCache, + ); + if (resolvedValue !== null) { + valueList.push(resolvedValue); + valuesWithVarLocs.set( + resolvedValue, + child.loc, + ); + } else { + if (!allowUnknownVariables) { + context.report({ + loc: child.children[0].loc, + messageId: "unknownVar", + data: { + var: child.children[0].name, + }, + }); + return; + } + } } else { // If the variable is not found and doesn't have a fallback value, report it if (child.children.length === 1) { @@ -194,81 +321,27 @@ export default { } else { // If it has a fallback value, use that if (child.children[2].type === "Raw") { - const fallbackVarList = - getVarFallbackList( - child.children[2].value.trim(), + const raw = + child.children[2].value.trim(); + const resolvedFallbackValue = + resolveFallback(raw, resolvedCache); + if (resolvedFallbackValue !== null) { + valueList.push( + resolvedFallbackValue, ); - if (fallbackVarList.length > 0) { - let gotFallbackVarValue = false; - - for (const fallbackVar of fallbackVarList) { - if ( - fallbackVar.startsWith("--") - ) { - const fallbackVarValue = - vars.get(fallbackVar); - - if (!fallbackVarValue) { - continue; // Try the next fallback - } - - valueList.push( - sourceCode - .getText( - fallbackVarValue, - ) - .trim(), - ); - valuesWithVarLocs.set( - sourceCode - .getText( - fallbackVarValue, - ) - .trim(), - child.loc, - ); - gotFallbackVarValue = true; - break; // Stop after finding the first valid variable - } else { - const fallbackValue = - fallbackVar.trim(); - valueList.push( - fallbackValue, - ); - valuesWithVarLocs.set( - fallbackValue, - child.loc, - ); - gotFallbackVarValue = true; - break; // Stop after finding the first non-variable fallback - } - } - - // If none of the fallback value is defined then report an error - if ( - !allowUnknownVariables && - !gotFallbackVarValue - ) { - context.report({ - loc: child.children[0].loc, - messageId: "unknownVar", - data: { - var: child.children[0] - .name, - }, - }); - - return; - } - } else { - // if it has a fallback value, use that - const fallbackValue = - child.children[2].value.trim(); - valueList.push(fallbackValue); valuesWithVarLocs.set( - fallbackValue, + resolvedFallbackValue, child.loc, ); + } else if (!allowUnknownVariables) { + context.report({ + loc: child.children[0].loc, + messageId: "unknownVar", + data: { + var: child.children[0].name, + }, + }); + return; } } } diff --git a/tests/rules/no-invalid-properties.test.js b/tests/rules/no-invalid-properties.test.js index d8768749..e051b4ef 100644 --- a/tests/rules/no-invalid-properties.test.js +++ b/tests/rules/no-invalid-properties.test.js @@ -77,6 +77,20 @@ ruleTester.run("no-invalid-properties", rule, { code: ":root { --color: red }\na { border-top: 1px var(--style, var(--fallback)) var(--color, blue); }", options: [{ allowUnknownVariables: true }], }, + ":root { --a: red; --b: var(--a); }\na { color: var(--b); }", + ":root { --a: red; --b: var(--a); }\na { color: var( --b ); }", + ":root { --a: red; --b: var(--a); --c: var(--b); }\na { color: var(--c); }", + ":root { --a: red; }\na { color: var(--b, var(--a)); }", + ":root { --a: red; }\na { color: var(--b, var(--c, var(--a))); }", + ":root { --a: 1px; --b: red; --c: var(--a); --d: var(--b); }\na { border-top: var(--c) solid var(--d); }", + ":root { --a: 1px; --b: var(--a); }\na { border-top: var(--b) solid var(--c, red) }", + ":root { --a: var(--b, 10px); } a { padding: var(--a); }", + ":root { --a: var(--b, var(--c, 10px)); } a { padding: var(--a); }", + ":root { --a: var(--b, var(--c, 10px)); --b: 20px; } a { padding: var(--a); }", + { + code: ":root { --a: var(--c); --b: var(--a); }\na { color: var(--b); }", + options: [{ allowUnknownVariables: true }], + }, /* * CSSTree doesn't currently support custom functions properly, so leaving @@ -592,5 +606,203 @@ ruleTester.run("no-invalid-properties", rule, { }, ], }, + { + code: ":root { --a: var(--b); }\na { color: var(--a); }", + errors: [ + { + messageId: "unknownVar", + data: { var: "--a" }, + line: 2, + column: 16, + endLine: 2, + endColumn: 19, + }, + ], + }, + { + code: ":root { --a: var(--c); --b: var(--a); }\na { color: var(--b); }", + errors: [ + { + messageId: "unknownVar", + data: { var: "--b" }, + line: 2, + column: 16, + endLine: 2, + endColumn: 19, + }, + ], + }, + { + code: ":root { --a: var(--b); --b: var(--c); }\na { color: var(--a); }", + errors: [ + { + messageId: "unknownVar", + data: { var: "--a" }, + line: 2, + column: 16, + endLine: 2, + endColumn: 19, + }, + ], + }, + { + code: ":root { --a: var(--c); --b: var(--a); }\na { color: var(--d, var(--b)); }", + errors: [ + { + messageId: "unknownVar", + data: { var: "--d" }, + line: 2, + column: 16, + endLine: 2, + endColumn: 19, + }, + ], + }, + { + code: ":root { --a: foo; --b: var(--a); }\na { border-top: 1px var(--b) var(--c, red); }", + errors: [ + { + messageId: "invalidPropertyValue", + data: { + property: "border-top", + value: "foo", + expected: " || || ", + }, + line: 2, + column: 21, + endLine: 2, + endColumn: 29, + }, + ], + }, + { + code: ":root { --a: foo; --b: var(--a); }\na { border-top: 1px var(--b, solid) var(--c, red); }", + errors: [ + { + messageId: "invalidPropertyValue", + data: { + property: "border-top", + value: "foo", + expected: " || || ", + }, + line: 2, + column: 21, + endLine: 2, + endColumn: 36, + }, + ], + }, + { + code: ":root { --a: foo; --b: var(--a); }\na { border-top: 1px var(--c, var(--d, solid)) var(--b); }", + errors: [ + { + messageId: "invalidPropertyValue", + data: { + property: "border-top", + value: "foo", + expected: " || || ", + }, + line: 2, + column: 47, + endLine: 2, + endColumn: 55, + }, + ], + }, + { + code: ":root { --a: foo; --b: var(--a); }\na { border-top: 1px var(--c, var(--d)) var(--b); }", + errors: [ + { + messageId: "unknownVar", + data: { + var: "--c", + }, + line: 2, + column: 25, + endLine: 2, + endColumn: 28, + }, + ], + }, + { + code: ":root { --a: red; --b: var(--a); }\na { colorr: var(--b, blue); }", + errors: [ + { + messageId: "unknownProperty", + data: { + property: "colorr", + }, + line: 2, + column: 5, + endLine: 2, + endColumn: 11, + }, + ], + }, + { + code: ":root { --a: foo; --b: var(--a); }\na { border-top: 1px var(--c, var(--d)) var(--b); }", + options: [{ allowUnknownVariables: true }], + errors: [ + { + messageId: "invalidPropertyValue", + data: { + property: "border-top", + value: "foo", + expected: " || || ", + }, + line: 2, + column: 40, + endLine: 2, + endColumn: 48, + }, + ], + }, + { + code: ":root { --a: var(--b); --b: var(--a); }\na { color: var(--a); }", + errors: [ + { + messageId: "unknownVar", + data: { var: "--a" }, + line: 2, + column: 16, + endLine: 2, + endColumn: 19, + }, + ], + }, + { + code: ":root { --a: var(--b, red); }\na { padding-top: var(--a); }", + errors: [ + { + messageId: "invalidPropertyValue", + data: { + property: "padding-top", + value: "red", + expected: "", + }, + line: 2, + column: 18, + endLine: 2, + endColumn: 26, + }, + ], + }, + { + code: ":root { --a: var(--b, var(--c, red)); }\na { padding-top: var(--a); }", + errors: [ + { + messageId: "invalidPropertyValue", + data: { + property: "padding-top", + value: "red", + expected: "", + }, + line: 2, + column: 18, + endLine: 2, + endColumn: 26, + }, + ], + }, ], }); From eb09f46f17edef3f16469aee13f022ee889ce187 Mon Sep 17 00:00:00 2001 From: xbinaryx Date: Mon, 25 Aug 2025 14:03:54 +0300 Subject: [PATCH 2/2] add algorithm explanation and cache documentation --- src/rules/no-invalid-properties.js | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js index 2775e5dd..f4479356 100644 --- a/src/rules/no-invalid-properties.js +++ b/src/rules/no-invalid-properties.js @@ -152,6 +152,22 @@ export default { const fallbackStack = []; let currentVarName = variableName; + /* + * Resolves a CSS variable by following its reference chain. + * + * Phase 1: Follow var() references + * - Use `seen` to detect cycles + * - Use `cache` for memoization + * - If value is concrete: cache and return + * - If value is another var(--next, ): + * push fallback to stack and continue with --next + * - If variable unknown: proceed to Phase 2 + * + * Phase 2: Try fallback values (if Phase 1 failed) + * - Process fallbacks in reverse order (LIFO) + * - Resolve each via resolveFallback() + * - Return first successful resolution + */ while (true) { if (seen.has(currentVarName)) { break; @@ -183,9 +199,6 @@ export default { while (fallbackStack.length > 0) { const fallbackText = fallbackStack.pop(); - if (!fallbackText) { - continue; - } // eslint-disable-next-line no-use-before-define -- resolveFallback and resolveVariable are mutually recursive const resolvedFallback = resolveFallback( fallbackText, @@ -209,10 +222,9 @@ export default { * @returns {string | null} */ function resolveFallback(rawFallbackText, cache, seen = new Set()) { - const trimmedFallback = rawFallbackText.trim(); - const fallbackVarList = getVarFallbackList(trimmedFallback); + const fallbackVarList = getVarFallbackList(rawFallbackText); if (fallbackVarList.length === 0) { - return trimmedFallback; + return rawFallbackText; } for (const fallbackCandidate of fallbackVarList) { @@ -270,7 +282,11 @@ export default { if (usingVars) { const valueList = []; - /** @type {Map} */ + /** + * Cache for resolved variable values within this single declaration. + * Prevents re-resolving the same variable and re-walking long `var()` chains. + * @type {Map} + */ const resolvedCache = new Map(); const valueNodes = node.value.children;