diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js index cc7c0810..f4479356 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*(.+))?\)/iu; + +/** + * 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*(.+))?\)/iu, - ); + 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.toLowerCase().includes("var(")) { - list.push(fallback); + if (!parsed.fallbackText.toLowerCase().includes("var(")) { + list.push(parsed.fallbackText); break; } // Continue parsing from fallback - currentValue = fallback; + currentValue = parsed.fallbackText; } return list; @@ -124,6 +140,111 @@ 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; + + /* + * 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; + } + 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(); + // 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 fallbackVarList = getVarFallbackList(rawFallbackText); + if (fallbackVarList.length === 0) { + return rawFallbackText; + } + + 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 +282,12 @@ export default { if (usingVars) { const valueList = []; + /** + * 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; // 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 { // 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) { @@ -197,81 +340,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 fa5babc3..982daf08 100644 --- a/tests/rules/no-invalid-properties.test.js +++ b/tests/rules/no-invalid-properties.test.js @@ -108,6 +108,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 @@ -797,5 +811,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, + }, + ], + }, ], });