Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 239 additions & 1 deletion src/languages/css-source-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -104,6 +104,18 @@ export class CSSSourceCode extends TextSourceCodeBase {
*/
lexer;

/**
* Map of custom property names to their uses.
* @type {Map<string, {declarations: Array<DeclarationPlain>, definitions: Array<AtrulePlain>, references: Array<FunctionNodePlain>}>}
*/
#customProperties = new Map();

/**
* Map of declaration nodes to the var() function nodes they contain.
* @type {WeakMap<DeclarationPlain, Array<FunctionNodePlain>>}
*/
#declarationVariables = new WeakMap();

/**
* Creates a new instance.
* @param {Object} options The options for the instance.
Expand Down Expand Up @@ -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<FunctionNodePlain>} 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<Raw|ValuePlain>} 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<CSSTraversalStep>} The steps that were taken while traversing the source code.
Expand All @@ -267,12 +412,100 @@ export class CSSSourceCode extends TextSourceCodeBase {
/** @type {Array<CSSTraversalStep>} */
const steps = (this.#steps = []);

/**
* Stack to track the current declaration being visited for
* collecting var() references in declarations.
* @type {Array<DeclarationPlain>}
*/
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 (
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this also handle nested var() calls in fallback positions, such as var(--a, var(--b))?

It seems that no-invalid-properties already has custom logic for resolving nested fallback var() references. If this RFC is meant to centralize that behavior in CSSSourceCode, I think this case need to be covered as well. Otherwise, rules may still need their own fallback parsing logic.

Just for reference, according to MDN, the following syntax is valid:

https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/var#syntax

var(--custom-prop, var(--default-value));
var(--custom-prop, var(--default-value, red));

When I tested it against a nested var() function, it failed:

		it("should return nested var() function nodes", () => {
			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);

			console.log(decl.value.children[0]);
			console.log(vars);

			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");
		});

The stdout is logged as follows:

{
  type: 'Function',
  loc: {
    source: '<unknown>',
    start: { offset: 11, line: 1, column: 12 },
    end: { offset: 47, line: 1, column: 48 }
  },
  name: 'var',
  children: [
    { type: 'Identifier', loc: [Object], name: '--my-color-1' },
    { type: 'Operator', loc: [Object], value: ',' },
    { type: 'Raw', loc: [Object], value: ' var(--my-color-2)' }
  ]
}
[
  {
    type: 'Function',
    loc: { source: '<unknown>', start: [Object], end: [Object] },
    name: 'var',
    children: [ [Object], [Object], [Object] ]
  }
]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added #extractNestedVarRefs private method that re-parses Raw fallback text using the CSS parser and tracks any nested var() references found. It handles arbitrary nesting depth by recursing. Location info is adjusted to map back to the original source positions via adjustLoc/adjustAllLocs helpers. Two new tests cover single-level and 3-level deep nesting. Commit: 051fea2

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({
Expand All @@ -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({
Expand Down
Loading