diff --git a/src/languages/css-source-code.js b/src/languages/css-source-code.js index ec98a51b..5c42a04d 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,100 @@ 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 +530,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..8e32336e 100644 --- a/src/rules/no-invalid-properties.js +++ b/src/rules/no-invalid-properties.js @@ -127,7 +127,12 @@ export default { const sourceCode = context.sourceCode; const lexer = sourceCode.lexer; - /** @type {Map} */ + /** + * 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(); /** @@ -146,6 +151,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 +256,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 +271,73 @@ export default { valueSegmentLocs, resolvedCache, ) { - const varValue = vars.get(varNode.children[0].name); + const closestValue = sourceCode.getClosestVariableValue(varNode); + + if (closestValue) { + const valueText = sourceCode.getText(closestValue).trim(); + const parsed = parseVarFunction(valueText); - if (varValue) { + 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, diff --git a/tests/languages/css-source-code.test.js b/tests/languages/css-source-code.test.js index f5a725c1..b1a2075f 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", () => { @@ -932,4 +984,204 @@ describe("CSSSourceCode", () => { ]); }); }); + + describe("getDeclarationVariables()", () => { + 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()", () => { + 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()", () => { + 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: [