diff --git a/CHANGELOG.md b/CHANGELOG.md index 282f00a..e282472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 [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 - confirm compatibility with ESLint 8.57.0 diff --git a/README.md b/README.md index b3c507d..6f15010 100644 --- a/README.md +++ b/README.md @@ -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 - Works with camelCase properties as used in vanilla-extract - 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 diff --git a/package.json b/package.json index b0ad559..2962b67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "author": "Ante Budimir", "license": "MIT", diff --git a/src/css-rules/alphabetical-order/__tests__/style-wrapper.test.ts b/src/css-rules/alphabetical-order/__tests__/style-wrapper.test.ts new file mode 100644 index 0000000..104cef6 --- /dev/null +++ b/src/css-rules/alphabetical-order/__tests__/style-wrapper.test.ts @@ -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' + } + } + }); + `, + }, + ], +}); diff --git a/src/css-rules/concentric-order/__tests__/style-wrapper.test.ts b/src/css-rules/concentric-order/__tests__/style-wrapper.test.ts new file mode 100644 index 0000000..af8de1f --- /dev/null +++ b/src/css-rules/concentric-order/__tests__/style-wrapper.test.ts @@ -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' + } + } + }); + `, + }, + ], +}); diff --git a/src/css-rules/custom-order/__tests__/style-wrapper.test.ts b/src/css-rules/custom-order/__tests__/style-wrapper.test.ts new file mode 100644 index 0000000..dfd7af5 --- /dev/null +++ b/src/css-rules/custom-order/__tests__/style-wrapper.test.ts @@ -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' + }); + `, + }, + ], +}); diff --git a/src/css-rules/no-empty-blocks/__tests__/is-effectively-empty-styles-object.test.ts b/src/css-rules/no-empty-blocks/__tests__/is-effectively-empty-styles-object.test.ts index cc3f07d..78b78ae 100644 --- a/src/css-rules/no-empty-blocks/__tests__/is-effectively-empty-styles-object.test.ts +++ b/src/css-rules/no-empty-blocks/__tests__/is-effectively-empty-styles-object.test.ts @@ -55,13 +55,22 @@ describe('isEffectivelyEmptyStylesObject', () => { 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([ createProperty('color', createLiteral('blue')), createProperty('selectors', createObjectExpression([])), createProperty('@media', 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); }); diff --git a/src/css-rules/no-empty-blocks/__tests__/style-wrapper.test.ts b/src/css-rules/no-empty-blocks/__tests__/style-wrapper.test.ts new file mode 100644 index 0000000..d8ba5a3 --- /dev/null +++ b/src/css-rules/no-empty-blocks/__tests__/style-wrapper.test.ts @@ -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' }], + }, + ], +}); diff --git a/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts b/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts index 643f36f..ca5b406 100644 --- a/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts +++ b/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts @@ -1,14 +1,35 @@ import type { Rule } from 'eslint'; import { TSESTree } from '@typescript-eslint/utils'; 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 { processEmptyNestedStyles } from './empty-nested-style-processor.js'; import { reportEmptyDeclaration } from './fix-utils.js'; -import { removeNodeWithComma } from './node-remover.js'; import { getStyleKeyName } from './property-utils.js'; import { processRecipeProperties } from './recipe-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). */ @@ -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) { - // A recipe is effectively empty if both base and variants are empty return isBaseEmpty && areAllVariantsEmpty; } - // / For non-recipe objects, check if all special properties (selectors, media queries, variants) are effectively empty - function isSpecialProperty(propertyName: string | null): boolean { - return ( - propertyName === 'selectors' || (propertyName && propertyName.startsWith('@')) || propertyName === 'variants' - ); - } - - 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; + // For regular style objects, check if all properties are effectively empty + return stylesObject.properties.every((property) => { + if (property.type !== 'Property') { + return true; // Skip spread elements for emptiness check } 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) { - return false; + return true; // Skip properties we can't identify } - // For selectors, media queries and supports, check if all nested objects are empty - if ( - (propertyName === 'selectors' || (propertyName && propertyName.startsWith('@'))) && - property.value.type === 'ObjectExpression' - ) { - // This handles the edge case of an empty properties array. - // 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; + // Handle special nested objects like selectors, media queries, supports + if (propertyName === 'selectors' || propertyName.startsWith('@')) { + if (property.value.type === 'ObjectExpression') { + return isNestedObjectEmpty(property.value); } - - return property.value.properties.every((nestedProperty) => { - return ( - nestedProperty.type === 'Property' && - nestedProperty.value.type === 'ObjectExpression' && - isEmptyObject(nestedProperty.value) - ); - }); + return false; // Non-object values in these properties } - // 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; - }); + // Handle regular CSS properties + if (property.value.type === 'ObjectExpression') { + return isEmptyObject(property.value); + } - // If we have special properties and they're all empty, the style is effectively empty - return specialProperties.length > 0 && allSpecialPropertiesEmpty; + return false; // Non-empty property (literal values, etc.) + }); }; /** - * Creates ESLint rule visitors for detecting empty style blocks in vanilla-extract. - * @param ruleContext The ESLint rule rule context. - * @returns An object with visitor functions for the ESLint rule. + * Creates ESLint rule visitors for detecting empty style blocks using reference tracking. + * This automatically detects vanilla-extract functions based on their import statements. */ 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(); return { + // Include the reference tracking visitors + ...trackingVisitor, + CallExpression(node) { if (node.callee.type !== 'Identifier') { return; } - // Target vanilla-extract style functions - const styleApiFunctions = [ - 'style', - 'styleVariants', - 'recipe', - 'globalStyle', - 'fontFace', - 'globalFontFace', - 'keyframes', - 'globalKeyframes', - ]; + const functionName = node.callee.name; - 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; } // Handle styleVariants specifically - if (node.callee.name === 'styleVariants' && node.arguments[0]?.type === 'ObjectExpression') { - processStyleVariants(ruleContext, node.arguments[0] as TSESTree.ObjectExpression, reportedNodes); + if (originalName === 'styleVariants') { + // For wrapper functions, use the correct parameter index + const styleArgumentIndex = wrapperInfo?.parameterMapping ?? 0; + if (node.arguments.length <= styleArgumentIndex) { + return; + } - // If the entire styleVariants object is empty after processing, remove the declaration - if (isEmptyObject(node.arguments[0] as TSESTree.ObjectExpression)) { - reportEmptyDeclaration(ruleContext, node.arguments[0] as TSESTree.Node, node as TSESTree.CallExpression); + 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 (isEmptyObject(node.arguments[styleArgumentIndex] as TSESTree.ObjectExpression)) { + reportEmptyDeclaration( + ruleContext, + node.arguments[styleArgumentIndex] as TSESTree.Node, + node as TSESTree.CallExpression, + ); + } } return; } - const defaultStyleArgumentIndex = 0; - const globalFunctionNames = ['globalStyle', 'globalFontFace', 'globalKeyframes']; - // Determine the style argument index based on the function name - const styleArgumentIndex = globalFunctionNames.includes(node.callee.name) ? 1 : defaultStyleArgumentIndex; + // Determine the style argument index based on the original function name and wrapper info + let styleArgumentIndex: number; + if (wrapperInfo) { + // 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 - 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; } const styleArgument = node.arguments[styleArgumentIndex]; // 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)) { return; } @@ -189,15 +213,29 @@ export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.Ru } // For recipe - check if entire recipe is effectively empty - if (node.callee.name === 'recipe' && styleArgument?.type === 'ObjectExpression') { - if (isEffectivelyEmptyStylesObject(styleArgument as TSESTree.ObjectExpression)) { + if (originalName === 'recipe') { + if (styleArgument?.type === 'ObjectExpression') { + if (isEffectivelyEmptyStylesObject(styleArgument as TSESTree.ObjectExpression)) { + reportedNodes.add(styleArgument as TSESTree.ObjectExpression); + reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression); + return; + } + + // Process individual properties in recipe + 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; } - - // Process individual properties in recipe - processRecipeProperties(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes); + return; } // For style objects with nested empty objects @@ -214,7 +252,10 @@ export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.Ru node: property.argument as Rule.Node, messageId: 'emptySpreadObject', fix(fixer) { - return removeNodeWithComma(ruleContext, property as TSESTree.Node, fixer); + if (property.range) { + return fixer.removeRange([property.range[0], property.range[1]]); + } + return null; }, }); } diff --git a/src/css-rules/no-unknown-unit/unknown-unit-visitor-creator.ts b/src/css-rules/no-unknown-unit/unknown-unit-visitor-creator.ts index 975b9db..b894c27 100644 --- a/src/css-rules/no-unknown-unit/unknown-unit-visitor-creator.ts +++ b/src/css-rules/no-unknown-unit/unknown-unit-visitor-creator.ts @@ -1,51 +1,78 @@ import type { Rule } from 'eslint'; import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; 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 { processUnknownUnitInStyleObject } from './unknown-unit-processor.js'; /** * 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 => { + const tracker = new ReferenceTracker(); + const trackingVisitor = createReferenceTrackingVisitor(tracker); + return { + // Include the import/variable tracking visitors + ...trackingVisitor, + CallExpression(node) { if (node.callee.type !== AST_NODE_TYPES.Identifier) { return; } - if (['fontFace', 'globalFontFace'].includes(node.callee.name)) { - const argumentIndex = node.callee.name === 'fontFace' ? 0 : 1; - if ( - node.arguments.length > argumentIndex && - node.arguments[argumentIndex]?.type === AST_NODE_TYPES.ObjectExpression - ) { - processUnknownUnitInStyleObject(context, node.arguments[argumentIndex] as TSESTree.ObjectExpression); - } + const functionName = node.callee.name; + + // Check if this function is tracked as a vanilla-extract function + if (!tracker.isTrackedFunction(functionName)) { return; } - if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) { - if (node.arguments.length > 0) { - processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject); - } + const originalName = tracker.getOriginalName(functionName); + if (!originalName) { + return; } - if (['globalKeyframes', 'globalStyle'].includes(node.callee.name) && node.arguments.length >= 2) { - processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject); - } + // 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; - if ( - node.callee.name === 'recipe' && - node.arguments.length > 0 && - node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression - ) { - processRecipeProperties( - context, - node.arguments[0] as TSESTree.ObjectExpression, - processUnknownUnitInStyleObject, - ); + 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) { + processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject); + } + break; + + case 'globalStyle': + case 'globalKeyframes': + if (node.arguments.length >= 2) { + processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject); + } + break; + + case 'recipe': + if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) { + processRecipeProperties( + context, + node.arguments[0] as TSESTree.ObjectExpression, + processUnknownUnitInStyleObject, + ); + } + break; } }, }; diff --git a/src/css-rules/no-zero-unit/zero-unit-visitor-creator.ts b/src/css-rules/no-zero-unit/zero-unit-visitor-creator.ts index f1c5004..cf764bb 100644 --- a/src/css-rules/no-zero-unit/zero-unit-visitor-creator.ts +++ b/src/css-rules/no-zero-unit/zero-unit-visitor-creator.ts @@ -1,55 +1,80 @@ import type { Rule } from 'eslint'; import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; 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 { processZeroUnitInStyleObject } from './zero-unit-processor.js'; /** * 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. * @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 => { + const tracker = new ReferenceTracker(); + const trackingVisitor = createReferenceTrackingVisitor(tracker); + return { + // Include the reference tracking visitors + ...trackingVisitor, + CallExpression(node) { if (node.callee.type !== AST_NODE_TYPES.Identifier) { return; } - if (['fontFace', 'globalFontFace'].includes(node.callee.name)) { - const argumentIndex = node.callee.name === 'fontFace' ? 0 : 1; - if ( - node.arguments.length > argumentIndex && - node.arguments[argumentIndex]?.type === AST_NODE_TYPES.ObjectExpression - ) { - processZeroUnitInStyleObject(context, node.arguments[argumentIndex] as TSESTree.ObjectExpression); - } + const functionName = node.callee.name; + + // Check if this function is tracked as a vanilla-extract function + if (!tracker.isTrackedFunction(functionName)) { return; } - if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) { - if (node.arguments.length > 0) { - processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processZeroUnitInStyleObject); - } + const originalName = tracker.getOriginalName(functionName); + if (!originalName) { + return; } - if (['globalKeyframes', 'globalStyle'].includes(node.callee.name) && node.arguments.length >= 2) { - processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processZeroUnitInStyleObject); - } + // 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; - if ( - node.callee.name === 'recipe' && - node.arguments.length > 0 && - node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression - ) { - processRecipeProperties(context, node.arguments[0] as TSESTree.ObjectExpression, processZeroUnitInStyleObject); + 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) { + processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processZeroUnitInStyleObject); + } + break; + + case 'globalStyle': + case 'globalKeyframes': + if (node.arguments.length >= 2) { + processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processZeroUnitInStyleObject); + } + break; + + case 'recipe': + if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) { + processRecipeProperties( + context, + node.arguments[0] as TSESTree.ObjectExpression, + processZeroUnitInStyleObject, + ); + } + break; } }, }; diff --git a/src/css-rules/shared-utils/order-strategy-visitor-creator.ts b/src/css-rules/shared-utils/order-strategy-visitor-creator.ts index 84c178c..e9fcabe 100644 --- a/src/css-rules/shared-utils/order-strategy-visitor-creator.ts +++ b/src/css-rules/shared-utils/order-strategy-visitor-creator.ts @@ -7,25 +7,20 @@ import { enforceConcentricCSSOrderInStyleObject } from '../concentric-order/styl 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. + * 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 ('alphabetical', 'concentric', or 'userDefinedGroupOrder'). * @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. - * - * 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 = ( ruleContext: Rule.RuleContext, @@ -33,88 +28,157 @@ export const createNodeVisitors = ( userDefinedGroupOrder?: string[], sortRemainingProperties?: SortRemainingProperties, ): Rule.RuleListener => { - // 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; - } - })(); + 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 fontFaceFunctionArgumentIndexMap = { - fontFace: 0, // First argument (index 0) - globalFontFace: 1, // Second argument (index 1) - }; - - // Handle font face functions with special ordering - if ( - node.callee.name in fontFaceFunctionArgumentIndexMap && - node.arguments.length > - fontFaceFunctionArgumentIndexMap[node.callee.name as keyof typeof fontFaceFunctionArgumentIndexMap] - ) { - const argumentIndex = - fontFaceFunctionArgumentIndexMap[node.callee.name as keyof typeof fontFaceFunctionArgumentIndexMap]; - const styleArguments = node.arguments[argumentIndex]; - - enforceFontFaceOrder(ruleContext, styleArguments as TSESTree.ObjectExpression); + const functionName = node.callee.name; + // Check if this function is tracked as a vanilla-extract function + if (!tracker.isTrackedFunction(functionName)) { return; } - // Handle style-related functions - if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) { - if (node.arguments.length > 0) { - const styleArguments = node.arguments[0]; - processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty); - } + const originalName = tracker.getOriginalName(functionName); + if (!originalName) { + return; } - // 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 different function types based on their original imported name + switch (originalName) { + case 'fontFace': + processFontFaceOrdering(ruleContext, node as TSESTree.CallExpression, 0); + break; - // Handle recipe function - if (node.callee.name === 'recipe') { - switch (orderingStrategy) { - case 'alphabetical': - enforceAlphabeticalCSSOrderInRecipe(node as TSESTree.CallExpression, ruleContext); - break; - case 'concentric': - enforceConcentricCSSOrderInRecipe(ruleContext, node as TSESTree.CallExpression); - break; - case 'userDefinedGroupOrder': - if (userDefinedGroupOrder) { - enforceUserDefinedGroupOrderInRecipe( - ruleContext, - node as TSESTree.CallExpression, - userDefinedGroupOrder, - sortRemainingProperties, - ); - } - 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 = (() => { + 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; + } + })(); + + processStyleNode(ruleContext, node.arguments[argumentIndex] as TSESTree.ObjectExpression, processProperty); + } +}; + +/** + * Helper function to process font face ordering + */ +const processFontFaceOrdering = ( + ruleContext: Rule.RuleContext, + node: TSESTree.CallExpression, + argumentIndex: number, +) => { + if (node.arguments.length > argumentIndex) { + enforceFontFaceOrder(ruleContext, node.arguments[argumentIndex] as TSESTree.ObjectExpression); + } +}; + +/** + * 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) { + switch (orderingStrategy) { + case 'alphabetical': + enforceAlphabeticalCSSOrderInRecipe(node, ruleContext); + break; + case 'concentric': + enforceConcentricCSSOrderInRecipe(ruleContext, node); + break; + case 'userDefinedGroupOrder': + if (userDefinedGroupOrder) { + enforceUserDefinedGroupOrderInRecipe(ruleContext, node, userDefinedGroupOrder, sortRemainingProperties); + } + break; + } + } +}; diff --git a/src/css-rules/shared-utils/reference-based-visitor-creator.ts b/src/css-rules/shared-utils/reference-based-visitor-creator.ts new file mode 100644 index 0000000..c1dbd57 --- /dev/null +++ b/src/css-rules/shared-utils/reference-based-visitor-creator.ts @@ -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; diff --git a/src/css-rules/shared-utils/reference-tracker.ts b/src/css-rules/shared-utils/reference-tracker.ts new file mode 100644 index 0000000..f946fb2 --- /dev/null +++ b/src/css-rules/shared-utils/reference-tracker.ts @@ -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; + recipeFunctions: Set; + fontFaceFunctions: Set; + globalFunctions: Set; + keyframeFunctions: Set; +} + +/** + * Tracks vanilla-extract function imports and their local bindings + */ +export class ReferenceTracker { + private imports: Map = new Map(); + private trackedFunctions: TrackedFunctions; + private wrapperFunctions: Map = 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); + }, + }; +} diff --git a/src/index.ts b/src/index.ts index 75a38cf..21fc702 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js'; const vanillaExtract = { meta: { name: '@antebudimir/eslint-plugin-vanilla-extract', - version: '1.10.0', + version: '1.11.0', }, rules: { 'alphabetical-order': alphabeticalOrderRule,