From 426660025263edce7e84defb5505d39e9621b276 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:43:04 +0000 Subject: [PATCH 1/5] Initial plan From e8ea7e810eb91f9b215f0e088c1f0e454753bfd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:04:11 +0000 Subject: [PATCH 2/5] feat: add CSS custom property tracking to CSSSourceCode and refactor no-invalid-properties Implements the RFC for CSS custom property tracking on CSSSourceCode: - Add #customProperties map and #declarationVariables WeakMap - Populate during traverse() to track declarations, @property definitions, and var() references - Add getDeclarationVariables(declaration) method - Add getClosestVariableValue(func) method - Add getVariableValues(func) method Refactor no-invalid-properties rule to use the new API: - Use getClosestVariableValue() for primary variable resolution - Pre-populate vars map from AST to support variable hoisting (fixes #199) - Fallback values take priority over other-block declarations per RFC Add comprehensive tests for all new CSSSourceCode methods. Agent-Logs-Url: https://github.com/eslint/css/sessions/e41fbbf4-0081-469f-8248-1e854213b08c Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- src/languages/css-source-code.js | 247 +++++++++++++- src/rules/no-invalid-properties.js | 131 ++++++-- tests/languages/css-source-code.test.js | 376 ++++++++++++++++++++++ tests/rules/no-invalid-properties.test.js | 60 +--- 4 files changed, 733 insertions(+), 81 deletions(-) diff --git a/src/languages/css-source-code.js b/src/languages/css-source-code.js index ec98a51b..7e75bed4 100644 --- a/src/languages/css-source-code.js +++ b/src/languages/css-source-code.js @@ -20,7 +20,7 @@ import { visitorKeys } from "./css-visitor-keys.js"; //----------------------------------------------------------------------------- /** - * @import { CssNode, CssNodePlain, CssLocationRange, Comment, Lexer, StyleSheetPlain } from "@eslint/css-tree" + * @import { CssNode, CssNodePlain, CssLocationRange, Comment, Lexer, StyleSheetPlain, DeclarationPlain, AtrulePlain, FunctionNodePlain, ValuePlain, Raw } from "@eslint/css-tree" * @import { SourceRange, FileProblem, DirectiveType, RulesConfig } from "@eslint/core" * @import { CSSSyntaxElement } from "../types.js" * @import { CSSLanguageOptions } from "./css-language.js" @@ -104,6 +104,18 @@ export class CSSSourceCode extends TextSourceCodeBase { */ lexer; + /** + * Map of custom property names to their uses. + * @type {Map, definitions: Array, references: Array}>} + */ + #customProperties = new Map(); + + /** + * Map of declaration nodes to the var() function nodes they contain. + * @type {WeakMap>} + */ + #declarationVariables = new WeakMap(); + /** * Creates a new instance. * @param {Object} options The options for the instance. @@ -254,6 +266,139 @@ export class CSSSourceCode extends TextSourceCodeBase { return this.#parents.get(node); } + /** + * Returns the var() function nodes used in a declaration's value. + * @param {DeclarationPlain} declaration The declaration node. + * @returns {Array} The var() function nodes. + */ + getDeclarationVariables(declaration) { + return this.#declarationVariables.get(declaration) || []; + } + + /** + * Returns the closest variable value for a var() function node. + * @param {FunctionNodePlain} func The var() function node. + * @returns {ValuePlain|Raw|undefined} The closest variable value. + */ + getClosestVariableValue(func) { + const varName = /** @type {{name: string}} */ (func.children[0]).name; + const uses = this.#customProperties.get(varName); + + // Step 1: Check if the current rule block has a declaration for this variable + const funcParent = this.#parents.get(func); + let current = funcParent; + + while (current) { + if (current.type === "Block") { + break; + } + current = this.#parents.get(current); + } + + if (current && current.type === "Block" && uses) { + // Find declarations in the same block + const blockDecls = uses.declarations.filter(decl => { + let declParent = this.#parents.get(decl); + + while (declParent) { + if (declParent === current) { + return true; + } + declParent = this.#parents.get(declParent); + } + return false; + }); + + if (blockDecls.length > 0) { + return blockDecls.at(-1).value; + } + } + + // Step 2: Check if var() has a fallback value + if (func.children.length >= 3 && func.children[2]) { + return /** @type {Raw} */ (func.children[2]); + } + + // Step 3: Check previous rules for a declaration + if (uses) { + const funcOffset = func.loc.start.offset; + + // Find the last declaration before this var() usage + const previousDecls = uses.declarations.filter( + decl => decl.loc.start.offset < funcOffset, + ); + + if (previousDecls.length > 0) { + return previousDecls.at(-1).value; + } + + // Also check declarations after the var() usage (hoisting) + if (uses.declarations.length > 0) { + return uses.declarations.at(-1).value; + } + } + + // Step 4: Check @property definitions for initial-value + if (uses) { + for (const definition of uses.definitions) { + if (definition.block && definition.block.children) { + for (const decl of definition.block.children) { + if ( + decl.type === "Declaration" && + decl.property === "initial-value" + ) { + return decl.value; + } + } + } + } + } + + // Step 5: return undefined + return undefined; + } + + /** + * Returns all declared values for a var() function node's custom property. + * @param {FunctionNodePlain} func The var() function node. + * @returns {Array} The declared values. + */ + getVariableValues(func) { + const varName = /** @type {{name: string}} */ (func.children[0]).name; + const uses = this.#customProperties.get(varName); + const result = []; + + // Step 1: @property initial-value comes first + if (uses) { + for (const definition of uses.definitions) { + if (definition.block && definition.block.children) { + for (const decl of definition.block.children) { + if ( + decl.type === "Declaration" && + decl.property === "initial-value" + ) { + result.push(decl.value); + } + } + } + } + } + + // Step 2: Declaration values in source order + if (uses) { + for (const decl of uses.declarations) { + result.push(decl.value); + } + } + + // Step 3: Fallback value last + if (func.children.length >= 3 && func.children[2]) { + result.push(/** @type {Raw} */ (func.children[2])); + } + + return result; + } + /** * Traverse the source code and return the steps that were taken. * @returns {Iterable} The steps that were taken while traversing the source code. @@ -267,12 +412,107 @@ export class CSSSourceCode extends TextSourceCodeBase { /** @type {Array} */ const steps = (this.#steps = []); + /** + * Stack to track the current declaration being visited for + * collecting var() references in declarations. + * @type {Array} + */ + const declarationStack = []; + // Note: We can't use `walk` from `css-tree` because it uses `CssNode` instead of `CssNodePlain` const visit = (node, parent) => { // first set the parent this.#parents.set(node, parent); + // Track custom property declarations (e.g., --my-color: red) + if ( + node.type === "Declaration" && + node.property.startsWith("--") + ) { + const varName = node.property; + let uses = this.#customProperties.get(varName); + + if (!uses) { + uses = { + declarations: [], + definitions: [], + references: [], + }; + this.#customProperties.set(varName, uses); + } + uses.declarations.push(node); + } + + // Track @property definitions + if ( + node.type === "Atrule" && + node.name.toLowerCase() === "property" && + node.prelude && + node.prelude.children && + node.prelude.children.length > 0 + ) { + const propName = node.prelude.children[0].name; + + if (propName) { + let uses = this.#customProperties.get(propName); + + if (!uses) { + uses = { + declarations: [], + definitions: [], + references: [], + }; + this.#customProperties.set(propName, uses); + } + uses.definitions.push(node); + } + } + + // Track declaration enter for var() collection + if (node.type === "Declaration") { + declarationStack.push(node); + } + + // Track var() references + if ( + node.type === "Function" && + node.name.toLowerCase() === "var" && + node.children && + node.children.length > 0 && + node.children[0].type === "Identifier" + ) { + const varName = node.children[0].name; + let uses = this.#customProperties.get(varName); + + if (!uses) { + uses = { + declarations: [], + definitions: [], + references: [], + }; + this.#customProperties.set(varName, uses); + } + uses.references.push(node); + + // Also track which declarations contain var() references + const currentDecl = declarationStack.at(-1); + + if (currentDecl) { + let varRefs = + this.#declarationVariables.get(currentDecl); + + if (!varRefs) { + varRefs = []; + this.#declarationVariables.set( + currentDecl, + varRefs, + ); + } + varRefs.push(node); + } + } + // then add the step steps.push( new CSSTraversalStep({ @@ -297,6 +537,11 @@ export class CSSSourceCode extends TextSourceCodeBase { } } + // Track declaration exit + if (node.type === "Declaration") { + declarationStack.pop(); + } + // then add the exit step steps.push( new CSSTraversalStep({ diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js index 8a6bd638..6204242e 100644 --- a/src/rules/no-invalid-properties.js +++ b/src/rules/no-invalid-properties.js @@ -82,6 +82,33 @@ function getVarFallbackList(value) { return list; } +/** + * Walks an AST node and its children, calling a visitor for each node. + * @param {Object} node The AST node to walk. + * @param {Function} visitor The visitor function to call for each node. + * @returns {void} + */ +function walkAST(node, visitor) { + if (!node || typeof node !== "object" || !node.type) { + return; + } + visitor(node); + for (const key of Object.keys(node)) { + if (key === "loc" || key === "type") { + continue; + } + const child = node[key]; + + if (Array.isArray(child)) { + for (const item of child) { + walkAST(item, visitor); + } + } else if (child && typeof child === "object" && child.type) { + walkAST(child, visitor); + } + } +} + //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- @@ -127,7 +154,11 @@ export default { const sourceCode = context.sourceCode; const lexer = sourceCode.lexer; - /** @type {Map} */ + /** + * Map of custom property names to their value nodes, pre-populated + * from the entire AST to support variable hoisting. + * @type {Map} + */ const vars = new Map(); /** @@ -146,6 +177,7 @@ export default { /** * Iteratively resolves CSS variable references until a value is found. + * Used for chain resolution when a variable's value contains another var(). * @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 @@ -250,7 +282,9 @@ export default { } /** - * Process a var function node and add its resolved value to the value list + * Process a var function node and add its resolved value to the value list. + * Uses sourceCode.getClosestVariableValue() for primary resolution and + * falls back to chain resolution for nested var() references. * @param {Object} varNode The var() function node * @param {string[]} valueList Array to collect processed values * @param {Map} valueSegmentLocs Map of rebuilt value segments to their locations @@ -263,50 +297,80 @@ export default { valueSegmentLocs, resolvedCache, ) { - const varValue = vars.get(varNode.children[0].name); + const closestValue = + sourceCode.getClosestVariableValue(varNode); - if (varValue) { + if (closestValue) { + const valueText = sourceCode.getText(closestValue).trim(); + const parsed = parseVarFunction(valueText); + + if (!parsed) { + // Concrete value (not a var() reference) + valueList.push(valueText); + valueSegmentLocs.set(valueText, varNode.loc); + return true; + } + + // Value contains var() - resolve the chain const resolvedValue = resolveVariable( - varNode.children[0].name, + parsed.name, resolvedCache, ); + if (resolvedValue) { valueList.push(resolvedValue); valueSegmentLocs.set(resolvedValue, varNode.loc); return true; } - } - // If the variable is not found and doesn't have a fallback value, report it - if (varNode.children.length === 1) { + // Chain resolution failed - try fallback from the chain + if (parsed.fallbackText) { + const resolvedFallback = resolveFallback( + parsed.fallbackText, + resolvedCache, + ); + + if (resolvedFallback) { + valueList.push(resolvedFallback); + valueSegmentLocs.set( + resolvedFallback, + varNode.loc, + ); + return true; + } + } + + /* + * Closest value couldn't be fully resolved (e.g., the + * fallback contained unresolvable var() references). + * Try resolving the original variable directly through + * declared values as a last resort. + */ + const varName = varNode.children[0].name; + const directResolved = resolveVariable( + varName, + resolvedCache, + ); + + if (directResolved) { + valueList.push(directResolved); + valueSegmentLocs.set(directResolved, varNode.loc); + return true; + } + + // Couldn't resolve at all if (!allowUnknownVariables) { context.report({ loc: varNode.children[0].loc, messageId: "unknownVar", - data: { var: varNode.children[0].name }, + data: { var: varName }, }); return false; } return true; } - // Handle fallback values - if (varNode.children[2].type !== "Raw") { - return true; - } - - const fallbackValue = varNode.children[2].value.trim(); - const resolvedFallbackValue = resolveFallback( - fallbackValue, - resolvedCache, - ); - if (resolvedFallbackValue) { - valueList.push(resolvedFallbackValue); - valueSegmentLocs.set(resolvedFallbackValue, varNode.loc); - return true; - } - - // No valid fallback found + // No closest value found at all if (!allowUnknownVariables) { context.report({ loc: varNode.children[0].loc, @@ -320,6 +384,18 @@ export default { } return { + StyleSheet() { + // Pre-populate vars map from the entire AST to support hoisting + walkAST(sourceCode.ast, node => { + if ( + node.type === "Declaration" && + node.property.startsWith("--") + ) { + vars.set(node.property, node.value); + } + }); + }, + "Rule > Block Declaration"() { declStack.push({ valueParts: [], @@ -404,9 +480,6 @@ export default { "Rule > Block Declaration:exit"(node) { const state = declStack.pop(); if (node.property.startsWith("--")) { - // store the custom property name and value to validate later - vars.set(node.property, node.value); - // don't validate custom properties return; } diff --git a/tests/languages/css-source-code.test.js b/tests/languages/css-source-code.test.js index f5a725c1..41678008 100644 --- a/tests/languages/css-source-code.test.js +++ b/tests/languages/css-source-code.test.js @@ -932,4 +932,380 @@ describe("CSSSourceCode", () => { ]); }); }); + + describe("getDeclarationVariables()", () => { + /** + * Helper to create a CSSSourceCode from CSS text and trigger traversal. + * @param {string} css The CSS text. + * @returns {import("../../src/languages/css-source-code.js").CSSSourceCode} The source code instance. + */ + function createSourceCode(css) { + const sourceCode = new CSSSourceCode({ + text: css, + ast: toPlainObject(parse(css, { positions: true })), + }); + + // trigger traversal to populate custom properties data + sourceCode.traverse(); + return sourceCode; + } + + it("should return empty array for a declaration without var()", () => { + const css = "a { color: red; }"; + const sourceCode = createSourceCode(css); + const decl = sourceCode.ast.children[0].block.children[0]; + + assert.deepStrictEqual( + sourceCode.getDeclarationVariables(decl), + [], + ); + }); + + it("should return var() function nodes in a declaration", () => { + const css = "a { color: var(--my-color); }"; + const sourceCode = createSourceCode(css); + const decl = sourceCode.ast.children[0].block.children[0]; + const vars = sourceCode.getDeclarationVariables(decl); + + assert.strictEqual(vars.length, 1); + assert.strictEqual(vars[0].type, "Function"); + assert.strictEqual(vars[0].name, "var"); + assert.strictEqual(vars[0].children[0].name, "--my-color"); + }); + + it("should return multiple var() function nodes", () => { + const css = + "a { border: var(--width) solid var(--color); }"; + const sourceCode = createSourceCode(css); + const decl = sourceCode.ast.children[0].block.children[0]; + const vars = sourceCode.getDeclarationVariables(decl); + + assert.strictEqual(vars.length, 2); + assert.strictEqual(vars[0].children[0].name, "--width"); + assert.strictEqual(vars[1].children[0].name, "--color"); + }); + + it("should not track var() from other declarations", () => { + const css = + "a { color: var(--color); padding: var(--padding); }"; + const sourceCode = createSourceCode(css); + const colorDecl = + sourceCode.ast.children[0].block.children[0]; + const paddingDecl = + sourceCode.ast.children[0].block.children[1]; + + const colorVars = + sourceCode.getDeclarationVariables(colorDecl); + const paddingVars = + sourceCode.getDeclarationVariables(paddingDecl); + + assert.strictEqual(colorVars.length, 1); + assert.strictEqual( + colorVars[0].children[0].name, + "--color", + ); + + assert.strictEqual(paddingVars.length, 1); + assert.strictEqual( + paddingVars[0].children[0].name, + "--padding", + ); + }); + }); + + describe("getClosestVariableValue()", () => { + /** + * Helper to create a CSSSourceCode from CSS text and trigger traversal. + * @param {string} css The CSS text. + * @returns {import("../../src/languages/css-source-code.js").CSSSourceCode} The source code instance. + */ + function createSourceCode(css) { + const sourceCode = new CSSSourceCode({ + text: css, + ast: toPlainObject(parse(css, { positions: true })), + }); + + sourceCode.traverse(); + return sourceCode; + } + + /** + * Helper to find the first var() Function node in the AST. + * @param {Object} node The node to search. + * @returns {Object|null} The var() function node, or null. + */ + function findVarFunc(node) { + if ( + node.type === "Function" && + node.name.toLowerCase() === "var" + ) { + return node; + } + for (const key of Object.keys(node)) { + if (key === "loc" || key === "type") { + continue; + } + const child = node[key]; + + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === "object" && item.type) { + const result = findVarFunc(item); + + if (result) { + return result; + } + } + } + } else if ( + child && + typeof child === "object" && + child.type + ) { + const result = findVarFunc(child); + + if (result) { + return result; + } + } + } + return null; + } + + it("should return value from same-block declaration", () => { + const css = "a { --my-color: red; color: var(--my-color); }"; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const result = sourceCode.getClosestVariableValue(varFunc); + + assert.ok(result); + assert.strictEqual( + sourceCode.getText(result).trim(), + "red", + ); + }); + + it("should return fallback when no same-block declaration exists", () => { + const css = "a { color: var(--my-color, blue); }"; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const result = sourceCode.getClosestVariableValue(varFunc); + + assert.ok(result); + assert.strictEqual(result.type, "Raw"); + assert.strictEqual(result.value.trim(), "blue"); + }); + + it("should return value from previous rule when no fallback", () => { + const css = + ":root { --my-color: red; }\na { color: var(--my-color); }"; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const result = sourceCode.getClosestVariableValue(varFunc); + + assert.ok(result); + assert.strictEqual( + sourceCode.getText(result).trim(), + "red", + ); + }); + + it("should return fallback before other-block declaration", () => { + const css = + ":root { --my-color: red; }\na { color: var(--my-color, blue); }"; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const result = sourceCode.getClosestVariableValue(varFunc); + + assert.ok(result); + assert.strictEqual(result.type, "Raw"); + assert.strictEqual(result.value.trim(), "blue"); + }); + + it("should return hoisted declaration value when no fallback", () => { + const css = + "a { color: var(--my-color); }\n:root { --my-color: red; }"; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const result = sourceCode.getClosestVariableValue(varFunc); + + assert.ok(result); + assert.strictEqual( + sourceCode.getText(result).trim(), + "red", + ); + }); + + it("should return @property initial-value when no declarations", () => { + const css = + '@property --my-color { syntax: ""; inherits: false; initial-value: green; }\na { color: var(--my-color); }'; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const result = sourceCode.getClosestVariableValue(varFunc); + + assert.ok(result); + assert.strictEqual( + sourceCode.getText(result).trim(), + "green", + ); + }); + + it("should return undefined when variable is not defined", () => { + const css = "a { color: var(--unknown); }"; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const result = sourceCode.getClosestVariableValue(varFunc); + + assert.strictEqual(result, undefined); + }); + + it("should return last same-block declaration value", () => { + const css = + "a { --x: red; --x: blue; color: var(--x); }"; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const result = sourceCode.getClosestVariableValue(varFunc); + + assert.ok(result); + assert.strictEqual( + sourceCode.getText(result).trim(), + "blue", + ); + }); + }); + + describe("getVariableValues()", () => { + /** + * Helper to create a CSSSourceCode from CSS text and trigger traversal. + * @param {string} css The CSS text. + * @returns {import("../../src/languages/css-source-code.js").CSSSourceCode} The source code instance. + */ + function createSourceCode(css) { + const sourceCode = new CSSSourceCode({ + text: css, + ast: toPlainObject(parse(css, { positions: true })), + }); + + sourceCode.traverse(); + return sourceCode; + } + + /** + * Helper to find the first var() Function node in the AST. + * @param {Object} node The node to search. + * @returns {Object|null} The var() function node, or null. + */ + function findVarFunc(node) { + if ( + node.type === "Function" && + node.name.toLowerCase() === "var" + ) { + return node; + } + for (const key of Object.keys(node)) { + if (key === "loc" || key === "type") { + continue; + } + const child = node[key]; + + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === "object" && item.type) { + const result = findVarFunc(item); + + if (result) { + return result; + } + } + } + } else if ( + child && + typeof child === "object" && + child.type + ) { + const result = findVarFunc(child); + + if (result) { + return result; + } + } + } + return null; + } + + it("should return empty array for unknown variable without fallback", () => { + const css = "a { color: var(--unknown); }"; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const values = sourceCode.getVariableValues(varFunc); + + assert.deepStrictEqual(values, []); + }); + + it("should return fallback only when no declarations", () => { + const css = "a { color: var(--unknown, red); }"; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const values = sourceCode.getVariableValues(varFunc); + + assert.strictEqual(values.length, 1); + assert.strictEqual(values[0].type, "Raw"); + assert.strictEqual(values[0].value.trim(), "red"); + }); + + it("should return declaration values in source order", () => { + const css = + ":root { --x: red; }\na { --x: blue; }\nb { color: var(--x); }"; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const values = sourceCode.getVariableValues(varFunc); + + assert.strictEqual(values.length, 2); + assert.strictEqual( + sourceCode.getText(values[0]).trim(), + "red", + ); + assert.strictEqual( + sourceCode.getText(values[1]).trim(), + "blue", + ); + }); + + it("should return @property initial-value first, then declarations, then fallback", () => { + const css = + '@property --x { syntax: ""; inherits: false; initial-value: green; }\n:root { --x: red; }\na { color: var(--x, blue); }'; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const values = sourceCode.getVariableValues(varFunc); + + assert.strictEqual(values.length, 3); + // First: @property initial-value + assert.strictEqual( + sourceCode.getText(values[0]).trim(), + "green", + ); + // Second: declaration value + assert.strictEqual( + sourceCode.getText(values[1]).trim(), + "red", + ); + // Third: fallback + assert.strictEqual(values[2].type, "Raw"); + assert.strictEqual(values[2].value.trim(), "blue"); + }); + + it("should return only declaration value when no @property or fallback", () => { + const css = + ":root { --x: red; }\na { color: var(--x); }"; + const sourceCode = createSourceCode(css); + const varFunc = findVarFunc(sourceCode.ast); + const values = sourceCode.getVariableValues(varFunc); + + assert.strictEqual(values.length, 1); + assert.strictEqual( + sourceCode.getText(values[0]).trim(), + "red", + ); + }); + }); }); diff --git a/tests/rules/no-invalid-properties.test.js b/tests/rules/no-invalid-properties.test.js index 618605c2..ef538056 100644 --- a/tests/rules/no-invalid-properties.test.js +++ b/tests/rules/no-invalid-properties.test.js @@ -171,6 +171,15 @@ ruleTester.run("no-invalid-properties", rule, { "@supports (color: color(display-p3 1 1 1)) { @media (color-gamut: p3) { a { --c: oklch(50% 0.1 120); } } }", "@import 'x.css' layer(theme);", + // Fallback values take priority over other-block declarations per getClosestVariableValue + ":root { --style: foo }\na { border-top: 1px var(--style, solid) var(--color, red); }", + ":root { --style: foo }\na { border-top: 1px VAR(--style, solid) VAR(--color, red); }", + ":root { --a: foo; --b: var(--a); }\na { border-top: 1px var(--b, solid) var(--c, red); }", + + // Variable hoisting: var() used before custom property declaration (#199) + ".test { color: var(--myColor); }\n:root { --myColor: blue; }", + "a { color: var(--x); }\nb { --x: red; }", + /* * CSSTree doesn't currently support custom functions properly, so leaving * these out for now. @@ -767,23 +776,6 @@ ruleTester.run("no-invalid-properties", rule, { }, ], }, - { - code: ":root { --style: foo }\na { border-top: 1px var(--style, solid) var(--color, red); }", - errors: [ - { - messageId: "invalidPropertyValue", - data: { - property: "border-top", - value: "foo", - expected: " || || ", - }, - line: 2, - column: 21, - endLine: 2, - endColumn: 40, - }, - ], - }, { code: ":root { --color: foo }\na { border-top: 1px var(--style, var(--fallback, solid)) var(--color); }", errors: [ @@ -896,23 +888,6 @@ ruleTester.run("no-invalid-properties", rule, { }, ], }, - { - code: ":root { --style: foo }\na { border-top: 1px VAR(--style, solid) VAR(--color, red); }", - errors: [ - { - messageId: "invalidPropertyValue", - data: { - property: "border-top", - value: "foo", - expected: " || || ", - }, - line: 2, - column: 21, - endLine: 2, - endColumn: 40, - }, - ], - }, { code: ":root { --color: foo }\na { border-top: 1px VAR(--style, VAR(--fallback, solid)) VAR(--color); }", errors: [ @@ -1047,23 +1022,6 @@ ruleTester.run("no-invalid-properties", rule, { }, ], }, - { - 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: [ From 63d930cec9086f5bec5262bd6f1fe27b20a6a08e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:30:54 +0000 Subject: [PATCH 3/5] refactor: remove extra AST traversal in no-invalid-properties, use rule visitor methods instead Agent-Logs-Url: https://github.com/eslint/css/sessions/fa674c27-bfb3-46b4-8c86-407a2fef55c5 Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- src/rules/no-invalid-properties.js | 47 ++++-------------------------- 1 file changed, 6 insertions(+), 41 deletions(-) diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js index 6204242e..8c8f0579 100644 --- a/src/rules/no-invalid-properties.js +++ b/src/rules/no-invalid-properties.js @@ -82,33 +82,6 @@ function getVarFallbackList(value) { return list; } -/** - * Walks an AST node and its children, calling a visitor for each node. - * @param {Object} node The AST node to walk. - * @param {Function} visitor The visitor function to call for each node. - * @returns {void} - */ -function walkAST(node, visitor) { - if (!node || typeof node !== "object" || !node.type) { - return; - } - visitor(node); - for (const key of Object.keys(node)) { - if (key === "loc" || key === "type") { - continue; - } - const child = node[key]; - - if (Array.isArray(child)) { - for (const item of child) { - walkAST(item, visitor); - } - } else if (child && typeof child === "object" && child.type) { - walkAST(child, visitor); - } - } -} - //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- @@ -155,8 +128,9 @@ export default { const lexer = sourceCode.lexer; /** - * Map of custom property names to their value nodes, pre-populated - * from the entire AST to support variable hoisting. + * Map of custom property names to their value nodes, populated + * incrementally as declarations are visited. Used for chain + * resolution when a variable's value references another var(). * @type {Map} */ const vars = new Map(); @@ -384,18 +358,6 @@ export default { } return { - StyleSheet() { - // Pre-populate vars map from the entire AST to support hoisting - walkAST(sourceCode.ast, node => { - if ( - node.type === "Declaration" && - node.property.startsWith("--") - ) { - vars.set(node.property, node.value); - } - }); - }, - "Rule > Block Declaration"() { declStack.push({ valueParts: [], @@ -480,6 +442,9 @@ export default { "Rule > Block Declaration:exit"(node) { const state = declStack.pop(); if (node.property.startsWith("--")) { + // store the custom property name and value to validate later + vars.set(node.property, node.value); + // don't validate custom properties return; } From 735ebbebc3a598cb2ffe525725bcd1c51c4edb9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:11:07 +0000 Subject: [PATCH 4/5] fix: apply Prettier formatting to resolve CI failures Agent-Logs-Url: https://github.com/eslint/css/sessions/067a08d1-cf1f-4d0b-ac09-9abe4691f119 Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- src/languages/css-source-code.js | 13 +-- src/rules/no-invalid-properties.js | 13 +-- tests/languages/css-source-code.test.js | 106 ++++++------------------ 3 files changed, 30 insertions(+), 102 deletions(-) diff --git a/src/languages/css-source-code.js b/src/languages/css-source-code.js index 7e75bed4..5c42a04d 100644 --- a/src/languages/css-source-code.js +++ b/src/languages/css-source-code.js @@ -426,10 +426,7 @@ export class CSSSourceCode extends TextSourceCodeBase { this.#parents.set(node, parent); // Track custom property declarations (e.g., --my-color: red) - if ( - node.type === "Declaration" && - node.property.startsWith("--") - ) { + if (node.type === "Declaration" && node.property.startsWith("--")) { const varName = node.property; let uses = this.#customProperties.get(varName); @@ -499,15 +496,11 @@ export class CSSSourceCode extends TextSourceCodeBase { const currentDecl = declarationStack.at(-1); if (currentDecl) { - let varRefs = - this.#declarationVariables.get(currentDecl); + let varRefs = this.#declarationVariables.get(currentDecl); if (!varRefs) { varRefs = []; - this.#declarationVariables.set( - currentDecl, - varRefs, - ); + this.#declarationVariables.set(currentDecl, varRefs); } varRefs.push(node); } diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js index 8c8f0579..8e32336e 100644 --- a/src/rules/no-invalid-properties.js +++ b/src/rules/no-invalid-properties.js @@ -271,8 +271,7 @@ export default { valueSegmentLocs, resolvedCache, ) { - const closestValue = - sourceCode.getClosestVariableValue(varNode); + const closestValue = sourceCode.getClosestVariableValue(varNode); if (closestValue) { const valueText = sourceCode.getText(closestValue).trim(); @@ -306,10 +305,7 @@ export default { if (resolvedFallback) { valueList.push(resolvedFallback); - valueSegmentLocs.set( - resolvedFallback, - varNode.loc, - ); + valueSegmentLocs.set(resolvedFallback, varNode.loc); return true; } } @@ -321,10 +317,7 @@ export default { * declared values as a last resort. */ const varName = varNode.children[0].name; - const directResolved = resolveVariable( - varName, - resolvedCache, - ); + const directResolved = resolveVariable(varName, resolvedCache); if (directResolved) { valueList.push(directResolved); diff --git a/tests/languages/css-source-code.test.js b/tests/languages/css-source-code.test.js index 41678008..f87d56fd 100644 --- a/tests/languages/css-source-code.test.js +++ b/tests/languages/css-source-code.test.js @@ -974,8 +974,7 @@ describe("CSSSourceCode", () => { }); it("should return multiple var() function nodes", () => { - const css = - "a { border: var(--width) solid var(--color); }"; + const css = "a { border: var(--width) solid var(--color); }"; const sourceCode = createSourceCode(css); const decl = sourceCode.ast.children[0].block.children[0]; const vars = sourceCode.getDeclarationVariables(decl); @@ -986,30 +985,19 @@ describe("CSSSourceCode", () => { }); it("should not track var() from other declarations", () => { - const css = - "a { color: var(--color); padding: var(--padding); }"; + const css = "a { color: var(--color); padding: var(--padding); }"; const sourceCode = createSourceCode(css); - const colorDecl = - sourceCode.ast.children[0].block.children[0]; - const paddingDecl = - sourceCode.ast.children[0].block.children[1]; + const colorDecl = sourceCode.ast.children[0].block.children[0]; + const paddingDecl = sourceCode.ast.children[0].block.children[1]; - const colorVars = - sourceCode.getDeclarationVariables(colorDecl); - const paddingVars = - sourceCode.getDeclarationVariables(paddingDecl); + const colorVars = sourceCode.getDeclarationVariables(colorDecl); + const paddingVars = sourceCode.getDeclarationVariables(paddingDecl); assert.strictEqual(colorVars.length, 1); - assert.strictEqual( - colorVars[0].children[0].name, - "--color", - ); + assert.strictEqual(colorVars[0].children[0].name, "--color"); assert.strictEqual(paddingVars.length, 1); - assert.strictEqual( - paddingVars[0].children[0].name, - "--padding", - ); + assert.strictEqual(paddingVars[0].children[0].name, "--padding"); }); }); @@ -1035,10 +1023,7 @@ describe("CSSSourceCode", () => { * @returns {Object|null} The var() function node, or null. */ function findVarFunc(node) { - if ( - node.type === "Function" && - node.name.toLowerCase() === "var" - ) { + if (node.type === "Function" && node.name.toLowerCase() === "var") { return node; } for (const key of Object.keys(node)) { @@ -1057,11 +1042,7 @@ describe("CSSSourceCode", () => { } } } - } else if ( - child && - typeof child === "object" && - child.type - ) { + } else if (child && typeof child === "object" && child.type) { const result = findVarFunc(child); if (result) { @@ -1079,10 +1060,7 @@ describe("CSSSourceCode", () => { const result = sourceCode.getClosestVariableValue(varFunc); assert.ok(result); - assert.strictEqual( - sourceCode.getText(result).trim(), - "red", - ); + assert.strictEqual(sourceCode.getText(result).trim(), "red"); }); it("should return fallback when no same-block declaration exists", () => { @@ -1104,10 +1082,7 @@ describe("CSSSourceCode", () => { const result = sourceCode.getClosestVariableValue(varFunc); assert.ok(result); - assert.strictEqual( - sourceCode.getText(result).trim(), - "red", - ); + assert.strictEqual(sourceCode.getText(result).trim(), "red"); }); it("should return fallback before other-block declaration", () => { @@ -1130,10 +1105,7 @@ describe("CSSSourceCode", () => { const result = sourceCode.getClosestVariableValue(varFunc); assert.ok(result); - assert.strictEqual( - sourceCode.getText(result).trim(), - "red", - ); + assert.strictEqual(sourceCode.getText(result).trim(), "red"); }); it("should return @property initial-value when no declarations", () => { @@ -1144,10 +1116,7 @@ describe("CSSSourceCode", () => { const result = sourceCode.getClosestVariableValue(varFunc); assert.ok(result); - assert.strictEqual( - sourceCode.getText(result).trim(), - "green", - ); + assert.strictEqual(sourceCode.getText(result).trim(), "green"); }); it("should return undefined when variable is not defined", () => { @@ -1160,17 +1129,13 @@ describe("CSSSourceCode", () => { }); it("should return last same-block declaration value", () => { - const css = - "a { --x: red; --x: blue; color: var(--x); }"; + const css = "a { --x: red; --x: blue; color: var(--x); }"; const sourceCode = createSourceCode(css); const varFunc = findVarFunc(sourceCode.ast); const result = sourceCode.getClosestVariableValue(varFunc); assert.ok(result); - assert.strictEqual( - sourceCode.getText(result).trim(), - "blue", - ); + assert.strictEqual(sourceCode.getText(result).trim(), "blue"); }); }); @@ -1196,10 +1161,7 @@ describe("CSSSourceCode", () => { * @returns {Object|null} The var() function node, or null. */ function findVarFunc(node) { - if ( - node.type === "Function" && - node.name.toLowerCase() === "var" - ) { + if (node.type === "Function" && node.name.toLowerCase() === "var") { return node; } for (const key of Object.keys(node)) { @@ -1218,11 +1180,7 @@ describe("CSSSourceCode", () => { } } } - } else if ( - child && - typeof child === "object" && - child.type - ) { + } else if (child && typeof child === "object" && child.type) { const result = findVarFunc(child); if (result) { @@ -1261,14 +1219,8 @@ describe("CSSSourceCode", () => { const values = sourceCode.getVariableValues(varFunc); assert.strictEqual(values.length, 2); - assert.strictEqual( - sourceCode.getText(values[0]).trim(), - "red", - ); - assert.strictEqual( - sourceCode.getText(values[1]).trim(), - "blue", - ); + assert.strictEqual(sourceCode.getText(values[0]).trim(), "red"); + assert.strictEqual(sourceCode.getText(values[1]).trim(), "blue"); }); it("should return @property initial-value first, then declarations, then fallback", () => { @@ -1280,32 +1232,22 @@ describe("CSSSourceCode", () => { assert.strictEqual(values.length, 3); // First: @property initial-value - assert.strictEqual( - sourceCode.getText(values[0]).trim(), - "green", - ); + assert.strictEqual(sourceCode.getText(values[0]).trim(), "green"); // Second: declaration value - assert.strictEqual( - sourceCode.getText(values[1]).trim(), - "red", - ); + assert.strictEqual(sourceCode.getText(values[1]).trim(), "red"); // Third: fallback assert.strictEqual(values[2].type, "Raw"); assert.strictEqual(values[2].value.trim(), "blue"); }); it("should return only declaration value when no @property or fallback", () => { - const css = - ":root { --x: red; }\na { color: var(--x); }"; + const css = ":root { --x: red; }\na { color: var(--x); }"; const sourceCode = createSourceCode(css); const varFunc = findVarFunc(sourceCode.ast); const values = sourceCode.getVariableValues(varFunc); assert.strictEqual(values.length, 1); - assert.strictEqual( - sourceCode.getText(values[0]).trim(), - "red", - ); + assert.strictEqual(sourceCode.getText(values[0]).trim(), "red"); }); }); }); From 051fea22eeb9a174483a6178b0659529bb05d3f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:19:02 +0000 Subject: [PATCH 5/5] fix: handle nested var() in fallback, move test helpers to top level Agent-Logs-Url: https://github.com/eslint/css/sessions/c7fb3f76-2d5b-4f30-bbcb-051fb15694b7 Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- src/languages/css-source-code.js | 164 ++++++++++++++++++++ tests/languages/css-source-code.test.js | 189 +++++++++--------------- 2 files changed, 237 insertions(+), 116 deletions(-) diff --git a/src/languages/css-source-code.js b/src/languages/css-source-code.js index 5c42a04d..4b1468cf 100644 --- a/src/languages/css-source-code.js +++ b/src/languages/css-source-code.js @@ -13,6 +13,7 @@ import { ConfigCommentParser, Directive, } from "@eslint/plugin-kit"; +import { parse as cssParse, toPlainObject } from "@eslint/css-tree"; import { visitorKeys } from "./css-visitor-keys.js"; //----------------------------------------------------------------------------- @@ -35,6 +36,61 @@ const commentParser = new ConfigCommentParser(); const INLINE_CONFIG = /^\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|$)/u; +/** + * Adjusts a parsed location relative to a Raw node's position in the + * original source. When a Raw fallback value is re-parsed, the resulting + * AST has offsets starting from 0. This function maps them back to the + * original source positions. + * @param {CssLocationRange} parsedLoc The location from the re-parsed AST. + * @param {CssLocationRange} rawLoc The location of the Raw node in the original source. + * @returns {CssLocationRange} The adjusted location. + */ +function adjustLoc(parsedLoc, rawLoc) { + return { + source: rawLoc.source, + start: { + offset: rawLoc.start.offset + parsedLoc.start.offset, + line: rawLoc.start.line + parsedLoc.start.line - 1, + column: + parsedLoc.start.line === 1 + ? rawLoc.start.column + parsedLoc.start.column - 1 + : parsedLoc.start.column, + }, + end: { + offset: rawLoc.start.offset + parsedLoc.end.offset, + line: rawLoc.start.line + parsedLoc.end.line - 1, + column: + parsedLoc.end.line === 1 + ? rawLoc.start.column + parsedLoc.end.column - 1 + : parsedLoc.end.column, + }, + }; +} + +/** + * Recursively adjusts all `loc` properties in a parsed AST node tree + * relative to a Raw node's location. + * @param {Object} node The parsed AST node. + * @param {CssLocationRange} rawLoc The location of the Raw node in the original source. + * @returns {void} + */ +function adjustAllLocs(node, rawLoc) { + if (node.loc) { + node.loc = adjustLoc(node.loc, rawLoc); + } + for (const key of visitorKeys[node.type] || []) { + const child = node[key]; + + if (child) { + if (Array.isArray(child)) { + child.forEach(item => adjustAllLocs(item, rawLoc)); + } else { + adjustAllLocs(child, rawLoc); + } + } + } +} + /** * A class to represent a step in the traversal process. */ @@ -399,6 +455,111 @@ export class CSSSourceCode extends TextSourceCodeBase { return result; } + /** + * Extracts nested var() references from a Raw fallback node by + * re-parsing its text content. The CSS parser represents fallback + * values as Raw text nodes, so nested var() calls like + * `var(--a, var(--b))` are not parsed into Function nodes. + * This method re-parses the raw text and tracks any var() + * references found. + * @param {FunctionNodePlain} varFuncNode The var() Function node containing the Raw fallback. + * @param {Array} declarationStack Stack tracking the current declaration. + * @returns {void} + */ + #extractNestedVarRefs(varFuncNode, declarationStack) { + if ( + varFuncNode.children.length < 3 || + !varFuncNode.children[2] || + varFuncNode.children[2].type !== "Raw" + ) { + return; + } + + const rawNode = varFuncNode.children[2]; + const rawText = rawNode.value; + + if (!rawText.toLowerCase().includes("var(")) { + return; + } + + let parsed; + + try { + parsed = toPlainObject( + cssParse(rawText, { context: "value", positions: true }), + ); + } catch { + return; + } + + // Adjust all locations in the re-parsed tree to reflect original source positions + adjustAllLocs(parsed, rawNode.loc); + + // Walk the re-parsed tree to find var() Function nodes + const visit = node => { + if ( + node.type === "Function" && + node.name.toLowerCase() === "var" && + node.children && + node.children.length > 0 && + node.children[0].type === "Identifier" + ) { + // Set parent to the outer var() function so the parent chain connects + this.#parents.set(node, varFuncNode); + + // Set parents for children + for (const child of node.children) { + this.#parents.set(child, node); + } + + // Track reference in #customProperties + const nestedVarName = node.children[0].name; + let uses = this.#customProperties.get(nestedVarName); + + if (!uses) { + uses = { + declarations: [], + definitions: [], + references: [], + }; + this.#customProperties.set(nestedVarName, uses); + } + uses.references.push(node); + + // Track in #declarationVariables for the current declaration + const currentDecl = declarationStack.at(-1); + + if (currentDecl) { + let varRefs = this.#declarationVariables.get(currentDecl); + + if (!varRefs) { + varRefs = []; + this.#declarationVariables.set(currentDecl, varRefs); + } + varRefs.push(node); + } + + // Recursively extract deeper nested var() references + this.#extractNestedVarRefs(node, declarationStack); + } + + // Continue walking children + for (const key of visitorKeys[node.type] || []) { + const child = node[key]; + + if (child) { + if (Array.isArray(child)) { + child.forEach(item => visit(item)); + } else { + visit(child); + } + } + } + }; + + visit(parsed); + } + /** * Traverse the source code and return the steps that were taken. * @returns {Iterable} The steps that were taken while traversing the source code. @@ -504,6 +665,9 @@ export class CSSSourceCode extends TextSourceCodeBase { } varRefs.push(node); } + + // Extract nested var() references from Raw fallback text + this.#extractNestedVarRefs(node, declarationStack); } // then add the step diff --git a/tests/languages/css-source-code.test.js b/tests/languages/css-source-code.test.js index f87d56fd..b1ca70c7 100644 --- a/tests/languages/css-source-code.test.js +++ b/tests/languages/css-source-code.test.js @@ -17,6 +17,58 @@ import dedent from "dedent"; // Tests //----------------------------------------------------------------------------- +/** + * Helper to create a CSSSourceCode from CSS text and trigger traversal. + * @param {string} css The CSS text. + * @returns {import("../../src/languages/css-source-code.js").CSSSourceCode} The source code instance. + */ +function createSourceCode(css) { + const sourceCode = new CSSSourceCode({ + text: css, + ast: toPlainObject(parse(css, { positions: true })), + }); + + // trigger traversal to populate custom properties data + sourceCode.traverse(); + return sourceCode; +} + +/** + * Helper to find the first var() Function node in the AST. + * @param {Object} node The node to search. + * @returns {Object|null} The var() function node, or null. + */ +function findVarFunc(node) { + if (node.type === "Function" && node.name.toLowerCase() === "var") { + return node; + } + for (const key of Object.keys(node)) { + if (key === "loc" || key === "type") { + continue; + } + const child = node[key]; + + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === "object" && item.type) { + const result = findVarFunc(item); + + if (result) { + return result; + } + } + } + } else if (child && typeof child === "object" && child.type) { + const result = findVarFunc(child); + + if (result) { + return result; + } + } + } + return null; +} + describe("CSSSourceCode", () => { describe("constructor", () => { it("should create a CSSSourceCode instance", () => { @@ -934,22 +986,6 @@ describe("CSSSourceCode", () => { }); describe("getDeclarationVariables()", () => { - /** - * Helper to create a CSSSourceCode from CSS text and trigger traversal. - * @param {string} css The CSS text. - * @returns {import("../../src/languages/css-source-code.js").CSSSourceCode} The source code instance. - */ - function createSourceCode(css) { - const sourceCode = new CSSSourceCode({ - text: css, - ast: toPlainObject(parse(css, { positions: true })), - }); - - // trigger traversal to populate custom properties data - sourceCode.traverse(); - return sourceCode; - } - it("should return empty array for a declaration without var()", () => { const css = "a { color: red; }"; const sourceCode = createSourceCode(css); @@ -999,60 +1035,32 @@ describe("CSSSourceCode", () => { assert.strictEqual(paddingVars.length, 1); assert.strictEqual(paddingVars[0].children[0].name, "--padding"); }); - }); - describe("getClosestVariableValue()", () => { - /** - * Helper to create a CSSSourceCode from CSS text and trigger traversal. - * @param {string} css The CSS text. - * @returns {import("../../src/languages/css-source-code.js").CSSSourceCode} The source code instance. - */ - function createSourceCode(css) { - const sourceCode = new CSSSourceCode({ - text: css, - ast: toPlainObject(parse(css, { positions: true })), - }); - - sourceCode.traverse(); - return sourceCode; - } - - /** - * Helper to find the first var() Function node in the AST. - * @param {Object} node The node to search. - * @returns {Object|null} The var() function node, or null. - */ - function findVarFunc(node) { - if (node.type === "Function" && node.name.toLowerCase() === "var") { - return node; - } - for (const key of Object.keys(node)) { - if (key === "loc" || key === "type") { - continue; - } - const child = node[key]; + it("should return nested var() function nodes from fallback", () => { + const css = "a { color: var(--my-color-1, var(--my-color-2)); }"; + const sourceCode = createSourceCode(css); + const decl = sourceCode.ast.children[0].block.children[0]; + const vars = sourceCode.getDeclarationVariables(decl); - if (Array.isArray(child)) { - for (const item of child) { - if (item && typeof item === "object" && item.type) { - const result = findVarFunc(item); + assert.strictEqual(vars.length, 2); + assert.strictEqual(vars[0].children[0].name, "--my-color-1"); + assert.strictEqual(vars[1].children[0].name, "--my-color-2"); + }); - if (result) { - return result; - } - } - } - } else if (child && typeof child === "object" && child.type) { - const result = findVarFunc(child); + it("should return deeply nested var() function nodes", () => { + const css = "a { color: var(--a, var(--b, var(--c))); }"; + const sourceCode = createSourceCode(css); + const decl = sourceCode.ast.children[0].block.children[0]; + const vars = sourceCode.getDeclarationVariables(decl); - if (result) { - return result; - } - } - } - return null; - } + assert.strictEqual(vars.length, 3); + assert.strictEqual(vars[0].children[0].name, "--a"); + assert.strictEqual(vars[1].children[0].name, "--b"); + assert.strictEqual(vars[2].children[0].name, "--c"); + }); + }); + describe("getClosestVariableValue()", () => { it("should return value from same-block declaration", () => { const css = "a { --my-color: red; color: var(--my-color); }"; const sourceCode = createSourceCode(css); @@ -1140,57 +1148,6 @@ describe("CSSSourceCode", () => { }); describe("getVariableValues()", () => { - /** - * Helper to create a CSSSourceCode from CSS text and trigger traversal. - * @param {string} css The CSS text. - * @returns {import("../../src/languages/css-source-code.js").CSSSourceCode} The source code instance. - */ - function createSourceCode(css) { - const sourceCode = new CSSSourceCode({ - text: css, - ast: toPlainObject(parse(css, { positions: true })), - }); - - sourceCode.traverse(); - return sourceCode; - } - - /** - * Helper to find the first var() Function node in the AST. - * @param {Object} node The node to search. - * @returns {Object|null} The var() function node, or null. - */ - function findVarFunc(node) { - if (node.type === "Function" && node.name.toLowerCase() === "var") { - return node; - } - for (const key of Object.keys(node)) { - if (key === "loc" || key === "type") { - continue; - } - const child = node[key]; - - if (Array.isArray(child)) { - for (const item of child) { - if (item && typeof item === "object" && item.type) { - const result = findVarFunc(item); - - if (result) { - return result; - } - } - } - } else if (child && typeof child === "object" && child.type) { - const result = findVarFunc(child); - - if (result) { - return result; - } - } - } - return null; - } - it("should return empty array for unknown variable without fallback", () => { const css = "a { color: var(--unknown); }"; const sourceCode = createSourceCode(css);