Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
318 changes: 317 additions & 1 deletion src/languages/css-source-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,19 @@ 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"
*/

/**
* @typedef {Object} CustomPropertyUses
* @property {Array<DeclarationPlain>} declarations Declaration nodes where the custom property value is declared.
* @property {Array<AtrulePlain>} definitions Atrule nodes where the custom property is defined using `@property`.
* @property {Array<FunctionNodePlain>} references Function nodes (`var()`) where the custom property is used.
*/

//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
Expand Down Expand Up @@ -86,6 +93,18 @@ export class CSSSourceCode extends TextSourceCodeBase {
*/
#inlineConfigComments;

/**
* Map of custom property names to their uses.
* @type {Map<string, CustomPropertyUses>|undefined}
*/
#customProperties;

/**
* Map of declarations to the var() functions they contain.
* @type {WeakMap<CssNodePlain, Array<FunctionNodePlain>>}
*/
#declarationVariables = new WeakMap();

/**
* The AST of the source code.
* @type {StyleSheetPlain}
Expand Down Expand Up @@ -254,6 +273,22 @@ export class CSSSourceCode extends TextSourceCodeBase {
return this.#parents.get(node);
}

/**
* Ensures the custom properties map entry exists for a given name.
* @param {string} name The custom property name.
* @returns {CustomPropertyUses} The uses object.
*/
#ensureCustomProperty(name) {
if (!this.#customProperties.has(name)) {
this.#customProperties.set(name, {
declarations: [],
definitions: [],
references: [],
});
}
return this.#customProperties.get(name);
}

/**
* 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 @@ -266,13 +301,70 @@ export class CSSSourceCode extends TextSourceCodeBase {

/** @type {Array<CSSTraversalStep>} */
const steps = (this.#steps = []);
this.#customProperties = new Map();

// Note: We can't use `walk` from `css-tree` because it uses `CssNode` instead of `CssNodePlain`

/**
* Stack of declaration nodes currently being visited.
* Used to track which var() functions belong to which declaration.
* @type {Array<CssNodePlain>}
*/
const declStack = [];

const visit = (node, parent) => {
// first set the parent
this.#parents.set(node, parent);

// Track custom property declarations
if (node.type === "Declaration" && node.property.startsWith("--")) {
this.#ensureCustomProperty(node.property).declarations.push(
node,
);
}

// Track @property definitions
if (node.type === "Atrule" && node.name === "property") {
const identNode = node.prelude?.children?.[0];
if (
identNode?.type === "Identifier" &&
identNode.name.startsWith("--")
) {
this.#ensureCustomProperty(identNode.name).definitions.push(
node,
);
}
}

// Track var() references
if (node.type === "Function" && node.name.toLowerCase() === "var") {
const identNode = node.children?.[0];
if (
identNode?.type === "Identifier" &&
identNode.name.startsWith("--")
) {
this.#ensureCustomProperty(identNode.name).references.push(
node,
);
}

// Associate this var() with the current declaration
if (declStack.length > 0) {
const currentDecl = declStack.at(-1);
const vars = this.#declarationVariables.get(currentDecl);
if (vars) {
vars.push(node);
}
}
}

// Track declaration context for getDeclarationVariables
const isDeclaration = node.type === "Declaration";
if (isDeclaration) {
declStack.push(node);
this.#declarationVariables.set(node, []);
}

// then add the step
steps.push(
new CSSTraversalStep({
Expand All @@ -297,6 +389,11 @@ export class CSSSourceCode extends TextSourceCodeBase {
}
}

// Pop declaration context
if (isDeclaration) {
declStack.pop();
}

// then add the exit step
steps.push(
new CSSTraversalStep({
Expand All @@ -311,4 +408,223 @@ export class CSSSourceCode extends TextSourceCodeBase {

return steps;
}

/**
* Returns an array of `var()` function nodes used in the given declaration's value.
* @param {DeclarationPlain} declaration The declaration node to inspect.
* @returns {Array<FunctionNodePlain>} The `var()` function nodes found in the declaration.
*/
getDeclarationVariables(declaration) {
// Ensure traversal has happened
if (!this.#customProperties) {
this.traverse();
}

return this.#declarationVariables.get(declaration) ?? [];
}

/**
* Returns the closest computed value for a `var()` function node or a
* custom property name.
*
* When called with a `var()` function node, the resolution order is:
* 1. If the current rule block has one or more custom property declarations
* for the variable, return the value of the last one.
* 2. If the `var()` has a fallback value, return the fallback.
* 3. If a previous rule had a custom property declaration, return the last value.
* 4. If there's a `@property` with an `initial-value`, return the initial value.
* 5. Otherwise, return `undefined`.
*
* When called with a custom property name string, returns the last declared
* value for that property, or the `@property` initial-value if no
* declarations exist.
* @param {FunctionNodePlain|string} funcOrName The `var()` function node or custom property name.
* @returns {ValuePlain|Raw|undefined} The closest value node, or `undefined`.
*/
getClosestVariableValue(funcOrName) {
// Ensure traversal has happened
if (!this.#customProperties) {
this.traverse();
}

// When called with a string name, return the last declaration value
if (typeof funcOrName === "string") {
const uses = this.#customProperties.get(funcOrName);

if (uses && uses.declarations.length > 0) {
return uses.declarations.at(-1).value;
}

// Fall back to @property initial-value
if (uses) {
for (const definition of uses.definitions) {
const block = definition.block;
if (block?.children) {
for (const child of block.children) {
if (
child.type === "Declaration" &&
child.property === "initial-value"
) {
return child.value;
}
}
}
}
}

return undefined;
}

const func = funcOrName;
const identNode = func.children?.[0];
if (!identNode || identNode.type !== "Identifier") {
return undefined;
}

const varName = identNode.name;
const uses = this.#customProperties.get(varName);

// Find the enclosing Rule node for this var() function
let ruleBlock = null;
let ancestor = this.#parents.get(func);
while (ancestor) {
if (ancestor.type === "Rule") {
ruleBlock = ancestor;
break;
}
ancestor = this.#parents.get(ancestor);
}

// Step 1: Check current rule block for declarations
if (ruleBlock && uses) {
const blockDeclarations = uses.declarations.filter(decl => {
let parent = this.#parents.get(decl);
while (parent) {
if (parent === ruleBlock) {
return true;
}
if (parent.type === "Rule") {
return false;
}
parent = this.#parents.get(parent);
}
return false;
});

if (blockDeclarations.length > 0) {
return blockDeclarations.at(-1).value;
}
}

// Step 2: Check fallback value
if (func.children.length >= 3) {
const fallback = func.children[2];
if (fallback) {
return /** @type {Raw} */ (fallback);
}
}

// Step 3: Check declarations in previous rules
if (uses) {
const funcOffset = func.loc?.start?.offset ?? Infinity;
const previousDeclarations = uses.declarations.filter(decl => {
// Must not be in the same rule block (already checked)
let parent = this.#parents.get(decl);
while (parent) {
if (parent === ruleBlock) {
return false;
}
if (parent.type === "Rule") {
break;
}
parent = this.#parents.get(parent);
}
return (decl.loc?.start?.offset ?? 0) < funcOffset;
});

if (previousDeclarations.length > 0) {
return previousDeclarations.at(-1).value;
}
}

// Step 4: Check @property initial-value
if (uses) {
for (const definition of uses.definitions) {
const block = definition.block;
if (block?.children) {
for (const child of block.children) {
if (
child.type === "Declaration" &&
child.property === "initial-value"
) {
return child.value;
}
}
}
}
}

// Step 5: Return undefined
return undefined;
}

/**
* Returns all possible values for a `var()` function node.
*
* The returned array is composed of:
* 1. If there's a `@property` with an `initial-value`, that value comes first.
* 2. The values from custom property declarations throughout the file, in source order.
* 3. The fallback value (if present) comes last.
* @param {FunctionNodePlain} func The `var()` function node.
* @returns {Array<ValuePlain|Raw>} Array of value nodes.
*/
getVariableValues(func) {
// Ensure traversal has happened
if (!this.#customProperties) {
this.traverse();
}

const identNode = func.children?.[0];
if (!identNode || identNode.type !== "Identifier") {
return [];
}

const varName = identNode.name;
const uses = this.#customProperties.get(varName);

/** @type {Array<ValuePlain|Raw>} */
const values = [];

if (uses) {
// Step 1: @property initial-value first
for (const definition of uses.definitions) {
const block = definition.block;
if (block?.children) {
for (const child of block.children) {
if (
child.type === "Declaration" &&
child.property === "initial-value"
) {
values.push(child.value);
}
}
}
}

// Step 2: All declarations in source order
for (const decl of uses.declarations) {
values.push(decl.value);
}
}

// Step 3: Fallback value last
if (func.children.length >= 3) {
const fallback = func.children[2];
if (fallback) {
values.push(/** @type {Raw} */ (fallback));
}
}

return values;
}
}
Loading