Skip to content

Commit a8a01db

Browse files
committed
feat: add CSS custom property tracking to CSSSourceCode
Implements RFC eslint/rfcs#136 to centralize CSS variable tracking on CSSSourceCode, making it available to all rules. Adds: - `#customProperties` map populated during AST traversal - `getDeclarationVariables(declaration)` to get var() functions in a declaration - `getClosestVariableValue(func)` to resolve closest value for a var() call - `getVariableValues(func)` to get all possible values across the file Refactors `no-invalid-properties` to use the new API instead of maintaining its own variable tracking. This naturally fixes variable hoisting (#199) since the new API sees all declarations in the file regardless of source position. Refs #160 Fixes #199
1 parent f8743a4 commit a8a01db

4 files changed

Lines changed: 616 additions & 19 deletions

File tree

src/languages/css-source-code.js

Lines changed: 317 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,19 @@ import { visitorKeys } from "./css-visitor-keys.js";
2020
//-----------------------------------------------------------------------------
2121

2222
/**
23-
* @import { CssNode, CssNodePlain, CssLocationRange, Comment, Lexer, StyleSheetPlain } from "@eslint/css-tree"
23+
* @import { CssNode, CssNodePlain, CssLocationRange, Comment, Lexer, StyleSheetPlain, DeclarationPlain, AtrulePlain, FunctionNodePlain, ValuePlain, Raw } from "@eslint/css-tree"
2424
* @import { SourceRange, FileProblem, DirectiveType, RulesConfig } from "@eslint/core"
2525
* @import { CSSSyntaxElement } from "../types.js"
2626
* @import { CSSLanguageOptions } from "./css-language.js"
2727
*/
2828

29+
/**
30+
* @typedef {Object} CustomPropertyUses
31+
* @property {Array<DeclarationPlain>} declarations Declaration nodes where the custom property value is declared.
32+
* @property {Array<AtrulePlain>} definitions Atrule nodes where the custom property is defined using `@property`.
33+
* @property {Array<FunctionNodePlain>} references Function nodes (`var()`) where the custom property is used.
34+
*/
35+
2936
//-----------------------------------------------------------------------------
3037
// Helpers
3138
//-----------------------------------------------------------------------------
@@ -86,6 +93,18 @@ export class CSSSourceCode extends TextSourceCodeBase {
8693
*/
8794
#inlineConfigComments;
8895

96+
/**
97+
* Map of custom property names to their uses.
98+
* @type {Map<string, CustomPropertyUses>|undefined}
99+
*/
100+
#customProperties;
101+
102+
/**
103+
* Map of declarations to the var() functions they contain.
104+
* @type {WeakMap<CssNodePlain, Array<FunctionNodePlain>>}
105+
*/
106+
#declarationVariables = new WeakMap();
107+
89108
/**
90109
* The AST of the source code.
91110
* @type {StyleSheetPlain}
@@ -254,6 +273,22 @@ export class CSSSourceCode extends TextSourceCodeBase {
254273
return this.#parents.get(node);
255274
}
256275

276+
/**
277+
* Ensures the custom properties map entry exists for a given name.
278+
* @param {string} name The custom property name.
279+
* @returns {CustomPropertyUses} The uses object.
280+
*/
281+
#ensureCustomProperty(name) {
282+
if (!this.#customProperties.has(name)) {
283+
this.#customProperties.set(name, {
284+
declarations: [],
285+
definitions: [],
286+
references: [],
287+
});
288+
}
289+
return this.#customProperties.get(name);
290+
}
291+
257292
/**
258293
* Traverse the source code and return the steps that were taken.
259294
* @returns {Iterable<CSSTraversalStep>} The steps that were taken while traversing the source code.
@@ -266,13 +301,70 @@ export class CSSSourceCode extends TextSourceCodeBase {
266301

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

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

308+
/**
309+
* Stack of declaration nodes currently being visited.
310+
* Used to track which var() functions belong to which declaration.
311+
* @type {Array<CssNodePlain>}
312+
*/
313+
const declStack = [];
314+
272315
const visit = (node, parent) => {
273316
// first set the parent
274317
this.#parents.set(node, parent);
275318

319+
// Track custom property declarations
320+
if (node.type === "Declaration" && node.property.startsWith("--")) {
321+
this.#ensureCustomProperty(node.property).declarations.push(
322+
node,
323+
);
324+
}
325+
326+
// Track @property definitions
327+
if (node.type === "Atrule" && node.name === "property") {
328+
const identNode = node.prelude?.children?.[0];
329+
if (
330+
identNode?.type === "Identifier" &&
331+
identNode.name.startsWith("--")
332+
) {
333+
this.#ensureCustomProperty(identNode.name).definitions.push(
334+
node,
335+
);
336+
}
337+
}
338+
339+
// Track var() references
340+
if (node.type === "Function" && node.name.toLowerCase() === "var") {
341+
const identNode = node.children?.[0];
342+
if (
343+
identNode?.type === "Identifier" &&
344+
identNode.name.startsWith("--")
345+
) {
346+
this.#ensureCustomProperty(identNode.name).references.push(
347+
node,
348+
);
349+
}
350+
351+
// Associate this var() with the current declaration
352+
if (declStack.length > 0) {
353+
const currentDecl = declStack.at(-1);
354+
const vars = this.#declarationVariables.get(currentDecl);
355+
if (vars) {
356+
vars.push(node);
357+
}
358+
}
359+
}
360+
361+
// Track declaration context for getDeclarationVariables
362+
const isDeclaration = node.type === "Declaration";
363+
if (isDeclaration) {
364+
declStack.push(node);
365+
this.#declarationVariables.set(node, []);
366+
}
367+
276368
// then add the step
277369
steps.push(
278370
new CSSTraversalStep({
@@ -297,6 +389,11 @@ export class CSSSourceCode extends TextSourceCodeBase {
297389
}
298390
}
299391

392+
// Pop declaration context
393+
if (isDeclaration) {
394+
declStack.pop();
395+
}
396+
300397
// then add the exit step
301398
steps.push(
302399
new CSSTraversalStep({
@@ -311,4 +408,223 @@ export class CSSSourceCode extends TextSourceCodeBase {
311408

312409
return steps;
313410
}
411+
412+
/**
413+
* Returns an array of `var()` function nodes used in the given declaration's value.
414+
* @param {DeclarationPlain} declaration The declaration node to inspect.
415+
* @returns {Array<FunctionNodePlain>} The `var()` function nodes found in the declaration.
416+
*/
417+
getDeclarationVariables(declaration) {
418+
// Ensure traversal has happened
419+
if (!this.#customProperties) {
420+
this.traverse();
421+
}
422+
423+
return this.#declarationVariables.get(declaration) ?? [];
424+
}
425+
426+
/**
427+
* Returns the closest computed value for a `var()` function node or a
428+
* custom property name.
429+
*
430+
* When called with a `var()` function node, the resolution order is:
431+
* 1. If the current rule block has one or more custom property declarations
432+
* for the variable, return the value of the last one.
433+
* 2. If the `var()` has a fallback value, return the fallback.
434+
* 3. If a previous rule had a custom property declaration, return the last value.
435+
* 4. If there's a `@property` with an `initial-value`, return the initial value.
436+
* 5. Otherwise, return `undefined`.
437+
*
438+
* When called with a custom property name string, returns the last declared
439+
* value for that property, or the `@property` initial-value if no
440+
* declarations exist.
441+
* @param {FunctionNodePlain|string} funcOrName The `var()` function node or custom property name.
442+
* @returns {ValuePlain|Raw|undefined} The closest value node, or `undefined`.
443+
*/
444+
getClosestVariableValue(funcOrName) {
445+
// Ensure traversal has happened
446+
if (!this.#customProperties) {
447+
this.traverse();
448+
}
449+
450+
// When called with a string name, return the last declaration value
451+
if (typeof funcOrName === "string") {
452+
const uses = this.#customProperties.get(funcOrName);
453+
454+
if (uses && uses.declarations.length > 0) {
455+
return uses.declarations.at(-1).value;
456+
}
457+
458+
// Fall back to @property initial-value
459+
if (uses) {
460+
for (const definition of uses.definitions) {
461+
const block = definition.block;
462+
if (block?.children) {
463+
for (const child of block.children) {
464+
if (
465+
child.type === "Declaration" &&
466+
child.property === "initial-value"
467+
) {
468+
return child.value;
469+
}
470+
}
471+
}
472+
}
473+
}
474+
475+
return undefined;
476+
}
477+
478+
const func = funcOrName;
479+
const identNode = func.children?.[0];
480+
if (!identNode || identNode.type !== "Identifier") {
481+
return undefined;
482+
}
483+
484+
const varName = identNode.name;
485+
const uses = this.#customProperties.get(varName);
486+
487+
// Find the enclosing Rule node for this var() function
488+
let ruleBlock = null;
489+
let ancestor = this.#parents.get(func);
490+
while (ancestor) {
491+
if (ancestor.type === "Rule") {
492+
ruleBlock = ancestor;
493+
break;
494+
}
495+
ancestor = this.#parents.get(ancestor);
496+
}
497+
498+
// Step 1: Check current rule block for declarations
499+
if (ruleBlock && uses) {
500+
const blockDeclarations = uses.declarations.filter(decl => {
501+
let parent = this.#parents.get(decl);
502+
while (parent) {
503+
if (parent === ruleBlock) {
504+
return true;
505+
}
506+
if (parent.type === "Rule") {
507+
return false;
508+
}
509+
parent = this.#parents.get(parent);
510+
}
511+
return false;
512+
});
513+
514+
if (blockDeclarations.length > 0) {
515+
return blockDeclarations.at(-1).value;
516+
}
517+
}
518+
519+
// Step 2: Check fallback value
520+
if (func.children.length >= 3) {
521+
const fallback = func.children[2];
522+
if (fallback) {
523+
return /** @type {Raw} */ (fallback);
524+
}
525+
}
526+
527+
// Step 3: Check declarations in previous rules
528+
if (uses) {
529+
const funcOffset = func.loc?.start?.offset ?? Infinity;
530+
const previousDeclarations = uses.declarations.filter(decl => {
531+
// Must not be in the same rule block (already checked)
532+
let parent = this.#parents.get(decl);
533+
while (parent) {
534+
if (parent === ruleBlock) {
535+
return false;
536+
}
537+
if (parent.type === "Rule") {
538+
break;
539+
}
540+
parent = this.#parents.get(parent);
541+
}
542+
return (decl.loc?.start?.offset ?? 0) < funcOffset;
543+
});
544+
545+
if (previousDeclarations.length > 0) {
546+
return previousDeclarations.at(-1).value;
547+
}
548+
}
549+
550+
// Step 4: Check @property initial-value
551+
if (uses) {
552+
for (const definition of uses.definitions) {
553+
const block = definition.block;
554+
if (block?.children) {
555+
for (const child of block.children) {
556+
if (
557+
child.type === "Declaration" &&
558+
child.property === "initial-value"
559+
) {
560+
return child.value;
561+
}
562+
}
563+
}
564+
}
565+
}
566+
567+
// Step 5: Return undefined
568+
return undefined;
569+
}
570+
571+
/**
572+
* Returns all possible values for a `var()` function node.
573+
*
574+
* The returned array is composed of:
575+
* 1. If there's a `@property` with an `initial-value`, that value comes first.
576+
* 2. The values from custom property declarations throughout the file, in source order.
577+
* 3. The fallback value (if present) comes last.
578+
* @param {FunctionNodePlain} func The `var()` function node.
579+
* @returns {Array<ValuePlain|Raw>} Array of value nodes.
580+
*/
581+
getVariableValues(func) {
582+
// Ensure traversal has happened
583+
if (!this.#customProperties) {
584+
this.traverse();
585+
}
586+
587+
const identNode = func.children?.[0];
588+
if (!identNode || identNode.type !== "Identifier") {
589+
return [];
590+
}
591+
592+
const varName = identNode.name;
593+
const uses = this.#customProperties.get(varName);
594+
595+
/** @type {Array<ValuePlain|Raw>} */
596+
const values = [];
597+
598+
if (uses) {
599+
// Step 1: @property initial-value first
600+
for (const definition of uses.definitions) {
601+
const block = definition.block;
602+
if (block?.children) {
603+
for (const child of block.children) {
604+
if (
605+
child.type === "Declaration" &&
606+
child.property === "initial-value"
607+
) {
608+
values.push(child.value);
609+
}
610+
}
611+
}
612+
}
613+
614+
// Step 2: All declarations in source order
615+
for (const decl of uses.declarations) {
616+
values.push(decl.value);
617+
}
618+
}
619+
620+
// Step 3: Fallback value last
621+
if (func.children.length >= 3) {
622+
const fallback = func.children[2];
623+
if (fallback) {
624+
values.push(/** @type {Raw} */ (fallback));
625+
}
626+
}
627+
628+
return values;
629+
}
314630
}

0 commit comments

Comments
 (0)