refactor: refactor: Migrate all CSS rules to use reference tracking

This commit is contained in:
seongminn 2025-06-15 23:38:23 +09:00
parent 6be1a0659e
commit 0b05bfa9cf
3 changed files with 226 additions and 133 deletions

View file

@ -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<TSESTree.ObjectExpression>();
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;
},
});
}

View file

@ -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;
}
},
};

View file

@ -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;
}
},
};