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..ef475de --- /dev/null +++ b/src/css-rules/shared-utils/reference-tracker.ts @@ -0,0 +1,321 @@ +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 + objectPath?: string[]; // path to the style object within the parameter (e.g., ['@layer', 'componentLayer']) +} + +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); + }, + }; +}