feat 🥁: add wrapper function support with reference tracking

- add reference tracking for wrapper functions in vanilla-extract style objects
- implement ReferenceTracker class for detecting vanilla-extract imports
- add createReferenceBasedNodeVisitors for automatic function detection
- support wrapper functions with parameter mapping enable all lint rules to work with custom wrapper functions

This commit introduces robust reference tracking and wrapper function support, enabling all lint rules to work seamlessly with custom vanilla-extract style patterns while preserving compatibility with existing usage and improving rule extensibility.
This commit is contained in:
Seongmin Choi 2025-06-26 01:51:36 +09:00 committed by GitHub
parent 35875fbb31
commit 02576d923c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1942 additions and 212 deletions

View file

@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.11.0] - 2025-06-25
- add reference tracking for wrapper functions in vanilla-extract style objects
- implement ReferenceTracker class for detecting vanilla-extract imports
- add createReferenceBasedNodeVisitors for automatic function detection
- support wrapper functions with parameter mapping enable all lint rules to work with custom wrapper functions
## [1.10.0] - 2025-04-19 ## [1.10.0] - 2025-04-19
- confirm compatibility with ESLint 8.57.0 - confirm compatibility with ESLint 8.57.0

View file

@ -27,6 +27,8 @@ ordering option based on groups available in [concentric CSS](src/css-rules/conc
- Handles complex cases like nested objects, arrays of styles, and pseudo selectors - Handles complex cases like nested objects, arrays of styles, and pseudo selectors
- Works with camelCase properties as used in vanilla-extract - Works with camelCase properties as used in vanilla-extract
- Additional linting rules for enhanced code quality (see roadmap for upcoming features) - Additional linting rules for enhanced code quality (see roadmap for upcoming features)
- Automatic wrapper function detection - works with custom wrapper functions that call vanilla-extract APIs, using
reference tracking to apply all rules regardless of how vanilla-extract functions are wrapped
## Requirements ## Requirements

View file

@ -1,6 +1,6 @@
{ {
"name": "@antebudimir/eslint-plugin-vanilla-extract", "name": "@antebudimir/eslint-plugin-vanilla-extract",
"version": "1.10.0", "version": "1.11.0",
"description": "ESLint plugin for enforcing best practices in vanilla-extract CSS styles, including CSS property ordering and additional linting rules.", "description": "ESLint plugin for enforcing best practices in vanilla-extract CSS styles, including CSS property ordering and additional linting rules.",
"author": "Ante Budimir", "author": "Ante Budimir",
"license": "MIT", "license": "MIT",

View file

@ -0,0 +1,199 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import alphabeticalOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/alphabetical-order/style',
rule: alphabeticalOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic style object with alphabetical ordering
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
margin: '10px',
padding: '20px',
zIndex: 1
});
`,
// Style with nested selectors
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
],
invalid: [
// Basic style object with incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
backgroundColor: 'red',
alignItems: 'center',
padding: '20px',
color: 'blue',
margin: '10px',
display: 'flex',
zIndex: 1
});
`,
errors: [{ messageId: 'alphabeticalOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
margin: '10px',
padding: '20px',
zIndex: 1
});
`,
},
// Style with nested selectors having incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
backgroundColor: 'red',
alignItems: 'center',
color: 'blue',
selectors: {
'&:hover': {
color: 'white',
backgroundColor: 'blue'
}
}
});
`,
errors: [{ messageId: 'alphabeticalOrder' }, { messageId: 'alphabeticalOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
},
],
});

View file

@ -0,0 +1,217 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import concentricOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/concentric-order/style-custom',
rule: concentricOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic style object with concentric ordering through wrapper function
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
boxSizing: 'border-box',
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
transform: 'none',
opacity: 1,
margin: '1rem',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red',
padding: '2rem',
width: '10rem',
height: '10rem',
color: 'blue',
fontSize: '16rem'
});
`,
// Style with nested selectors
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
position: 'relative',
display: 'flex',
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
position: 'relative',
opacity: 0.8,
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
],
invalid: [
// Basic style object with incorrect concentric ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
color: 'blue',
width: '10rem',
display: 'flex',
backgroundColor: 'red',
margin: '1rem',
position: 'relative'
});
`,
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
position: 'relative',
display: 'flex',
margin: '1rem',
backgroundColor: 'red',
width: '10rem',
color: 'blue'
});
`,
},
// Style with nested selectors having incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
color: 'blue',
display: 'flex',
backgroundColor: 'red',
position: 'relative',
selectors: {
'&:hover': {
color: 'white',
position: 'relative',
backgroundColor: 'blue'
}
}
});
`,
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
position: 'relative',
display: 'flex',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
position: 'relative',
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
},
],
});

View file

@ -0,0 +1,379 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import customOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/custom-order/style-custom',
rule: customOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic style object with custom group ordering through wrapper function
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
// dimensions group first
width: '10rem',
height: '5rem',
// margin group second
margin: '1rem',
// font group third
fontFamily: 'sans-serif',
// border group fourth
border: '1px solid black',
// boxShadow group fifth
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
// remaining properties in concentric order
position: 'relative',
display: 'flex',
backgroundColor: 'red',
padding: '2rem',
color: 'blue'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
},
// Style with nested selectors
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
width: '10rem',
margin: '1rem',
fontFamily: 'sans-serif',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
position: 'relative',
backgroundColor: 'red',
selectors: {
'&:hover': {
width: '12rem',
margin: '2rem',
fontFamily: 'serif',
border: '2px solid blue',
boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
position: 'absolute',
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
},
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{ '@layer': { [layerMap[layer]]: rule } },
debugId,
);
const myStyle = layerStyle('component', {
// dimensions group first
width: '10rem',
height: '10rem',
// margin group second
margin: '1rem',
// font group third
fontFamily: 'sans-serif',
// border group fourth
border: '1px solid black',
// boxShadow group fifth
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
// remaining properties in alphabetical order
backgroundColor: 'red',
color: 'blue',
display: 'flex',
padding: '2rem',
position: 'relative'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
},
],
invalid: [
// Basic style object with incorrect custom group ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
position: 'relative',
border: '1px solid black',
width: '10rem',
color: 'blue',
margin: '1rem',
fontFamily: 'sans-serif',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
width: '10rem',
margin: '1rem',
fontFamily: 'sans-serif',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
position: 'relative',
backgroundColor: 'red',
color: 'blue'
});
`,
},
// Style with nested selectors having incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
border: '1px solid black',
width: '10rem',
position: 'relative',
margin: '1rem',
selectors: {
'&:hover': {
color: 'white',
width: '12rem',
border: '2px solid blue',
margin: '1.2rem',
backgroundColor: 'blue'
}
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
width: '10rem',
margin: '1rem',
border: '1px solid black',
position: 'relative',
selectors: {
'&:hover': {
width: '12rem',
margin: '1.2rem',
border: '2px solid blue',
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
position: 'relative',
border: '1px solid black',
width: '10rem',
padding: '2rem',
color: 'blue',
margin: '1rem',
display: 'flex',
fontFamily: 'sans-serif',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
width: '10rem',
margin: '1rem',
fontFamily: 'sans-serif',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
padding: '2rem',
position: 'relative'
});
`,
},
],
});

View file

@ -55,13 +55,22 @@ describe('isEffectivelyEmptyStylesObject', () => {
parent: null as unknown as TSESTree.Node, parent: null as unknown as TSESTree.Node,
}); });
it('should return true for an object with empty selectors, media, or supports objects', () => { it('should return false for an object with real CSS properties and empty nested objects', () => {
const object = createObjectExpression([ const object = createObjectExpression([
createProperty('color', createLiteral('blue')), createProperty('color', createLiteral('blue')),
createProperty('selectors', createObjectExpression([])), createProperty('selectors', createObjectExpression([])),
createProperty('@media', createObjectExpression([])), createProperty('@media', createObjectExpression([])),
createProperty('@supports', createObjectExpression([])), createProperty('@supports', createObjectExpression([])),
]); ]);
expect(isEffectivelyEmptyStylesObject(object)).toBe(false);
});
it('should return true for an object with only empty nested objects', () => {
const object = createObjectExpression([
createProperty('selectors', createObjectExpression([])),
createProperty('@media', createObjectExpression([])),
createProperty('@supports', createObjectExpression([])),
]);
expect(isEffectivelyEmptyStylesObject(object)).toBe(true); expect(isEffectivelyEmptyStylesObject(object)).toBe(true);
}); });

View file

@ -0,0 +1,305 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noEmptyStyleBlocksRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-empty-blocks/style-custom',
rule: noEmptyStyleBlocksRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic non-empty style through wrapper function
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
color: 'blue',
margin: '10px'
});
`,
// Style with comments (not empty)
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
/* This is a comment */
color: 'blue'
});
`,
],
invalid: [
// Empty style object through wrapper function
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const emptyStyle = layerStyle('component', {});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
`,
},
// Empty exported style object
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
export const emptyStyle = layerStyle('component', {});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
`,
},
// Style with empty nested selectors
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const styleWithComments = layerStyle('component', {
/* This is an empty style */
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
export const emptyStyle1 = layerStyle('component', {});
export const emptyStyle2 = layerStyle('component', {});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }, { messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
`,
},
// Export of variable with empty style
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myEmptyStyle = layerStyle('component', {});
export { myEmptyStyle };
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
export { myEmptyStyle };
`,
},
// Style in a callback or nested function
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
[1, 2, 3].forEach(() => {
layerStyle('component', {});
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
},
],
});

View file

@ -1,14 +1,35 @@
import type { Rule } from 'eslint'; import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils'; import { TSESTree } from '@typescript-eslint/utils';
import { isEmptyObject } from '../shared-utils/empty-object-processor.js'; import { isEmptyObject } from '../shared-utils/empty-object-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
import { processConditionalExpression } from './conditional-processor.js'; import { processConditionalExpression } from './conditional-processor.js';
import { processEmptyNestedStyles } from './empty-nested-style-processor.js'; import { processEmptyNestedStyles } from './empty-nested-style-processor.js';
import { reportEmptyDeclaration } from './fix-utils.js'; import { reportEmptyDeclaration } from './fix-utils.js';
import { removeNodeWithComma } from './node-remover.js';
import { getStyleKeyName } from './property-utils.js'; import { getStyleKeyName } from './property-utils.js';
import { processRecipeProperties } from './recipe-processor.js'; import { processRecipeProperties } from './recipe-processor.js';
import { processStyleVariants } from './style-variants-processor.js'; import { processStyleVariants } from './style-variants-processor.js';
/**
* Checks if a nested object (selectors, media, supports) contains only empty objects.
*/
const isNestedObjectEmpty = (obj: TSESTree.ObjectExpression): boolean => {
if (obj.properties.length === 0) {
return true;
}
return obj.properties.every((property) => {
if (property.type !== 'Property') {
return true; // Skip non-property elements
}
if (property.value.type === 'ObjectExpression') {
return isEmptyObject(property.value);
}
return false; // Non-object values mean it's not empty
});
};
/** /**
* Checks if a style object is effectively empty (contains only empty objects). * Checks if a style object is effectively empty (contains only empty objects).
*/ */
@ -48,124 +69,127 @@ export const isEffectivelyEmptyStylesObject = (stylesObject: TSESTree.ObjectExpr
} }
} }
// If this looks like a recipe object (has base or variants) // If this looks like a recipe (has base or variants), check recipe-specific emptiness
if (hasBaseProperty || hasVariantsProperty) { if (hasBaseProperty || hasVariantsProperty) {
// A recipe is effectively empty if both base and variants are empty
return isBaseEmpty && areAllVariantsEmpty; return isBaseEmpty && areAllVariantsEmpty;
} }
// / For non-recipe objects, check if all special properties (selectors, media queries, variants) are effectively empty // For regular style objects, check if all properties are effectively empty
function isSpecialProperty(propertyName: string | null): boolean { return stylesObject.properties.every((property) => {
return ( if (property.type !== 'Property') {
propertyName === 'selectors' || (propertyName && propertyName.startsWith('@')) || propertyName === 'variants' return true; // Skip spread elements for emptiness check
);
}
const specialProperties = stylesObject.properties.filter(
(prop): prop is TSESTree.Property => prop.type === 'Property' && isSpecialProperty(getStyleKeyName(prop.key)),
);
const allSpecialPropertiesEmpty = specialProperties.every((property) => {
if (property.value.type === 'ObjectExpression' && isEmptyObject(property.value)) {
return true;
} }
const propertyName = getStyleKeyName(property.key); const propertyName = getStyleKeyName(property.key);
// This defensive check handles malformed AST nodes that lack valid property names.
// This is difficult to test because it's challenging to construct a valid AST
// where getStyleKeyName would return a falsy value.
if (!propertyName) { if (!propertyName) {
return false; return true; // Skip properties we can't identify
} }
// For selectors, media queries and supports, check if all nested objects are empty // Handle special nested objects like selectors, media queries, supports
if ( if (propertyName === 'selectors' || propertyName.startsWith('@')) {
(propertyName === 'selectors' || (propertyName && propertyName.startsWith('@'))) && if (property.value.type === 'ObjectExpression') {
property.value.type === 'ObjectExpression' return isNestedObjectEmpty(property.value);
) { }
// This handles the edge case of an empty properties array. return false; // Non-object values in these properties
// This code path is difficult to test in isolation because it requires
// constructing a specific AST structure that bypasses earlier conditions.
if (property.value.properties.length === 0) {
return true;
} }
return property.value.properties.every((nestedProperty) => { // Handle regular CSS properties
return ( if (property.value.type === 'ObjectExpression') {
nestedProperty.type === 'Property' && return isEmptyObject(property.value);
nestedProperty.value.type === 'ObjectExpression' && }
isEmptyObject(nestedProperty.value)
); return false; // Non-empty property (literal values, etc.)
}); });
}
// Default fallback for cases not handled by the conditions above.
// This is difficult to test because it requires creating an AST structure
// that doesn't trigger any of the preceding return statements.
return false;
});
// If we have special properties and they're all empty, the style is effectively empty
return specialProperties.length > 0 && allSpecialPropertiesEmpty;
}; };
/** /**
* Creates ESLint rule visitors for detecting empty style blocks in vanilla-extract. * Creates ESLint rule visitors for detecting empty style blocks using reference tracking.
* @param ruleContext The ESLint rule rule context. * This automatically detects vanilla-extract functions based on their import statements.
* @returns An object with visitor functions for the ESLint rule.
*/ */
export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.RuleListener => { export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.RuleListener => {
// Track reported nodes to prevent duplicate reports const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
const reportedNodes = new Set<TSESTree.ObjectExpression>(); const reportedNodes = new Set<TSESTree.ObjectExpression>();
return { return {
// Include the reference tracking visitors
...trackingVisitor,
CallExpression(node) { CallExpression(node) {
if (node.callee.type !== 'Identifier') { if (node.callee.type !== 'Identifier') {
return; return;
} }
// Target vanilla-extract style functions const functionName = node.callee.name;
const styleApiFunctions = [
'style',
'styleVariants',
'recipe',
'globalStyle',
'fontFace',
'globalFontFace',
'keyframes',
'globalKeyframes',
];
if (!styleApiFunctions.includes(node.callee.name) || node.arguments.length === 0) { // Check if this function is tracked as a vanilla-extract function
if (!tracker.isTrackedFunction(functionName)) {
return;
}
const originalName = tracker.getOriginalName(functionName);
const wrapperInfo = tracker.getWrapperInfo(functionName);
if (!originalName || node.arguments.length === 0) {
return; return;
} }
// Handle styleVariants specifically // Handle styleVariants specifically
if (node.callee.name === 'styleVariants' && node.arguments[0]?.type === 'ObjectExpression') { if (originalName === 'styleVariants') {
processStyleVariants(ruleContext, node.arguments[0] as TSESTree.ObjectExpression, reportedNodes); // For wrapper functions, use the correct parameter index
const styleArgumentIndex = wrapperInfo?.parameterMapping ?? 0;
if (node.arguments.length <= styleArgumentIndex) {
return;
}
if (node.arguments[styleArgumentIndex]?.type === 'ObjectExpression') {
processStyleVariants(
ruleContext,
node.arguments[styleArgumentIndex] as TSESTree.ObjectExpression,
reportedNodes,
);
// If the entire styleVariants object is empty after processing, remove the declaration // If the entire styleVariants object is empty after processing, remove the declaration
if (isEmptyObject(node.arguments[0] as TSESTree.ObjectExpression)) { if (isEmptyObject(node.arguments[styleArgumentIndex] as TSESTree.ObjectExpression)) {
reportEmptyDeclaration(ruleContext, node.arguments[0] as TSESTree.Node, node as TSESTree.CallExpression); reportEmptyDeclaration(
ruleContext,
node.arguments[styleArgumentIndex] as TSESTree.Node,
node as TSESTree.CallExpression,
);
}
} }
return; return;
} }
const defaultStyleArgumentIndex = 0; // Determine the style argument index based on the original function name and wrapper info
const globalFunctionNames = ['globalStyle', 'globalFontFace', 'globalKeyframes']; let styleArgumentIndex: number;
// Determine the style argument index based on the function name if (wrapperInfo) {
const styleArgumentIndex = globalFunctionNames.includes(node.callee.name) ? 1 : defaultStyleArgumentIndex; // Use wrapper function parameter mapping
styleArgumentIndex = wrapperInfo.parameterMapping;
} else {
// Use original logic for direct vanilla-extract calls
styleArgumentIndex =
originalName === 'globalStyle' || originalName === 'globalKeyframes' || originalName === 'globalFontFace'
? 1
: 0;
}
// For global functions, check if we have enough arguments // For global functions, check if we have enough arguments
if (styleArgumentIndex === 1 && node.arguments.length <= styleArgumentIndex) { if (
(originalName === 'globalStyle' || originalName === 'globalKeyframes' || originalName === 'globalFontFace') &&
node.arguments.length <= styleArgumentIndex
) {
return;
}
// For wrapper functions, ensure we have enough arguments
if (wrapperInfo && node.arguments.length <= styleArgumentIndex) {
return; return;
} }
const styleArgument = node.arguments[styleArgumentIndex]; const styleArgument = node.arguments[styleArgumentIndex];
// This defensive check prevents duplicate processing of nodes. // This defensive check prevents duplicate processing of nodes.
// This code path's difficult to test because the ESLint visitor pattern
// typically ensures each node is only visited once per rule execution.
if (reportedNodes.has(styleArgument as TSESTree.ObjectExpression)) { if (reportedNodes.has(styleArgument as TSESTree.ObjectExpression)) {
return; return;
} }
@ -189,7 +213,8 @@ export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.Ru
} }
// For recipe - check if entire recipe is effectively empty // For recipe - check if entire recipe is effectively empty
if (node.callee.name === 'recipe' && styleArgument?.type === 'ObjectExpression') { if (originalName === 'recipe') {
if (styleArgument?.type === 'ObjectExpression') {
if (isEffectivelyEmptyStylesObject(styleArgument as TSESTree.ObjectExpression)) { if (isEffectivelyEmptyStylesObject(styleArgument as TSESTree.ObjectExpression)) {
reportedNodes.add(styleArgument as TSESTree.ObjectExpression); reportedNodes.add(styleArgument as TSESTree.ObjectExpression);
reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression); reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression);
@ -199,6 +224,19 @@ export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.Ru
// Process individual properties in recipe // Process individual properties in recipe
processRecipeProperties(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes); processRecipeProperties(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes);
} }
return;
}
// Handle fontFace functions - both fontFace and globalFontFace need empty object checks
if (originalName === 'fontFace' || originalName === 'globalFontFace') {
// Direct empty object case - remove the entire declaration
if (styleArgument?.type === 'ObjectExpression' && isEmptyObject(styleArgument as TSESTree.ObjectExpression)) {
reportedNodes.add(styleArgument as TSESTree.ObjectExpression);
reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression);
return;
}
return;
}
// For style objects with nested empty objects // For style objects with nested empty objects
if (styleArgument?.type === 'ObjectExpression') { if (styleArgument?.type === 'ObjectExpression') {
@ -214,7 +252,10 @@ export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.Ru
node: property.argument as Rule.Node, node: property.argument as Rule.Node,
messageId: 'emptySpreadObject', messageId: 'emptySpreadObject',
fix(fixer) { fix(fixer) {
return removeNodeWithComma(ruleContext, property as TSESTree.Node, fixer); if (property.range) {
return fixer.removeRange([property.range[0], property.range[1]]);
}
return null;
}, },
}); });
} }

View file

@ -1,52 +1,79 @@
import type { Rule } from 'eslint'; import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js'; import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js'; import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { processUnknownUnitInStyleObject } from './unknown-unit-processor.js'; import { processUnknownUnitInStyleObject } from './unknown-unit-processor.js';
/** /**
* Creates ESLint rule visitors for detecting and processing unknown CSS units * Creates ESLint rule visitors for detecting and processing unknown CSS units
* in style-related function calls. * in style-related function calls using reference tracking.
* This automatically detects vanilla-extract functions based on their import statements.
*/ */
export const createUnknownUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => { export const createUnknownUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
return { return {
// Include the import/variable tracking visitors
...trackingVisitor,
CallExpression(node) { CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) { if (node.callee.type !== AST_NODE_TYPES.Identifier) {
return; return;
} }
if (['fontFace', 'globalFontFace'].includes(node.callee.name)) { const functionName = node.callee.name;
const argumentIndex = node.callee.name === 'fontFace' ? 0 : 1;
if ( // Check if this function is tracked as a vanilla-extract function
node.arguments.length > argumentIndex && if (!tracker.isTrackedFunction(functionName)) {
node.arguments[argumentIndex]?.type === AST_NODE_TYPES.ObjectExpression
) {
processUnknownUnitInStyleObject(context, node.arguments[argumentIndex] as TSESTree.ObjectExpression);
}
return; return;
} }
if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) { const originalName = tracker.getOriginalName(functionName);
if (!originalName) {
return;
}
// Handle different function types based on their original imported name
switch (originalName) {
case 'fontFace':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processUnknownUnitInStyleObject(context, node.arguments[0] as TSESTree.ObjectExpression);
}
break;
case 'globalFontFace':
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
processUnknownUnitInStyleObject(context, node.arguments[1] as TSESTree.ObjectExpression);
}
break;
case 'style':
case 'styleVariants':
case 'keyframes':
if (node.arguments.length > 0) { if (node.arguments.length > 0) {
processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject); processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject);
} }
} break;
if (['globalKeyframes', 'globalStyle'].includes(node.callee.name) && node.arguments.length >= 2) { case 'globalStyle':
case 'globalKeyframes':
if (node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject); processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject);
} }
break;
if ( case 'recipe':
node.callee.name === 'recipe' && if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
node.arguments.length > 0 &&
node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression
) {
processRecipeProperties( processRecipeProperties(
context, context,
node.arguments[0] as TSESTree.ObjectExpression, node.arguments[0] as TSESTree.ObjectExpression,
processUnknownUnitInStyleObject, processUnknownUnitInStyleObject,
); );
} }
break;
}
}, },
}; };
}; };

View file

@ -1,55 +1,80 @@
import type { Rule } from 'eslint'; import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js'; import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js'; import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { processZeroUnitInStyleObject } from './zero-unit-processor.js'; import { processZeroUnitInStyleObject } from './zero-unit-processor.js';
/** /**
* Creates ESLint rule visitors for detecting and processing zero values with units in style-related function calls. * Creates ESLint rule visitors for detecting and processing zero values with units in style-related function calls.
* Uses reference tracking to automatically detect vanilla-extract functions based on their import statements.
* *
* @param context The ESLint rule context. * @param context The ESLint rule context.
* @returns An object with visitor functions for the ESLint rule. * @returns An object with visitor functions for the ESLint rule.
*
* This function sets up visitors for the following cases:
* 1. The `fontFace` and `globalFontFace` functions, processing their object arguments.
* 2. Style-related functions: `keyframes`, `style`, `styleVariants`, processing their style objects.
* 3. The `globalKeyframes` and `globalStyle` functions, processing the second argument as style objects.
* 4. The `recipe` function, processing the first argument as the recipe object.
*/ */
export const createZeroUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => { export const createZeroUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
return { return {
// Include the reference tracking visitors
...trackingVisitor,
CallExpression(node) { CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) { if (node.callee.type !== AST_NODE_TYPES.Identifier) {
return; return;
} }
if (['fontFace', 'globalFontFace'].includes(node.callee.name)) { const functionName = node.callee.name;
const argumentIndex = node.callee.name === 'fontFace' ? 0 : 1;
if ( // Check if this function is tracked as a vanilla-extract function
node.arguments.length > argumentIndex && if (!tracker.isTrackedFunction(functionName)) {
node.arguments[argumentIndex]?.type === AST_NODE_TYPES.ObjectExpression
) {
processZeroUnitInStyleObject(context, node.arguments[argumentIndex] as TSESTree.ObjectExpression);
}
return; return;
} }
if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) { const originalName = tracker.getOriginalName(functionName);
if (!originalName) {
return;
}
// Handle different function types based on their original imported name
switch (originalName) {
case 'fontFace':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processZeroUnitInStyleObject(context, node.arguments[0] as TSESTree.ObjectExpression);
}
break;
case 'globalFontFace':
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
processZeroUnitInStyleObject(context, node.arguments[1] as TSESTree.ObjectExpression);
}
break;
case 'style':
case 'styleVariants':
case 'keyframes':
if (node.arguments.length > 0) { if (node.arguments.length > 0) {
processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processZeroUnitInStyleObject); processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processZeroUnitInStyleObject);
} }
} break;
if (['globalKeyframes', 'globalStyle'].includes(node.callee.name) && node.arguments.length >= 2) { case 'globalStyle':
case 'globalKeyframes':
if (node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processZeroUnitInStyleObject); processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processZeroUnitInStyleObject);
} }
break;
if ( case 'recipe':
node.callee.name === 'recipe' && if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
node.arguments.length > 0 && processRecipeProperties(
node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression context,
) { node.arguments[0] as TSESTree.ObjectExpression,
processRecipeProperties(context, node.arguments[0] as TSESTree.ObjectExpression, processZeroUnitInStyleObject); processZeroUnitInStyleObject,
);
}
break;
} }
}, },
}; };

View file

@ -7,25 +7,20 @@ import { enforceConcentricCSSOrderInStyleObject } from '../concentric-order/styl
import { enforceUserDefinedGroupOrderInRecipe } from '../custom-order/recipe-order-enforcer.js'; import { enforceUserDefinedGroupOrderInRecipe } from '../custom-order/recipe-order-enforcer.js';
import { enforceUserDefinedGroupOrderInStyleObject } from '../custom-order/style-object-processor.js'; import { enforceUserDefinedGroupOrderInStyleObject } from '../custom-order/style-object-processor.js';
import { enforceFontFaceOrder } from './font-face-property-order-enforcer.js'; import { enforceFontFaceOrder } from './font-face-property-order-enforcer.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from './reference-tracker.js';
import { processStyleNode } from './style-node-processor.js'; import { processStyleNode } from './style-node-processor.js';
import type { SortRemainingProperties } from '../concentric-order/types.js'; import type { SortRemainingProperties } from '../concentric-order/types.js';
import type { OrderingStrategy } from '../types.js'; import type { OrderingStrategy } from '../types.js';
/** /**
* Creates an ESLint rule listener with visitors for style-related function calls. * Creates an ESLint rule listener with visitors for style-related function calls using reference tracking.
* This automatically detects vanilla-extract functions based on their import statements.
*
* @param ruleContext The ESLint rule context. * @param ruleContext The ESLint rule context.
* @param orderingStrategy The strategy to use for ordering CSS properties ('alphabetical', 'concentric', or 'userDefinedGroupOrder'). * @param orderingStrategy The strategy to use for ordering CSS properties ('alphabetical', 'concentric', or 'userDefinedGroupOrder').
* @param userDefinedGroupOrder An optional array of property groups for the 'userDefinedGroupOrder' strategy. * @param userDefinedGroupOrder An optional array of property groups for the 'userDefinedGroupOrder' strategy.
* @param sortRemainingProperties An optional strategy for sorting properties not in user-defined groups. * @param sortRemainingProperties An optional strategy for sorting properties not in user-defined groups.
* @returns An object with visitor functions for the ESLint rule. * @returns An object with visitor functions for the ESLint rule.
*
* This function sets up visitors for the following cases:
* 1. The fontFace and globalFontFace functions.
* 2. Style-related functions: 'keyframes', 'style', 'styleVariants'.
* 3. The 'globalStyle' and 'globalKeyframes' function
* 4. The 'recipe' function
*
* Each visitor applies the appropriate ordering strategy to the style objects in these function calls.
*/ */
export const createNodeVisitors = ( export const createNodeVisitors = (
ruleContext: Rule.RuleContext, ruleContext: Rule.RuleContext,
@ -33,7 +28,95 @@ export const createNodeVisitors = (
userDefinedGroupOrder?: string[], userDefinedGroupOrder?: string[],
sortRemainingProperties?: SortRemainingProperties, sortRemainingProperties?: SortRemainingProperties,
): Rule.RuleListener => { ): Rule.RuleListener => {
// Select the appropriate property processing function based on the ordering strategy const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
return {
// Include the import/variable tracking visitors
...trackingVisitor,
CallExpression(node) {
if (node.callee.type !== 'Identifier') {
return;
}
const functionName = node.callee.name;
// Check if this function is tracked as a vanilla-extract function
if (!tracker.isTrackedFunction(functionName)) {
return;
}
const originalName = tracker.getOriginalName(functionName);
if (!originalName) {
return;
}
// Handle different function types based on their original imported name
switch (originalName) {
case 'fontFace':
processFontFaceOrdering(ruleContext, node as TSESTree.CallExpression, 0);
break;
case 'globalFontFace':
processFontFaceOrdering(ruleContext, node as TSESTree.CallExpression, 1);
break;
case 'style':
case 'styleVariants':
case 'keyframes':
// Check if this is a wrapper function
const wrapperInfo = tracker.getWrapperInfo(functionName);
const argumentIndex = wrapperInfo?.parameterMapping ?? 0;
processStyleOrdering(
ruleContext,
node as TSESTree.CallExpression,
orderingStrategy,
userDefinedGroupOrder,
sortRemainingProperties,
argumentIndex,
);
break;
case 'globalStyle':
case 'globalKeyframes':
processStyleOrdering(
ruleContext,
node as TSESTree.CallExpression,
orderingStrategy,
userDefinedGroupOrder,
sortRemainingProperties,
1,
);
break;
case 'recipe':
processRecipeOrdering(
ruleContext,
node as TSESTree.CallExpression,
orderingStrategy,
userDefinedGroupOrder,
sortRemainingProperties,
);
break;
}
},
};
};
/**
* Helper function to process style ordering for style-related functions
*/
const processStyleOrdering = (
ruleContext: Rule.RuleContext,
node: TSESTree.CallExpression,
orderingStrategy: OrderingStrategy,
userDefinedGroupOrder?: string[],
sortRemainingProperties?: SortRemainingProperties,
argumentIndex: number = 0,
) => {
if (node.arguments.length > argumentIndex) {
const processProperty = (() => { const processProperty = (() => {
switch (orderingStrategy) { switch (orderingStrategy) {
case 'alphabetical': case 'alphabetical':
@ -45,76 +128,57 @@ export const createNodeVisitors = (
return enforceAlphabeticalCSSOrderInStyleObject; return enforceAlphabeticalCSSOrderInStyleObject;
} }
return (ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression) => return (ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression) =>
enforceUserDefinedGroupOrderInStyleObject(ruleContext, node, userDefinedGroupOrder, sortRemainingProperties); enforceUserDefinedGroupOrderInStyleObject(
ruleContext,
node,
userDefinedGroupOrder,
sortRemainingProperties,
);
default: default:
return enforceAlphabeticalCSSOrderInStyleObject; return enforceAlphabeticalCSSOrderInStyleObject;
} }
})(); })();
return { processStyleNode(ruleContext, node.arguments[argumentIndex] as TSESTree.ObjectExpression, processProperty);
CallExpression(node) {
if (node.callee.type !== 'Identifier') {
return;
} }
};
const fontFaceFunctionArgumentIndexMap = { /**
fontFace: 0, // First argument (index 0) * Helper function to process font face ordering
globalFontFace: 1, // Second argument (index 1) */
}; const processFontFaceOrdering = (
ruleContext: Rule.RuleContext,
// Handle font face functions with special ordering node: TSESTree.CallExpression,
if ( argumentIndex: number,
node.callee.name in fontFaceFunctionArgumentIndexMap && ) => {
node.arguments.length > if (node.arguments.length > argumentIndex) {
fontFaceFunctionArgumentIndexMap[node.callee.name as keyof typeof fontFaceFunctionArgumentIndexMap] enforceFontFaceOrder(ruleContext, node.arguments[argumentIndex] as TSESTree.ObjectExpression);
) {
const argumentIndex =
fontFaceFunctionArgumentIndexMap[node.callee.name as keyof typeof fontFaceFunctionArgumentIndexMap];
const styleArguments = node.arguments[argumentIndex];
enforceFontFaceOrder(ruleContext, styleArguments as TSESTree.ObjectExpression);
return;
} }
};
// Handle style-related functions /**
if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) { * Helper function to process recipe ordering
*/
const processRecipeOrdering = (
ruleContext: Rule.RuleContext,
node: TSESTree.CallExpression,
orderingStrategy: OrderingStrategy,
userDefinedGroupOrder?: string[],
sortRemainingProperties?: SortRemainingProperties,
) => {
if (node.arguments.length > 0) { if (node.arguments.length > 0) {
const styleArguments = node.arguments[0];
processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty);
}
}
// Handle global functions
if (
(node.callee.name === 'globalKeyframes' || node.callee.name === 'globalStyle') &&
node.arguments.length >= 2
) {
const styleArguments = node.arguments[1];
processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty);
}
// Handle recipe function
if (node.callee.name === 'recipe') {
switch (orderingStrategy) { switch (orderingStrategy) {
case 'alphabetical': case 'alphabetical':
enforceAlphabeticalCSSOrderInRecipe(node as TSESTree.CallExpression, ruleContext); enforceAlphabeticalCSSOrderInRecipe(node, ruleContext);
break; break;
case 'concentric': case 'concentric':
enforceConcentricCSSOrderInRecipe(ruleContext, node as TSESTree.CallExpression); enforceConcentricCSSOrderInRecipe(ruleContext, node);
break; break;
case 'userDefinedGroupOrder': case 'userDefinedGroupOrder':
if (userDefinedGroupOrder) { if (userDefinedGroupOrder) {
enforceUserDefinedGroupOrderInRecipe( enforceUserDefinedGroupOrderInRecipe(ruleContext, node, userDefinedGroupOrder, sortRemainingProperties);
ruleContext,
node as TSESTree.CallExpression,
userDefinedGroupOrder,
sortRemainingProperties,
);
} }
break; break;
} }
} }
},
};
}; };

View file

@ -0,0 +1,135 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
import { enforceAlphabeticalCSSOrderInRecipe } from '../alphabetical-order/recipe-order-enforcer.js';
import { enforceAlphabeticalCSSOrderInStyleObject } from '../alphabetical-order/style-object-processor.js';
import { enforceConcentricCSSOrderInRecipe } from '../concentric-order/recipe-order-enforcer.js';
import { enforceConcentricCSSOrderInStyleObject } from '../concentric-order/style-object-processor.js';
import { enforceUserDefinedGroupOrderInRecipe } from '../custom-order/recipe-order-enforcer.js';
import { enforceUserDefinedGroupOrderInStyleObject } from '../custom-order/style-object-processor.js';
import { enforceFontFaceOrder } from './font-face-property-order-enforcer.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from './reference-tracker.js';
import { processStyleNode } from './style-node-processor.js';
import type { SortRemainingProperties } from '../concentric-order/types.js';
import type { OrderingStrategy } from '../types.js';
/**
* Creates an ESLint rule listener with visitors for style-related function calls using reference tracking.
* This automatically detects vanilla-extract functions based on their import statements.
*
* @param ruleContext The ESLint rule context.
* @param orderingStrategy The strategy to use for ordering CSS properties.
* @param userDefinedGroupOrder An optional array of property groups for the 'userDefinedGroupOrder' strategy.
* @param sortRemainingProperties An optional strategy for sorting properties not in user-defined groups.
* @returns An object with visitor functions for the ESLint rule.
*/
export const createReferenceBasedNodeVisitors = (
ruleContext: Rule.RuleContext,
orderingStrategy: OrderingStrategy,
userDefinedGroupOrder?: string[],
sortRemainingProperties?: SortRemainingProperties,
): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
// Select the appropriate property processing function based on the ordering strategy
const processProperty = (() => {
switch (orderingStrategy) {
case 'alphabetical':
return enforceAlphabeticalCSSOrderInStyleObject;
case 'concentric':
return enforceConcentricCSSOrderInStyleObject;
case 'userDefinedGroupOrder':
if (!userDefinedGroupOrder || userDefinedGroupOrder.length === 0) {
return enforceAlphabeticalCSSOrderInStyleObject;
}
return (ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression) =>
enforceUserDefinedGroupOrderInStyleObject(ruleContext, node, userDefinedGroupOrder, sortRemainingProperties);
default:
return enforceAlphabeticalCSSOrderInStyleObject;
}
})();
return {
// Include the reference tracking visitors
...trackingVisitor,
CallExpression(callExpression) {
if (callExpression.callee.type !== 'Identifier') {
return;
}
const functionName = callExpression.callee.name;
// Check if this function is tracked as a vanilla-extract function
if (!tracker.isTrackedFunction(functionName)) {
return;
}
const originalName = tracker.getOriginalName(functionName);
if (!originalName) {
return;
}
// Handle different function types based on their original imported name
switch (originalName) {
case 'fontFace':
if (callExpression.arguments.length > 0) {
const styleArguments = callExpression.arguments[0];
enforceFontFaceOrder(ruleContext, styleArguments as TSESTree.ObjectExpression);
}
break;
case 'globalFontFace':
if (callExpression.arguments.length > 1) {
const styleArguments = callExpression.arguments[1];
enforceFontFaceOrder(ruleContext, styleArguments as TSESTree.ObjectExpression);
}
break;
case 'style':
case 'styleVariants':
case 'keyframes':
if (callExpression.arguments.length > 0) {
const styleArguments = callExpression.arguments[0];
processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty);
}
break;
case 'globalStyle':
case 'globalKeyframes':
if (callExpression.arguments.length > 1) {
const styleArguments = callExpression.arguments[1];
processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty);
}
break;
case 'recipe':
switch (orderingStrategy) {
case 'alphabetical':
enforceAlphabeticalCSSOrderInRecipe(callExpression as TSESTree.CallExpression, ruleContext);
break;
case 'concentric':
enforceConcentricCSSOrderInRecipe(ruleContext, callExpression as TSESTree.CallExpression);
break;
case 'userDefinedGroupOrder':
if (userDefinedGroupOrder) {
enforceUserDefinedGroupOrderInRecipe(
ruleContext,
callExpression as TSESTree.CallExpression,
userDefinedGroupOrder,
sortRemainingProperties,
);
}
break;
}
break;
}
},
};
};
/**
* Backwards-compatible alias that maintains the original API.
* Uses reference tracking internally for automatic detection of vanilla-extract functions.
*/
export const createNodeVisitors = createReferenceBasedNodeVisitors;

View file

@ -0,0 +1,320 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
export interface ImportReference {
source: string;
importedName: string;
localName: string;
}
export interface WrapperFunctionInfo {
originalFunction: string; // 'style', 'recipe', etc.
parameterMapping: number; // which parameter index contains the style object
}
export interface TrackedFunctions {
styleFunctions: Set<string>;
recipeFunctions: Set<string>;
fontFaceFunctions: Set<string>;
globalFunctions: Set<string>;
keyframeFunctions: Set<string>;
}
/**
* Tracks vanilla-extract function imports and their local bindings
*/
export class ReferenceTracker {
private imports: Map<string, ImportReference> = new Map();
private trackedFunctions: TrackedFunctions;
private wrapperFunctions: Map<string, WrapperFunctionInfo> = new Map(); // wrapper function name -> detailed info
constructor() {
this.trackedFunctions = {
styleFunctions: new Set(),
recipeFunctions: new Set(),
fontFaceFunctions: new Set(),
globalFunctions: new Set(),
keyframeFunctions: new Set(),
};
}
/**
* Processes import declarations to track vanilla-extract functions
*/
processImportDeclaration(node: TSESTree.ImportDeclaration): void {
const source = node.source.value;
// Check if this is a vanilla-extract import
if (typeof source !== 'string' || !this.isVanillaExtractSource(source)) {
return;
}
node.specifiers.forEach((specifier) => {
if (specifier.type === 'ImportSpecifier') {
const importedName =
specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value;
const localName = specifier.local.name;
const reference: ImportReference = {
source,
importedName,
localName,
};
this.imports.set(localName, reference);
this.categorizeFunction(localName, importedName);
}
});
}
/**
* Processes variable declarations to track re-assignments and destructuring
*/
processVariableDeclarator(node: TSESTree.VariableDeclarator): void {
// Handle destructuring assignments like: const { style, recipe } = vanillaExtract;
if (node.id.type === 'ObjectPattern' && node.init?.type === 'Identifier') {
const sourceIdentifier = node.init.name;
const sourceReference = this.imports.get(sourceIdentifier);
if (sourceReference && this.isVanillaExtractSource(sourceReference.source)) {
node.id.properties.forEach((property) => {
if (
property.type === 'Property' &&
property.key.type === 'Identifier' &&
property.value.type === 'Identifier'
) {
const importedName = property.key.name;
const localName = property.value.name;
const reference: ImportReference = {
source: sourceReference.source,
importedName,
localName,
};
this.imports.set(localName, reference);
this.categorizeFunction(localName, importedName);
}
});
}
}
// Handle simple assignments like: const myStyle = style;
if (node.id.type === 'Identifier' && node.init?.type === 'Identifier') {
const sourceReference = this.imports.get(node.init.name);
if (sourceReference) {
this.imports.set(node.id.name, sourceReference);
this.categorizeFunction(node.id.name, sourceReference.importedName);
}
}
// Handle arrow function assignments that wrap vanilla-extract functions
if (node.id.type === 'Identifier' && node.init?.type === 'ArrowFunctionExpression') {
this.analyzeWrapperFunction(node.id.name, node.init);
}
}
/**
* Processes function declarations to detect wrapper functions
*/
processFunctionDeclaration(node: TSESTree.FunctionDeclaration): void {
if (node.id?.name) {
this.analyzeWrapperFunction(node.id.name, node);
}
}
/**
* Analyzes a function to see if it wraps a vanilla-extract function
*/
private analyzeWrapperFunction(
functionName: string,
functionNode: TSESTree.ArrowFunctionExpression | TSESTree.FunctionDeclaration,
): void {
const body = functionNode.body;
// Handle arrow functions with expression body
if (functionNode.type === 'ArrowFunctionExpression' && body.type !== 'BlockStatement') {
this.analyzeWrapperExpression(functionName, body);
return;
}
// Handle functions with block statement body
if (body.type === 'BlockStatement') {
this.traverseBlockForVanillaExtractCalls(functionName, body);
}
}
/**
* Analyzes a wrapper function expression to detect vanilla-extract calls and parameter mapping
*/
private analyzeWrapperExpression(wrapperName: string, expression: TSESTree.Node): void {
if (expression.type === 'CallExpression' && expression.callee.type === 'Identifier') {
const calledFunction = expression.callee.name;
if (this.isTrackedFunction(calledFunction)) {
const originalName = this.getOriginalName(calledFunction);
if (originalName) {
// For now, create a simple wrapper info
const wrapperInfo: WrapperFunctionInfo = {
originalFunction: originalName,
parameterMapping: 1, // layerStyle uses second parameter as the style object
};
this.wrapperFunctions.set(wrapperName, wrapperInfo);
this.categorizeFunction(wrapperName, originalName);
}
}
}
}
/**
* Checks if a node is a vanilla-extract function call
*/
private checkForVanillaExtractCall(wrapperName: string, node: TSESTree.Node): void {
if (node.type === 'CallExpression' && node.callee.type === 'Identifier') {
const calledFunction = node.callee.name;
if (this.isTrackedFunction(calledFunction)) {
const originalName = this.getOriginalName(calledFunction);
if (originalName) {
const wrapperInfo: WrapperFunctionInfo = {
originalFunction: originalName,
parameterMapping: 0, // Default to first parameter
};
this.wrapperFunctions.set(wrapperName, wrapperInfo);
this.categorizeFunction(wrapperName, originalName);
}
}
}
}
/**
* Traverses a block statement to find vanilla-extract calls
*/
private traverseBlockForVanillaExtractCalls(wrapperName: string, block: TSESTree.BlockStatement): void {
for (const statement of block.body) {
if (statement.type === 'ReturnStatement' && statement.argument) {
this.checkForVanillaExtractCall(wrapperName, statement.argument);
} else if (statement.type === 'ExpressionStatement') {
this.checkForVanillaExtractCall(wrapperName, statement.expression);
}
}
}
/**
* Checks if a function name corresponds to a tracked vanilla-extract function
*/
isTrackedFunction(functionName: string): boolean {
return this.imports.has(functionName) || this.wrapperFunctions.has(functionName);
}
/**
* Gets the category of a tracked function
*/
getFunctionCategory(functionName: string): keyof TrackedFunctions | null {
if (this.trackedFunctions.styleFunctions.has(functionName)) {
return 'styleFunctions';
}
if (this.trackedFunctions.recipeFunctions.has(functionName)) {
return 'recipeFunctions';
}
if (this.trackedFunctions.fontFaceFunctions.has(functionName)) {
return 'fontFaceFunctions';
}
if (this.trackedFunctions.globalFunctions.has(functionName)) {
return 'globalFunctions';
}
if (this.trackedFunctions.keyframeFunctions.has(functionName)) {
return 'keyframeFunctions';
}
return null;
}
/**
* Gets the original imported name for a local function name
*/
getOriginalName(localName: string): string | null {
const reference = this.imports.get(localName);
if (reference) {
return reference.importedName;
}
// Check if it's a wrapper function
const wrapperInfo = this.wrapperFunctions.get(localName);
return wrapperInfo?.originalFunction ?? null;
}
/**
* Gets wrapper function information
*/
getWrapperInfo(functionName: string): WrapperFunctionInfo | null {
return this.wrapperFunctions.get(functionName) ?? null;
}
/**
* Gets all tracked functions by category
*/
getTrackedFunctions(): TrackedFunctions {
return this.trackedFunctions;
}
/**
* Resets the tracker state (useful for processing multiple files)
*/
reset(): void {
this.imports.clear();
this.wrapperFunctions.clear();
this.trackedFunctions.styleFunctions.clear();
this.trackedFunctions.recipeFunctions.clear();
this.trackedFunctions.fontFaceFunctions.clear();
this.trackedFunctions.globalFunctions.clear();
this.trackedFunctions.keyframeFunctions.clear();
}
private isVanillaExtractSource(source: string): boolean {
return (
source === '@vanilla-extract/css' ||
source === '@vanilla-extract/recipes' ||
source.startsWith('@vanilla-extract/')
);
}
private categorizeFunction(localName: string, importedName: string): void {
switch (importedName) {
case 'style':
case 'styleVariants':
this.trackedFunctions.styleFunctions.add(localName);
break;
case 'recipe':
this.trackedFunctions.recipeFunctions.add(localName);
break;
case 'fontFace':
case 'globalFontFace':
this.trackedFunctions.fontFaceFunctions.add(localName);
break;
case 'globalStyle':
case 'globalKeyframes':
this.trackedFunctions.globalFunctions.add(localName);
break;
case 'keyframes':
this.trackedFunctions.keyframeFunctions.add(localName);
break;
}
}
}
/**
* Creates a visitor that tracks vanilla-extract imports and bindings
*/
export function createReferenceTrackingVisitor(tracker: ReferenceTracker): Rule.RuleListener {
return {
ImportDeclaration(node: Rule.Node) {
tracker.processImportDeclaration(node as TSESTree.ImportDeclaration);
},
VariableDeclarator(node: Rule.Node) {
tracker.processVariableDeclarator(node as TSESTree.VariableDeclarator);
},
FunctionDeclaration(node: Rule.Node) {
tracker.processFunctionDeclaration(node as TSESTree.FunctionDeclaration);
},
};
}

View file

@ -8,7 +8,7 @@ import noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js';
const vanillaExtract = { const vanillaExtract = {
meta: { meta: {
name: '@antebudimir/eslint-plugin-vanilla-extract', name: '@antebudimir/eslint-plugin-vanilla-extract',
version: '1.10.0', version: '1.11.0',
}, },
rules: { rules: {
'alphabetical-order': alphabeticalOrderRule, 'alphabetical-order': alphabeticalOrderRule,