feat 🥁: add no-zero-unit rule

This rule enforces unitless zero values in vanilla-extract style objects:
- Automatically removes unnecessary units from zero values
- Handles both positive and negative zero values
- Preserves units where required (time properties, CSS functions)
- Works with all vanilla-extract APIs
This commit is contained in:
Ante Budimir 2025-04-12 20:43:11 +03:00
parent 52d38d4477
commit 7dc7204749
20 changed files with 650 additions and 737 deletions

View file

@ -6,12 +6,12 @@ import { reportEmptyDeclaration } from './fix-utils.js';
/**
* Handles conditional expressions with empty objects.
*/
export function processConditionalExpression(
export const processConditionalExpression = (
context: Rule.RuleContext,
node: TSESTree.ConditionalExpression,
reportedNodes: Set<TSESTree.Node>,
callNode: TSESTree.CallExpression,
): void {
): void => {
const isConsequentEmpty = node.consequent.type === 'ObjectExpression' && isEmptyObject(node.consequent);
const isAlternateEmpty = node.alternate.type === 'ObjectExpression' && isEmptyObject(node.alternate);
@ -33,4 +33,4 @@ export function processConditionalExpression(
messageId: 'emptyConditionalStyle',
});
}
}
};

View file

@ -7,11 +7,11 @@ import { areAllChildrenEmpty, getStyleKeyName } from './property-utils.js';
/**
* Processes nested style objects like selectors and media queries.
*/
export function processEmptyNestedStyles(
export const processEmptyNestedStyles = (
ruleContext: Rule.RuleContext,
node: TSESTree.ObjectExpression,
reportedNodes: Set<TSESTree.Node>,
): void {
): void => {
node.properties.forEach((property) => {
if (property.type !== 'Property') {
return;
@ -72,4 +72,4 @@ export function processEmptyNestedStyles(
});
}
});
}
};

View file

@ -9,128 +9,10 @@ import { getStyleKeyName } from './property-utils.js';
import { processRecipeProperties } from './recipe-processor.js';
import { processStyleVariants } from './style-variants-processor.js';
/**
* 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.
*/
export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.RuleListener => {
// Track reported nodes to prevent duplicate reports
const reportedNodes = new Set<TSESTree.Node>();
return {
CallExpression(node) {
if (node.callee.type !== 'Identifier') {
return;
}
// Target vanilla-extract style functions
const styleApiFunctions = [
'style',
'styleVariants',
'recipe',
'globalStyle',
'fontFace',
'globalFontFace',
'keyframes',
'globalKeyframes',
];
if (!styleApiFunctions.includes(node.callee.name) || 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 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);
}
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;
// For global functions, check if we have enough arguments
if (styleArgumentIndex === 1 && 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.Node)) {
return;
}
// Handle conditional expressions
if (styleArgument?.type === 'ConditionalExpression') {
processConditionalExpression(
ruleContext,
styleArgument as TSESTree.ConditionalExpression,
reportedNodes,
node as TSESTree.CallExpression,
);
return;
}
// 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;
}
// For recipe - check if entire recipe is effectively empty
if (node.callee.name === 'recipe' && 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);
}
// For style objects with nested empty objects
if (styleArgument?.type === 'ObjectExpression') {
// Check for spread elements
styleArgument.properties.forEach((property) => {
if (
property.type === 'SpreadElement' &&
property.argument.type === 'ObjectExpression' &&
isEmptyObject(property.argument as TSESTree.ObjectExpression)
) {
reportedNodes.add(property.argument as TSESTree.Node);
ruleContext.report({
node: property.argument as Rule.Node,
messageId: 'emptySpreadObject',
fix(fixer) {
return removeNodeWithComma(ruleContext, property as TSESTree.Node, fixer);
},
});
}
});
// Process nested selectors and media queries
processEmptyNestedStyles(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes);
}
},
};
};
/**
* Checks if a style object is effectively empty (contains only empty objects).
*/
export function isEffectivelyEmptyStylesObject(stylesObject: TSESTree.ObjectExpression): boolean {
export const isEffectivelyEmptyStylesObject = (stylesObject: TSESTree.ObjectExpression): boolean => {
// Empty object itself
if (stylesObject.properties.length === 0) {
return true;
@ -225,4 +107,122 @@ export function isEffectivelyEmptyStylesObject(stylesObject: TSESTree.ObjectExpr
// 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.
* @param ruleContext The ESLint rule rule context.
* @returns An object with visitor functions for the ESLint rule.
*/
export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.RuleListener => {
// Track reported nodes to prevent duplicate reports
const reportedNodes = new Set<TSESTree.ObjectExpression>();
return {
CallExpression(node) {
if (node.callee.type !== 'Identifier') {
return;
}
// Target vanilla-extract style functions
const styleApiFunctions = [
'style',
'styleVariants',
'recipe',
'globalStyle',
'fontFace',
'globalFontFace',
'keyframes',
'globalKeyframes',
];
if (!styleApiFunctions.includes(node.callee.name) || 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 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);
}
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;
// For global functions, check if we have enough arguments
if (styleArgumentIndex === 1 && 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;
}
// Handle conditional expressions
if (styleArgument?.type === 'ConditionalExpression') {
processConditionalExpression(
ruleContext,
styleArgument as TSESTree.ConditionalExpression,
reportedNodes,
node as TSESTree.CallExpression,
);
return;
}
// 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;
}
// For recipe - check if entire recipe is effectively empty
if (node.callee.name === 'recipe' && 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);
}
// For style objects with nested empty objects
if (styleArgument?.type === 'ObjectExpression') {
// Check for spread elements
styleArgument.properties.forEach((property) => {
if (
property.type === 'SpreadElement' &&
property.argument.type === 'ObjectExpression' &&
isEmptyObject(property.argument as TSESTree.ObjectExpression)
) {
reportedNodes.add(property.argument as TSESTree.ObjectExpression);
ruleContext.report({
node: property.argument as Rule.Node,
messageId: 'emptySpreadObject',
fix(fixer) {
return removeNodeWithComma(ruleContext, property as TSESTree.Node, fixer);
},
});
}
});
// Process nested selectors and media queries
processEmptyNestedStyles(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes);
}
},
};
};

View file

@ -8,11 +8,11 @@ import type { TSESTree } from '@typescript-eslint/utils';
* @param fixer The ESLint fixer.
* @returns The fix object.
*/
export function removeNodeWithComma(ruleContext: Rule.RuleContext, node: TSESTree.Node, fixer: Rule.RuleFixer) {
export const removeNodeWithComma = (ruleContext: Rule.RuleContext, node: TSESTree.Node, fixer: Rule.RuleFixer) => {
const sourceCode = ruleContext.sourceCode;
const tokenAfter = sourceCode.getTokenAfter(node as Rule.Node);
if (tokenAfter && tokenAfter.value === ',' && node.range && tokenAfter.range) {
return fixer.removeRange([node.range[0], tokenAfter.range[1]]);
}
return fixer.remove(node as Rule.Node);
}
};

View file

@ -4,7 +4,7 @@ import { isEmptyObject } from '../shared-utils/empty-object-processor.js';
/**
* Gets the property name regardless of whether it's an identifier or a literal.
*/
export function getStyleKeyName(key: TSESTree.Expression | TSESTree.PrivateIdentifier): string | null {
export const getStyleKeyName = (key: TSESTree.Expression | TSESTree.PrivateIdentifier): string | null => {
if (key.type === 'Identifier') {
return key.name;
}
@ -12,7 +12,7 @@ export function getStyleKeyName(key: TSESTree.Expression | TSESTree.PrivateIdent
return key.value;
}
return null;
}
};
/**
* Checks if all properties in a style object are empty objects.

View file

@ -13,11 +13,11 @@ import { areAllChildrenEmpty, getStyleKeyName } from './property-utils.js';
* @param recipeNode The recipe object node to process.
* @param reportedNodes A set of nodes that have already been reported by other processors.
*/
export function processRecipeProperties(
export const processRecipeProperties = (
ruleContext: Rule.RuleContext,
recipeNode: TSESTree.ObjectExpression,
reportedNodes: Set<TSESTree.Node>,
): void {
): void => {
recipeNode.properties.forEach((property) => {
if (property.type !== 'Property') {
return;
@ -139,4 +139,4 @@ export function processRecipeProperties(
}
}
});
}
};

View file

@ -10,11 +10,11 @@ import { removeNodeWithComma } from './node-remover.js';
* @param node The styleVariants call argument (object expression).
* @param reportedNodes A set of nodes that have already been reported.
*/
export function processStyleVariants(
export const processStyleVariants = (
ruleContext: Rule.RuleContext,
node: TSESTree.ObjectExpression,
reportedNodes: Set<TSESTree.Node>,
): void {
): void => {
node.properties.forEach((property) => {
if (property.type !== 'Property') {
return;
@ -50,4 +50,4 @@ export function processStyleVariants(
return;
}
});
}
};