From 321d38776fe4e71b8d9d960bc84360053c6aa98a Mon Sep 17 00:00:00 2001 From: anubra266 Date: Mon, 7 Oct 2024 22:27:47 -0500 Subject: [PATCH 1/2] Apply memoization to rules --- plugin/src/rules/file-not-included.ts | 26 ++- .../src/rules/no-config-function-in-source.ts | 99 +++++++---- plugin/src/rules/no-debug.ts | 15 +- plugin/src/rules/no-dynamic-styling.ts | 98 ++++++----- plugin/src/rules/no-escape-hatch.ts | 81 +++++---- plugin/src/rules/no-hardcoded-color.ts | 136 +++++++++------ plugin/src/rules/no-important.ts | 151 ++++++++++------ plugin/src/rules/no-invalid-nesting.ts | 95 ++++++++--- plugin/src/rules/no-invalid-token-paths.ts | 143 +++++++++------- plugin/src/rules/no-margin-properties.ts | 64 ++++++- plugin/src/rules/no-physical-properties.ts | 73 ++++++-- plugin/src/rules/no-property-renaming.ts | 71 +++++--- plugin/src/rules/no-unsafe-token-fn-usage.ts | 161 +++++++++++------- plugin/src/rules/prefer-atomic-properties.ts | 98 +++++++++-- .../src/rules/prefer-composite-properties.ts | 92 ++++++++-- .../src/rules/prefer-longhand-properties.ts | 67 ++++++-- .../src/rules/prefer-shorthand-properties.ts | 87 ++++++++-- .../rules/prefer-unified-property-style.ts | 116 ++++++++++--- plugin/src/utils/helpers.ts | 34 ++-- plugin/src/utils/index.ts | 17 +- plugin/src/utils/nodes.ts | 17 -- plugin/src/utils/worker.ts | 29 ++-- plugin/tests/file-not-included.test.ts | 2 +- 23 files changed, 1212 insertions(+), 560 deletions(-) diff --git a/plugin/src/rules/file-not-included.ts b/plugin/src/rules/file-not-included.ts index 698c1c4..0522c9e 100644 --- a/plugin/src/rules/file-not-included.ts +++ b/plugin/src/rules/file-not-included.ts @@ -1,5 +1,6 @@ import { type Rule, createRule } from '../utils' import { isPandaImport, isValidFile } from '../utils/helpers' +import { TSESTree } from '@typescript-eslint/utils' export const RULE_NAME = 'file-not-included' @@ -8,25 +9,40 @@ const rule: Rule = createRule({ meta: { docs: { description: - 'Disallow the use of panda css in files that are not included in the specified panda `include` config.', + 'Disallow the use of Panda CSS in files that are not included in the specified Panda CSS `include` config.', }, messages: { - include: 'The use of Panda CSS is not allowed in this file. Please check the specified `include` config.', + include: + 'The use of Panda CSS is not allowed in this file. Please ensure the file is included in the Panda CSS `include` configuration.', }, - type: 'suggestion', + type: 'problem', schema: [], }, defaultOptions: [], create(context) { + // Determine if the current file is included in the Panda CSS configuration + const isFileIncluded = isValidFile(context) + + // If the file is included, no need to proceed + if (isFileIncluded) { + return {} + } + + let hasReported = false + return { - ImportDeclaration(node) { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + if (hasReported) return + if (!isPandaImport(node, context)) return - if (isValidFile(context)) return + // Report only on the first import declaration context.report({ node, messageId: 'include', }) + + hasReported = true }, } }, diff --git a/plugin/src/rules/no-config-function-in-source.ts b/plugin/src/rules/no-config-function-in-source.ts index a6cb91a..a8be3c4 100644 --- a/plugin/src/rules/no-config-function-in-source.ts +++ b/plugin/src/rules/no-config-function-in-source.ts @@ -1,55 +1,103 @@ import { isIdentifier, isVariableDeclaration } from '../utils/nodes' import { type Rule, createRule } from '../utils' import { getAncestor, getImportSpecifiers, hasPkgImport, isPandaConfigFunction, isValidFile } from '../utils/helpers' +import { TSESTree } from '@typescript-eslint/utils' export const RULE_NAME = 'no-config-function-in-source' +const CONFIG_FUNCTIONS = new Set([ + 'defineConfig', + 'defineRecipe', + 'defineSlotRecipe', + 'defineParts', + 'definePattern', + 'definePreset', + 'defineKeyframes', + 'defineGlobalStyles', + 'defineUtility', + 'defineTextStyles', + 'defineLayerStyles', + 'defineStyles', + 'defineTokens', + 'defineSemanticTokens', +]) + const rule: Rule = createRule({ name: RULE_NAME, meta: { docs: { - description: 'Prohibit the use of config functions outside the Panda config.', + description: 'Prohibit the use of config functions outside the Panda config file.', }, messages: { - configFunction: 'Unnecessary`{{name}}` call. \nConfig functions should only be used in panda config.', + configFunction: 'Unnecessary `{{name}}` call. Config functions should only be used in the Panda config file.', delete: 'Delete `{{name}}` call.', }, - type: 'suggestion', + type: 'problem', hasSuggestions: true, schema: [], }, defaultOptions: [], create(context) { - if (!hasPkgImport(context)) return {} + // Check if the package is imported; if not, exit early + if (!hasPkgImport(context)) { + return {} + } + + // Determine if the current file is the Panda config file + const isPandaFile = isValidFile(context) + + // If we are in the config file, no need to proceed + if (!isPandaFile) { + return {} + } return { - CallExpression(node) { - if (!isValidFile(context)) return + CallExpression(node: TSESTree.CallExpression) { + // Ensure the callee is an identifier if (!isIdentifier(node.callee)) return - if (!CONFIG_FUNCTIONS.includes(node.callee.name)) return - if (!isPandaConfigFunction(context, node.callee.name)) return + + const functionName = node.callee.name + + // Check if the function is a config function + if (!CONFIG_FUNCTIONS.has(functionName)) return + + // Verify that it's a Panda config function + if (!isPandaConfigFunction(context, functionName)) return context.report({ node, messageId: 'configFunction', data: { - name: node.callee.name, + name: functionName, }, suggest: [ { messageId: 'delete', data: { - name: node.callee.name, + name: functionName, }, fix(fixer) { const declaration = getAncestor(isVariableDeclaration, node) - const importSpec = getImportSpecifiers(context).find( - (s) => isIdentifier(node.callee) && s.specifier.local.name === node.callee.name, - ) - return [ - fixer.remove(declaration ?? node), - importSpec?.specifier ? fixer.remove(importSpec?.specifier) : ({} as any), - ] + const importSpecifiers = getImportSpecifiers(context) + + // Find the import specifier for the function + const importSpec = importSpecifiers.find((s) => s.specifier.local.name === functionName) + + const fixes = [] + + // Remove the variable declaration if it exists; otherwise, remove the call expression + if (declaration) { + fixes.push(fixer.remove(declaration)) + } else { + fixes.push(fixer.remove(node)) + } + + // Remove the import specifier if it exists + if (importSpec?.specifier) { + fixes.push(fixer.remove(importSpec.specifier)) + } + + return fixes }, }, ], @@ -60,20 +108,3 @@ const rule: Rule = createRule({ }) export default rule - -const CONFIG_FUNCTIONS = [ - 'defineConfig', - 'defineRecipe', - 'defineSlotRecipe', - 'defineParts', - 'definePattern', - 'definePreset', - 'defineKeyframes', - 'defineGlobalStyles', - 'defineUtility', - 'defineTextStyles', - 'defineLayerStyles', - 'defineStyles', - 'defineTokens', - 'defineSemanticTokens', -] diff --git a/plugin/src/rules/no-debug.ts b/plugin/src/rules/no-debug.ts index 3291f95..bbae102 100644 --- a/plugin/src/rules/no-debug.ts +++ b/plugin/src/rules/no-debug.ts @@ -1,6 +1,6 @@ -import { isIdentifier, isJSXIdentifier } from '../utils/nodes' import { type Rule, createRule } from '../utils' -import { isPandaAttribute, isPandaProp, isRecipeVariant } from '../utils/helpers' +import { isPandaProp, isPandaAttribute, isRecipeVariant } from '../utils/helpers' +import { TSESTree } from '@typescript-eslint/utils' export const RULE_NAME = 'no-debug' @@ -15,15 +15,15 @@ const rule: Rule = createRule({ prop: 'Remove the debug prop.', property: 'Remove the debug property.', }, - type: 'suggestion', + type: 'problem', hasSuggestions: true, schema: [], }, defaultOptions: [], create(context) { return { - JSXAttribute(node) { - if (!isJSXIdentifier(node.name) || node.name.name !== 'debug') return + 'JSXAttribute[name.name="debug"]'(node: TSESTree.JSXAttribute) { + // Ensure the attribute is a Panda prop if (!isPandaProp(node, context)) return context.report({ @@ -38,9 +38,10 @@ const rule: Rule = createRule({ }) }, - Property(node) { - if (!isIdentifier(node.key) || node.key.name !== 'debug') return + 'Property[key.name="debug"]'(node: TSESTree.Property) { + // Ensure the property is a Panda attribute if (!isPandaAttribute(node, context)) return + // Exclude recipe variants if (isRecipeVariant(node, context)) return context.report({ diff --git a/plugin/src/rules/no-dynamic-styling.ts b/plugin/src/rules/no-dynamic-styling.ts index 6bff5f3..e80a208 100644 --- a/plugin/src/rules/no-dynamic-styling.ts +++ b/plugin/src/rules/no-dynamic-styling.ts @@ -1,4 +1,4 @@ -import type { TSESTree } from '@typescript-eslint/utils' +import { type TSESTree } from '@typescript-eslint/utils' import { type Rule, createRule } from '../utils' import { isInPandaFunction, isPandaAttribute, isPandaProp, isRecipeVariant } from '../utils/helpers' import { @@ -17,48 +17,69 @@ const rule: Rule = createRule({ meta: { docs: { description: - "Ensure user doesn't use dynamic styling at any point. \nPrefer to use static styles, leverage css variables or recipes for known dynamic styles.", + "Ensure users don't use dynamic styling. Prefer static styles, leverage CSS variables, or recipes for known dynamic styles.", }, messages: { - dynamic: 'Remove dynamic value. Prefer static styles', - dynamicProperty: 'Remove dynamic property. Prefer static style property', - dynamicRecipeVariant: 'Remove dynamic variant. Prefer static variant definition', + dynamic: 'Remove dynamic value. Prefer static styles.', + dynamicProperty: 'Remove dynamic property. Prefer static style property.', + dynamicRecipeVariant: 'Remove dynamic variant. Prefer static variant definition.', }, - type: 'suggestion', + type: 'problem', schema: [], }, defaultOptions: [], create(context) { + // Helper function to determine if a node represents a static value + function isStaticValue(node: TSESTree.Node | null | undefined): boolean { + if (!node) return false + if (isLiteral(node)) return true + if (isTemplateLiteral(node) && node.expressions.length === 0) return true + if (isObjectExpression(node)) return true // Conditions are acceptable + return false + } + + // Function to check array elements for dynamic values + function checkArrayElements(array: TSESTree.ArrayExpression) { + array.elements.forEach((element) => { + if (!element) return + if (isStaticValue(element)) return + + context.report({ + node: element, + messageId: 'dynamic', + }) + }) + } + return { - JSXAttribute(node) { + // JSX Attributes + JSXAttribute(node: TSESTree.JSXAttribute) { if (!node.value) return - if (isLiteral(node.value)) return - if (isJSXExpressionContainer(node.value) && isLiteral(node.value.expression)) return - - // For syntax like: - if ( - isJSXExpressionContainer(node.value) && - isTemplateLiteral(node.value.expression) && - node.value.expression.expressions.length === 0 - ) - return - // Don't warn for objects. Those are conditions - if (isObjectExpression(node.value.expression)) return + if (isLiteral(node.value)) return + // Check if it's a Panda prop early to avoid unnecessary processing if (!isPandaProp(node, context)) return - if (isArrayExpression(node.value.expression)) { - return checkArrayElements(node.value.expression, context) + if (isJSXExpressionContainer(node.value)) { + const expr = node.value.expression + + if (isStaticValue(expr)) return + + if (isArrayExpression(expr)) { + checkArrayElements(expr) + return + } } + // Report dynamic value usage context.report({ node: node.value, messageId: 'dynamic', }) }, - // Dynamic properties - 'Property[computed=true]'(node: TSESTree.Property) { + // Dynamic properties with computed keys + 'Property[computed=true]': (node: TSESTree.Property) => { if (!isInPandaFunction(node, context)) return context.report({ @@ -67,22 +88,22 @@ const rule: Rule = createRule({ }) }, - Property(node) { + // Object Properties + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return - if (isLiteral(node.value)) return - - // For syntax like: { property: `value that could be multiline` } - if (isTemplateLiteral(node.value) && node.value.expressions.length === 0) return - - // Don't warn for objects. Those are conditions - if (isObjectExpression(node.value)) return + // Check if it's a Panda attribute early to avoid unnecessary processing if (!isPandaAttribute(node, context)) return + if (isRecipeVariant(node, context)) return + + if (isStaticValue(node.value)) return if (isArrayExpression(node.value)) { - return checkArrayElements(node.value, context) + checkArrayElements(node.value) + return } + // Report dynamic value usage context.report({ node: node.value, messageId: 'dynamic', @@ -92,17 +113,4 @@ const rule: Rule = createRule({ }, }) -function checkArrayElements(array: TSESTree.ArrayExpression, context: Parameters<(typeof rule)['create']>[0]) { - array.elements.forEach((node) => { - if (!node) return - if (isLiteral(node)) return - if (isTemplateLiteral(node) && node.expressions.length === 0) return - - context.report({ - node: node, - messageId: 'dynamic', - }) - }) -} - export default rule diff --git a/plugin/src/rules/no-escape-hatch.ts b/plugin/src/rules/no-escape-hatch.ts index cf0998f..2bd52ff 100644 --- a/plugin/src/rules/no-escape-hatch.ts +++ b/plugin/src/rules/no-escape-hatch.ts @@ -1,7 +1,8 @@ import { isPandaAttribute, isPandaProp, isRecipeVariant } from '../utils/helpers' import { type Rule, createRule } from '../utils' import { getArbitraryValue } from '@pandacss/shared' -import { isIdentifier, isJSXExpressionContainer, isLiteral, isTemplateLiteral, type Node } from '../utils/nodes' +import { isIdentifier, isJSXExpressionContainer, isLiteral, isTemplateLiteral } from '../utils/nodes' +import { TSESTree } from '@typescript-eslint/utils' export const RULE_NAME = 'no-escape-hatch' @@ -13,48 +14,41 @@ const rule: Rule = createRule({ }, messages: { escapeHatch: - 'Avoid using the escape hatch [value] for undefined tokens. \nDefine a corresponding token in your design system for better consistency and maintainability.', + 'Avoid using the escape hatch [value] for undefined tokens. Define a corresponding token in your design system for better consistency and maintainability.', remove: 'Remove the square brackets (`[]`).', }, - type: 'suggestion', + type: 'problem', hasSuggestions: true, schema: [], }, defaultOptions: [], create(context) { - const removeQuotes = ([start, end]: readonly [number, number]) => [start + 1, end - 1] as const + // Helper function to adjust the range for fixing (removing brackets) + const removeBrackets = (range: readonly [number, number]) => { + const [start, end] = range + return [start + 1, end - 1] as const + } - const hasEscapeHatch = (value?: string) => { + // Function to check if a value contains escape hatch syntax + const hasEscapeHatch = (value: string | undefined): boolean => { if (!value) return false + // Early return if the value doesn't contain brackets + if (!value.includes('[')) return false return getArbitraryValue(value) !== value.trim() } - const handleLiteral = (node: Node) => { - if (!isLiteral(node)) return - if (!hasEscapeHatch(node.value?.toString())) return - - sendReport(node) - } + // Unified function to handle reporting + const handleNodeValue = (node: TSESTree.Node, value: string) => { + if (!hasEscapeHatch(value)) return - const handleTemplateLiteral = (node: Node) => { - if (!isTemplateLiteral(node)) return - if (node.expressions.length > 0) return - if (!hasEscapeHatch(node.quasis[0].value.raw)) return - - sendReport(node.quasis[0], node.quasis[0].value.raw) - } - - const sendReport = (node: any, _value?: string) => { - const value = _value ?? node.value?.toString() - - return context.report({ + context.report({ node, messageId: 'escapeHatch', suggest: [ { messageId: 'remove', fix: (fixer) => { - return fixer.replaceTextRange(removeQuotes(node.range), getArbitraryValue(value)) + return fixer.replaceTextRange(removeBrackets(node.range as [number, number]), getArbitraryValue(value)) }, }, ], @@ -62,26 +56,43 @@ const rule: Rule = createRule({ } return { - JSXAttribute(node) { + JSXAttribute(node: TSESTree.JSXAttribute) { if (!node.value) return + // Ensure the attribute is a Panda prop if (!isPandaProp(node, context)) return - handleLiteral(node.value) - - if (!isJSXExpressionContainer(node.value)) return - - handleLiteral(node.value.expression) - handleTemplateLiteral(node.value.expression) + const { value } = node + + if (isLiteral(value)) { + const val = value.value?.toString() ?? '' + handleNodeValue(value, val) + } else if (isJSXExpressionContainer(value)) { + const expr = value.expression + if (isLiteral(expr)) { + const val = expr.value?.toString() ?? '' + handleNodeValue(expr, val) + } else if (isTemplateLiteral(expr) && expr.expressions.length === 0) { + const val = expr.quasis[0].value.raw + handleNodeValue(expr.quasis[0], val) + } + } }, - Property(node) { + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return - if (!isLiteral(node.value) && !isTemplateLiteral(node.value)) return + // Ensure the property is a Panda attribute if (!isPandaAttribute(node, context)) return + // Exclude recipe variants if (isRecipeVariant(node, context)) return - handleLiteral(node.value) - handleTemplateLiteral(node.value) + const value = node.value + if (isLiteral(value)) { + const val = value.value?.toString() ?? '' + handleNodeValue(value, val) + } else if (isTemplateLiteral(value) && value.expressions.length === 0) { + const val = value.quasis[0].value.raw + handleNodeValue(value.quasis[0], val) + } }, } }, diff --git a/plugin/src/rules/no-hardcoded-color.ts b/plugin/src/rules/no-hardcoded-color.ts index c0d4e9f..6135de7 100644 --- a/plugin/src/rules/no-hardcoded-color.ts +++ b/plugin/src/rules/no-hardcoded-color.ts @@ -1,13 +1,14 @@ import { extractTokens, - isColorAttribute, - isColorToken, + isColorAttribute as originalIsColorAttribute, + isColorToken as originalIsColorToken, isPandaAttribute, isPandaProp, isRecipeVariant, } from '../utils/helpers' import { type Rule, createRule } from '../utils' -import { isIdentifier, isJSXExpressionContainer, isJSXIdentifier, isLiteral } from '../utils/nodes' +import { isIdentifier, isJSXExpressionContainer, isJSXIdentifier, isLiteral, isTemplateLiteral } from '../utils/nodes' +import { TSESTree } from '@typescript-eslint/utils' export const RULE_NAME = 'no-hardcoded-color' @@ -20,7 +21,7 @@ const rule: Rule = createRule({ messages: { invalidColor: '`{{color}}` is not a valid color token.', }, - type: 'suggestion', + type: 'problem', schema: [ { type: 'object', @@ -29,6 +30,7 @@ const rule: Rule = createRule({ type: 'boolean', }, }, + additionalProperties: false, }, ], }, @@ -40,75 +42,101 @@ const rule: Rule = createRule({ create(context) { const noOpacity = context.options[0]?.noOpacity - const isTokenFn = (value?: string) => { + // Caches for isColorToken and isColorAttribute results + const colorTokenCache = new Map() + const colorAttributeCache = new Map() + + // Cached version of isColorToken + const isColorToken = (token: string): boolean => { + if (colorTokenCache.has(token)) { + return colorTokenCache.get(token)! + } + const result = originalIsColorToken(token, context) + colorTokenCache.set(token, result) + return !!result + } + + // Cached version of isColorAttribute + const isColorAttribute = (attribute: string): boolean => { + if (colorAttributeCache.has(attribute)) { + return colorAttributeCache.get(attribute)! + } + const result = originalIsColorAttribute(attribute, context) + colorAttributeCache.set(attribute, result) + return result + } + + const isTokenFunctionUsed = (value: string): boolean => { if (!value) return false const tokens = extractTokens(value) return tokens.length > 0 } - const testColorToken = (value?: string) => { + const isValidColorToken = (value: string): boolean => { if (!value) return false - const color = value?.split('/') - const isOpacityToken = !!color[1]?.length - const isValidToken = isColorToken(color[0], context) - return noOpacity ? isValidToken && !isOpacityToken : isValidToken + const [colorToken, opacity] = value.split('/') + const hasOpacity = opacity !== undefined && opacity.length > 0 + const isValidToken = isColorToken(colorToken) + + return noOpacity ? isValidToken && !hasOpacity : isValidToken + } + + const reportInvalidColor = (node: TSESTree.Node, color: string) => { + context.report({ + node, + messageId: 'invalidColor', + data: { + color, + }, + }) + } + + const checkColorValue = (node: TSESTree.Node, value: string, attributeName: string) => { + if (!isColorAttribute(attributeName)) return + if (isTokenFunctionUsed(value)) return + if (isValidColorToken(value)) return + + reportInvalidColor(node, value) } return { - JSXAttribute(node) { + JSXAttribute(node: TSESTree.JSXAttribute) { if (!isJSXIdentifier(node.name)) return if (!isPandaProp(node, context) || !node.value) return - if ( - isLiteral(node.value) && - isColorAttribute(node.name.name, context) && - !isTokenFn(node.value.value?.toString()) && - !testColorToken(node.value.value?.toString()) - ) { - context.report({ - node: node.value, - messageId: 'invalidColor', - data: { - color: node.value.value?.toString(), - }, - }) - } + const attributeName = node.name.name + const valueNode = node.value - if (!isJSXExpressionContainer(node.value)) return - - if ( - isLiteral(node.value.expression) && - isColorAttribute(node.name.name, context) && - !isTokenFn(node.value.expression.value?.toString()) && - !testColorToken(node.value.expression.value?.toString()) - ) { - context.report({ - node: node.value.expression, - messageId: 'invalidColor', - data: { - color: node.value.expression.value?.toString(), - }, - }) + if (isLiteral(valueNode)) { + const value = valueNode.value?.toString() || '' + checkColorValue(valueNode, value, attributeName) + } else if (isJSXExpressionContainer(valueNode)) { + const expression = valueNode.expression + if (isLiteral(expression)) { + const value = expression.value?.toString() || '' + checkColorValue(expression, value, attributeName) + } else if (isTemplateLiteral(expression) && expression.expressions.length === 0) { + const value = expression.quasis[0].value.raw + checkColorValue(expression.quasis[0], value, attributeName) + } } }, - Property(node) { + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return - if (!isLiteral(node.value)) return - if (!isPandaAttribute(node, context)) return if (isRecipeVariant(node, context)) return - if (!isColorAttribute(node.key.name, context)) return - if (isTokenFn(node.value.value?.toString())) return - if (testColorToken(node.value.value?.toString())) return - - context.report({ - node: node.value, - messageId: 'invalidColor', - data: { - color: node.value.value?.toString(), - }, - }) + + const attributeName = node.key.name + const valueNode = node.value + + if (isLiteral(valueNode)) { + const value = valueNode.value?.toString() || '' + checkColorValue(valueNode, value, attributeName) + } else if (isTemplateLiteral(valueNode) && valueNode.expressions.length === 0) { + const value = valueNode.quasis[0].value.raw + checkColorValue(valueNode.quasis[0], value, attributeName) + } }, } }, diff --git a/plugin/src/rules/no-important.ts b/plugin/src/rules/no-important.ts index 1a29d18..8ba1f43 100644 --- a/plugin/src/rules/no-important.ts +++ b/plugin/src/rules/no-important.ts @@ -1,11 +1,11 @@ import { isPandaAttribute, isPandaProp, isRecipeVariant } from '../utils/helpers' import { type Rule, createRule } from '../utils' -import { isIdentifier, isJSXExpressionContainer, isLiteral, isTemplateLiteral, type Node } from '../utils/nodes' +import { isIdentifier, isJSXExpressionContainer, isLiteral, isTemplateLiteral } from '../utils/nodes' import { getArbitraryValue } from '@pandacss/shared' +import { TSESTree } from '@typescript-eslint/utils' -// Check if the string ends with '!' with optional whitespace before it +// Regular expressions to detect '!important' and '!' at the end of a value const exclamationRegex = /\s*!$/ -// Check if the string ends with '!important' with optional whitespace before it and after, but not within '!important' const importantRegex = /\s*!important\s*$/ export const RULE_NAME = 'no-important' @@ -15,68 +15,97 @@ const rule: Rule = createRule({ meta: { docs: { description: - 'Disallow usage of important keyword. Prioroitize specificity for a maintainable and predictable styling structure.', + 'Disallow usage of !important keyword. Prioritize specificity for a maintainable and predictable styling structure.', }, messages: { important: - 'Avoid using the !important keyword. Refactor your code to prioritize specificity for predictable styling.', + 'Avoid using the {{keyword}} keyword. Refactor your code to prioritize specificity for predictable styling.', remove: 'Remove the `{{keyword}}` keyword.', }, - type: 'suggestion', + type: 'problem', hasSuggestions: true, schema: [], }, defaultOptions: [], create(context) { - const removeQuotes = ([start, end]: readonly [number, number]) => [start + 1, end - 1] as const - - const hasImportantKeyword = (_value?: string) => { - if (!_value) return false - const value = getArbitraryValue(_value) - return exclamationRegex.test(value) || importantRegex.test(value) + // Helper function to adjust the range for fixing (removing quotes) + const removeQuotes = (range: readonly [number, number]) => { + const [start, end] = range + return [start + 1, end - 1] as const } - const removeImportantKeyword = (input: string) => { - if (exclamationRegex.test(input)) { - // Remove trailing '!' - return { fixed: input.replace(exclamationRegex, ''), keyword: '!' } - } else if (importantRegex.test(input)) { - // Remove '!important' with optional whitespace - return { fixed: input.replace(importantRegex, ''), keyword: '!important' } - } else { - // No match, return the original string - return { fixed: input, keyword: null } + // Caches for helper functions + const pandaPropCache = new WeakMap() + const pandaAttributeCache = new WeakMap() + const recipeVariantCache = new WeakMap() + + // Cached version of isPandaProp + const isCachedPandaProp = (node: TSESTree.JSXAttribute): boolean => { + if (pandaPropCache.has(node)) { + return pandaPropCache.get(node)! } + const result = isPandaProp(node, context) + pandaPropCache.set(node, result) + return !!result } - const handleLiteral = (node: Node) => { - if (!isLiteral(node)) return - if (!hasImportantKeyword(node.value?.toString())) return + // Cached version of isPandaAttribute + const isCachedPandaAttribute = (node: TSESTree.Property): boolean => { + if (pandaAttributeCache.has(node)) { + return pandaAttributeCache.get(node)! + } + const result = isPandaAttribute(node, context) + pandaAttributeCache.set(node, result) + return !!result + } - sendReport(node) + // Cached version of isRecipeVariant + const isCachedRecipeVariant = (node: TSESTree.Property): boolean => { + if (recipeVariantCache.has(node)) { + return recipeVariantCache.get(node)! + } + const result = isRecipeVariant(node, context) + recipeVariantCache.set(node, result) + return !!result } - const handleTemplateLiteral = (node: Node) => { - if (!isTemplateLiteral(node)) return - if (node.expressions.length > 0) return - if (!hasImportantKeyword(node.quasis[0].value.raw)) return + // Function to check if a value contains '!important' or '!' + const hasImportantKeyword = (value: string | undefined): boolean => { + if (!value) return false + const arbitraryValue = getArbitraryValue(value) + return exclamationRegex.test(arbitraryValue) || importantRegex.test(arbitraryValue) + } - sendReport(node.quasis[0], node.quasis[0].value.raw) + // Function to remove '!important' or '!' from a string + const removeImportantKeyword = (input: string): { fixed: string; keyword: string | null } => { + if (importantRegex.test(input)) { + // Remove '!important' with optional whitespace + return { fixed: input.replace(importantRegex, '').trimEnd(), keyword: '!important' } + } else if (exclamationRegex.test(input)) { + // Remove trailing '!' + return { fixed: input.replace(exclamationRegex, '').trimEnd(), keyword: '!' } + } else { + // No match, return the original string + return { fixed: input, keyword: null } + } } - const sendReport = (node: any, _value?: string) => { - const value = _value ?? node.value?.toString() - const { keyword, fixed } = removeImportantKeyword(value) + // Unified function to handle reporting + const handleNodeValue = (node: TSESTree.Node, value: string) => { + if (!hasImportantKeyword(value)) return - return context.report({ + const { fixed, keyword } = removeImportantKeyword(value) + + context.report({ node, messageId: 'important', + data: { keyword }, suggest: [ { messageId: 'remove', data: { keyword }, fix: (fixer) => { - return fixer.replaceTextRange(removeQuotes(node.range), fixed) + return fixer.replaceTextRange(removeQuotes(node.range as [number, number]), fixed) }, }, ], @@ -84,26 +113,44 @@ const rule: Rule = createRule({ } return { - JSXAttribute(node) { + // JSX Attributes + JSXAttribute(node: TSESTree.JSXAttribute) { if (!node.value) return - if (!isPandaProp(node, context)) return - - handleLiteral(node.value) - - if (!isJSXExpressionContainer(node.value)) return - - handleLiteral(node.value.expression) - handleTemplateLiteral(node.value.expression) + if (!isCachedPandaProp(node)) return + + const valueNode = node.value + + if (isLiteral(valueNode)) { + const val = valueNode.value?.toString() ?? '' + handleNodeValue(valueNode, val) + } else if (isJSXExpressionContainer(valueNode)) { + const expr = valueNode.expression + + if (isLiteral(expr)) { + const val = expr.value?.toString() ?? '' + handleNodeValue(expr, val) + } else if (isTemplateLiteral(expr) && expr.expressions.length === 0) { + const val = expr.quasis[0].value.raw + handleNodeValue(expr.quasis[0], val) + } + } }, - Property(node) { + // Object Properties + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return - if (!isLiteral(node.value) && !isTemplateLiteral(node.value)) return - if (!isPandaAttribute(node, context)) return - if (isRecipeVariant(node, context)) return - - handleLiteral(node.value) - handleTemplateLiteral(node.value) + if (!isCachedPandaAttribute(node)) return + if (isCachedRecipeVariant(node)) return + + const valueNode = node.value + + if (isLiteral(valueNode)) { + const val = valueNode.value?.toString() ?? '' + handleNodeValue(valueNode, val) + } else if (isTemplateLiteral(valueNode) && valueNode.expressions.length === 0) { + const val = valueNode.quasis[0].value.raw + handleNodeValue(valueNode.quasis[0], val) + } }, } }, diff --git a/plugin/src/rules/no-invalid-nesting.ts b/plugin/src/rules/no-invalid-nesting.ts index e3f7384..9accf6f 100644 --- a/plugin/src/rules/no-invalid-nesting.ts +++ b/plugin/src/rules/no-invalid-nesting.ts @@ -1,6 +1,7 @@ -import { isIdentifier, isLiteral, isObjectExpression, isTemplateLiteral } from '../utils/nodes' +import { isLiteral, isTemplateLiteral } from '../utils/nodes' import { type Rule, createRule } from '../utils' import { isInJSXProp, isInPandaFunction, isRecipeVariant, isStyledProperty } from '../utils/helpers' +import { TSESTree } from '@typescript-eslint/utils' export const RULE_NAME = 'no-invalid-nesting' @@ -8,34 +9,88 @@ const rule: Rule = createRule({ name: RULE_NAME, meta: { docs: { - description: "Warn against invalid nesting. i.e. nested styles that don't contain the `&` character.", + description: 'Warn against invalid nesting. Nested styles must contain the `&` character.', }, messages: { nesting: 'Invalid style nesting. Nested styles must contain the `&` character.', }, - type: 'suggestion', + type: 'problem', schema: [], }, defaultOptions: [], create(context) { + // Caches for helper functions + const pandaFunctionCache = new WeakMap() + const jsxPropCache = new WeakMap() + const recipeVariantCache = new WeakMap() + const styledPropertyCache = new WeakMap() + + // Cached helper functions + const isCachedInPandaFunction = (node: TSESTree.Property): boolean => { + if (pandaFunctionCache.has(node)) { + return pandaFunctionCache.get(node)! + } + const result = !!isInPandaFunction(node, context) + pandaFunctionCache.set(node, result) + return !!result + } + + const isCachedInJSXProp = (node: TSESTree.Property): boolean => { + if (jsxPropCache.has(node)) { + return jsxPropCache.get(node)! + } + const result = isInJSXProp(node, context) + jsxPropCache.set(node, result) + return !!result + } + + const isCachedRecipeVariant = (node: TSESTree.Property): boolean => { + if (recipeVariantCache.has(node)) { + return recipeVariantCache.get(node)! + } + const result = isRecipeVariant(node, context) + recipeVariantCache.set(node, result) + return !!result + } + + const isCachedStyledProperty = (node: TSESTree.Property): boolean => { + if (styledPropertyCache.has(node)) { + return styledPropertyCache.get(node)! + } + const result = isStyledProperty(node, context) + styledPropertyCache.set(node, result) + return !!result + } + + // Function to check if a key is an invalid nesting selector + const isInvalidNestingSelector = (node: TSESTree.Expression): boolean => { + if (isLiteral(node) && typeof node.value === 'string') { + return !node.value.includes('&') + } else if (isTemplateLiteral(node) && node.expressions.length === 0) { + return !node.quasis[0].value.raw.includes('&') + } + return false + } + return { - Property(node) { - if (!isObjectExpression(node.value) || isIdentifier(node.key)) return - const caller = isInPandaFunction(node, context) - if (!caller && !isInJSXProp(node, context)) return - if (isRecipeVariant(node, context)) return - if (isStyledProperty(node, context)) return - - const invalidLiteral = - isLiteral(node.key) && typeof node.key.value === 'string' && !node.key.value.includes('&') - const invalidTemplateLiteral = isTemplateLiteral(node.key) && !node.key.quasis[0].value.raw.includes('&') - - if (!(invalidLiteral || invalidTemplateLiteral)) return - - context.report({ - node: node.key, - messageId: 'nesting', - }) + // Use AST selector to target Property nodes with non-Identifier keys whose value is an ObjectExpression + 'Property[key.type!=/Identifier/][value.type="ObjectExpression"]'(node: TSESTree.Property) { + // Check if the node is within a Panda function or JSX prop + const inPandaFunction = isCachedInPandaFunction(node) + const inJSXProp = isCachedInJSXProp(node) + + if (!inPandaFunction && !inJSXProp) return + if (isCachedRecipeVariant(node)) return + if (isCachedStyledProperty(node)) return + + const keyNode = node.key + + if (isInvalidNestingSelector(keyNode)) { + context.report({ + node: keyNode, + messageId: 'nesting', + }) + } }, } }, diff --git a/plugin/src/rules/no-invalid-token-paths.ts b/plugin/src/rules/no-invalid-token-paths.ts index 9628953..13cc1e3 100644 --- a/plugin/src/rules/no-invalid-token-paths.ts +++ b/plugin/src/rules/no-invalid-token-paths.ts @@ -7,9 +7,9 @@ import { isRecipeVariant, } from '../utils/helpers' import { type Rule, createRule } from '../utils' -import { AST_NODE_TYPES } from '@typescript-eslint/utils' +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' import { isNodeOfTypes } from '@typescript-eslint/utils/ast-utils' -import { isIdentifier, isJSXExpressionContainer, isLiteral, isTemplateLiteral, type Node } from '../utils/nodes' +import { isIdentifier, isJSXExpressionContainer, isLiteral, isTemplateLiteral } from '../utils/nodes' export const RULE_NAME = 'no-invalid-token-paths' @@ -22,92 +22,107 @@ const rule: Rule = createRule({ messages: { noInvalidTokenPaths: '`{{token}}` is an invalid token path.', }, - type: 'suggestion', + type: 'problem', schema: [], }, defaultOptions: [], create(context) { - const handleLiteral = (node: Node) => { - if (!isLiteral(node)) return + // Cache for invalid tokens to avoid redundant computations + const invalidTokensCache = new Map() - sendReport(node) - } + const sendReport = (node: TSESTree.Node, value: string | undefined) => { + if (!value) return - const handleTemplateLiteral = (node: Node) => { - if (!isTemplateLiteral(node)) return - if (node.expressions.length > 0) return - sendReport(node.quasis[0], node.quasis[0].value.raw) - } + let tokens: string[] | undefined = invalidTokensCache.get(value) + if (!tokens) { + tokens = getInvalidTokens(value, context) + invalidTokensCache.set(value, tokens) + } - const sendReport = (node: any, _value?: string) => { - const value = _value ?? node.value?.toString() - const tokens = getInvalidTokens(value, context) - if (!tokens) return - - if (tokens.length > 0) { - tokens.forEach((token) => { - context.report({ - node, - messageId: 'noInvalidTokenPaths', - data: { token }, - }) + if (tokens.length === 0) return + + tokens.forEach((token) => { + context.report({ + node, + messageId: 'noInvalidTokenPaths', + data: { token }, }) - } + }) } - return { - JSXAttribute(node) { - if (!node.value) return - if (!isPandaProp(node, context)) return - - handleLiteral(node.value) + const handleLiteralOrTemplate = (node: TSESTree.Node | undefined) => { + if (!node) return - if (!isJSXExpressionContainer(node.value)) return + if (isLiteral(node)) { + const value = node.value?.toString() + sendReport(node, value) + } else if (isTemplateLiteral(node) && node.expressions.length === 0) { + const value = node.quasis[0].value.raw + sendReport(node.quasis[0], value) + } + } - handleLiteral(node.value.expression) - handleTemplateLiteral(node.value.expression) + return { + JSXAttribute(node: TSESTree.JSXAttribute) { + if (!node.value || !isPandaProp(node, context)) return + + if (isLiteral(node.value)) { + handleLiteralOrTemplate(node.value) + } else if (isJSXExpressionContainer(node.value)) { + handleLiteralOrTemplate(node.value.expression) + } }, - Property(node) { - if (!isIdentifier(node.key)) return - if (!isNodeOfTypes([AST_NODE_TYPES.Literal, AST_NODE_TYPES.TemplateLiteral])(node.value)) return - if (!isPandaAttribute(node, context)) return - if (isRecipeVariant(node, context)) return - - handleLiteral(node.value) - handleTemplateLiteral(node.value) + Property(node: TSESTree.Property) { + if ( + !isIdentifier(node.key) || + !isNodeOfTypes([AST_NODE_TYPES.Literal, AST_NODE_TYPES.TemplateLiteral])(node.value) || + !isPandaAttribute(node, context) || + isRecipeVariant(node, context) + ) { + return + } + + handleLiteralOrTemplate(node.value) }, - TaggedTemplateExpression(node) { + TaggedTemplateExpression(node: TSESTree.TaggedTemplateExpression) { const caller = getTaggedTemplateCaller(node) - if (!caller) return + if (!caller || !isPandaIsh(caller, context)) return - if (!isPandaIsh(caller, context)) return + const quasis = node.quasi.quasis + quasis.forEach((quasi) => { + const styles = quasi.value.raw + if (!styles) return - const quasis = node.quasi.quasis[0] - const styles = quasis.value.raw - const tokens = getInvalidTokens(styles, context) - if (!tokens) return + let tokens: string[] | undefined = invalidTokensCache.get(styles) + if (!tokens) { + tokens = getInvalidTokens(styles, context) + invalidTokensCache.set(styles, tokens) + } - tokens.forEach((token, i, arr) => { - // Avoid duplicate reports on the same token - if (arr.indexOf(token) < i) return + if (tokens.length === 0) return - let index = styles.indexOf(token) + tokens.forEach((token) => { + let index = styles.indexOf(token) - while (index !== -1) { - const start = quasis.range[0] + 1 + index - const end = start + token.length + while (index !== -1) { + const start = quasi.range[0] + index + 1 // +1 for the backtick + const end = start + token.length - context.report({ - loc: { start: context.sourceCode.getLocFromIndex(start), end: context.sourceCode.getLocFromIndex(end) }, - messageId: 'noInvalidTokenPaths', - data: { token }, - }) + context.report({ + loc: { + start: context.sourceCode.getLocFromIndex(start), + end: context.sourceCode.getLocFromIndex(end), + }, + messageId: 'noInvalidTokenPaths', + data: { token }, + }) - // Check for other occurences of the invalid token - index = styles.indexOf(token, index + 1) - } + // Check for other occurences of the invalid token + index = styles.indexOf(token, index + token.length) + } + }) }) }, } diff --git a/plugin/src/rules/no-margin-properties.ts b/plugin/src/rules/no-margin-properties.ts index 36bab44..361d67f 100644 --- a/plugin/src/rules/no-margin-properties.ts +++ b/plugin/src/rules/no-margin-properties.ts @@ -21,29 +21,77 @@ const rule: Rule = createRule({ }, defaultOptions: [], create(context) { - const getLonghand = (name: string) => resolveLonghand(name, context) ?? name + // Cache for resolved longhand properties + const longhandCache = new Map() + + const getLonghand = (name: string): string => { + if (longhandCache.has(name)) { + return longhandCache.get(name)! + } + const longhand = resolveLonghand(name, context) ?? name + longhandCache.set(name, longhand) + return longhand + } + + const marginRegex = /margin/i + + const isMarginProperty = (name: string): boolean => { + const longhand = getLonghand(name).toLowerCase() + return marginRegex.test(longhand) + } const sendReport = (node: TSESTree.Identifier | TSESTree.JSXIdentifier) => { - if (!getLonghand(node.name).toLowerCase().includes('margin')) return + if (!isMarginProperty(node.name)) return - return context.report({ + context.report({ node, messageId: 'noMargin', }) } + // Cache for helper functions + const pandaPropCache = new WeakMap() + const isCachedPandaProp = (node: TSESTree.JSXAttribute): boolean => { + if (pandaPropCache.has(node)) { + return pandaPropCache.get(node)! + } + const result = isPandaProp(node, context) + pandaPropCache.set(node, result) + return !!result + } + + const pandaAttributeCache = new WeakMap() + const isCachedPandaAttribute = (node: TSESTree.Property): boolean => { + if (pandaAttributeCache.has(node)) { + return pandaAttributeCache.get(node)! + } + const result = isPandaAttribute(node, context) + pandaAttributeCache.set(node, result) + return !!result + } + + const recipeVariantCache = new WeakMap() + const isCachedRecipeVariant = (node: TSESTree.Property): boolean => { + if (recipeVariantCache.has(node)) { + return recipeVariantCache.get(node)! + } + const result = isRecipeVariant(node, context) + recipeVariantCache.set(node, result) + return !!result + } + return { - JSXAttribute(node) { + JSXAttribute(node: TSESTree.JSXAttribute) { if (!isJSXIdentifier(node.name)) return - if (!isPandaProp(node, context)) return + if (!isCachedPandaProp(node)) return sendReport(node.name) }, - Property(node) { + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return - if (!isPandaAttribute(node, context)) return - if (isRecipeVariant(node, context)) return + if (!isCachedPandaAttribute(node)) return + if (isCachedRecipeVariant(node)) return sendReport(node.key) }, diff --git a/plugin/src/rules/no-physical-properties.ts b/plugin/src/rules/no-physical-properties.ts index 7d71dba..1fa9f75 100644 --- a/plugin/src/rules/no-physical-properties.ts +++ b/plugin/src/rules/no-physical-properties.ts @@ -11,11 +11,11 @@ const rule: Rule = createRule({ meta: { docs: { description: - 'Encourage the use of [logical properties](https://mdn.io/logical-properties-basic-concepts) over physical proeprties, to foster a responsive and adaptable user interface.', + 'Encourage the use of logical properties over physical properties to foster a responsive and adaptable user interface.', }, messages: { - physical: 'Use logical property of {{physical}} instead. Prefer `{{logical}}`', - replace: 'Replace `{{physical}}` with `{{logical}}`', + physical: 'Use logical property instead of {{physical}}. Prefer `{{logical}}`.', + replace: 'Replace `{{physical}}` with `{{logical}}`.', }, type: 'suggestion', hasSuggestions: true, @@ -23,19 +23,62 @@ const rule: Rule = createRule({ }, defaultOptions: [], create(context) { - const getLonghand = (name: string) => resolveLonghand(name, context) ?? name + // Cache for resolved longhand properties + const longhandCache = new Map() + + // Cache for helper functions + const pandaPropCache = new WeakMap() + const pandaAttributeCache = new WeakMap() + const recipeVariantCache = new WeakMap() + + const getLonghand = (name: string): string => { + if (longhandCache.has(name)) { + return longhandCache.get(name)! + } + const longhand = resolveLonghand(name, context) ?? name + longhandCache.set(name, longhand) + return longhand + } + + const isCachedPandaProp = (node: TSESTree.JSXAttribute): boolean => { + if (pandaPropCache.has(node)) { + return pandaPropCache.get(node)! + } + const result = isPandaProp(node, context) + pandaPropCache.set(node, result) + return !!result + } + + const isCachedPandaAttribute = (node: TSESTree.Property): boolean => { + if (pandaAttributeCache.has(node)) { + return pandaAttributeCache.get(node)! + } + const result = isPandaAttribute(node, context) + pandaAttributeCache.set(node, result) + return !!result + } + + const isCachedRecipeVariant = (node: TSESTree.Property): boolean => { + if (recipeVariantCache.has(node)) { + return recipeVariantCache.get(node)! + } + const result = isRecipeVariant(node, context) + recipeVariantCache.set(node, result) + return !!result + } const sendReport = (node: TSESTree.Identifier | TSESTree.JSXIdentifier) => { - if (!(getLonghand(node.name) in physicalProperties)) return + const longhandName = getLonghand(node.name) + if (!(longhandName in physicalProperties)) return - const logical = physicalProperties[getLonghand(node.name)] - const longhand = resolveLonghand(node.name, context) + const logical = physicalProperties[longhandName] + const physicalName = `\`${node.name}\`${longhandName !== node.name ? ` (resolved to \`${longhandName}\`)` : ''}` - return context.report({ + context.report({ node, messageId: 'physical', data: { - physical: `\`${node.name}\`${longhand ? ` - \`${longhand}\`` : ''}`, + physical: physicalName, logical, }, suggest: [ @@ -46,7 +89,7 @@ const rule: Rule = createRule({ logical, }, fix: (fixer) => { - return fixer.replaceTextRange(node.range, logical) + return fixer.replaceText(node, logical) }, }, ], @@ -54,17 +97,17 @@ const rule: Rule = createRule({ } return { - JSXAttribute(node) { + JSXAttribute(node: TSESTree.JSXAttribute) { if (!isJSXIdentifier(node.name)) return - if (!isPandaProp(node, context)) return + if (!isCachedPandaProp(node)) return sendReport(node.name) }, - Property(node) { + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return - if (!isPandaAttribute(node, context)) return - if (isRecipeVariant(node, context)) return + if (!isCachedPandaAttribute(node)) return + if (isCachedRecipeVariant(node)) return sendReport(node.key) }, diff --git a/plugin/src/rules/no-property-renaming.ts b/plugin/src/rules/no-property-renaming.ts index 9e46a86..056b44a 100644 --- a/plugin/src/rules/no-property-renaming.ts +++ b/plugin/src/rules/no-property-renaming.ts @@ -9,20 +9,53 @@ const rule: Rule = createRule({ name: RULE_NAME, meta: { docs: { - description: "Ensure user does not rename a property for a pattern or style prop. \nIt doesn't get tracked.", + description: + 'Ensure that properties for patterns or style props are not renamed, as it prevents proper tracking.', }, messages: { noRenaming: - 'Incoming `{{prop}}` prop is different from the expected `{{expected}}` attribute. Panada will not track this prop.', + 'Incoming `{{prop}}` prop is different from the expected `{{expected}}` attribute. Panda will not track this prop.', }, - type: 'suggestion', + type: 'problem', schema: [], }, defaultOptions: [], create(context) { - const sendReport = (node: any, expected: string, prop: string) => { - return context.report({ - node: node.value, + // Caches for helper functions + const pandaPropCache = new WeakMap() + const pandaAttributeCache = new WeakMap() + const recipeVariantCache = new WeakMap() + + const isCachedPandaProp = (node: TSESTree.JSXAttribute): boolean => { + if (pandaPropCache.has(node)) { + return pandaPropCache.get(node)! + } + const result = isPandaProp(node, context) + pandaPropCache.set(node, result) + return !!result + } + + const isCachedPandaAttribute = (node: TSESTree.Property): boolean => { + if (pandaAttributeCache.has(node)) { + return pandaAttributeCache.get(node)! + } + const result = isPandaAttribute(node, context) + pandaAttributeCache.set(node, result) + return !!result + } + + const isCachedRecipeVariant = (node: TSESTree.Property): boolean => { + if (recipeVariantCache.has(node)) { + return recipeVariantCache.get(node)! + } + const result = isRecipeVariant(node, context) + recipeVariantCache.set(node, result) + return !!result + } + + const sendReport = (node: TSESTree.Node, expected: string, prop: string) => { + context.report({ + node, messageId: 'noRenaming', data: { expected, @@ -31,38 +64,36 @@ const rule: Rule = createRule({ }) } - const handleReport = (node: TSESTree.Node, value: any, attr: string) => { + const handleReport = (node: TSESTree.Node, value: TSESTree.Node, attr: string) => { if (isIdentifier(value) && attr !== value.name) { - return sendReport(node, attr, value.name) - } - - if (isMemberExpression(value) && isIdentifier(value.property) && attr !== value.property.name) { - return sendReport(node, attr, value.property.name) + sendReport(node, attr, value.name) + } else if (isMemberExpression(value) && isIdentifier(value.property) && attr !== value.property.name) { + sendReport(node, attr, value.property.name) } } return { - JSXAttribute(node) { + JSXAttribute(node: TSESTree.JSXAttribute) { if (!node.value) return if (!isJSXExpressionContainer(node.value)) return - if (!isPandaProp(node, context)) return + if (!isCachedPandaProp(node)) return const attr = node.name.name.toString() const expression = node.value.expression - handleReport(node, expression, attr) + handleReport(node.value, expression, attr) }, - Property(node) { + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return if (!isIdentifier(node.value) && !isMemberExpression(node.value)) return - if (!isPandaAttribute(node, context)) return - if (isRecipeVariant(node, context)) return + if (!isCachedPandaAttribute(node)) return + if (isCachedRecipeVariant(node)) return - const attr = node.key.name.toString() + const attr = node.key.name const value = node.value - handleReport(node, value, attr) + handleReport(node.value, value, attr) }, } }, diff --git a/plugin/src/rules/no-unsafe-token-fn-usage.ts b/plugin/src/rules/no-unsafe-token-fn-usage.ts index 1d628b4..4e3ca09 100644 --- a/plugin/src/rules/no-unsafe-token-fn-usage.ts +++ b/plugin/src/rules/no-unsafe-token-fn-usage.ts @@ -1,14 +1,7 @@ import { extractTokens, getTokenImport, isPandaAttribute, isPandaProp, isRecipeVariant } from '../utils/helpers' import { type Rule, createRule } from '../utils' -import { TSESTree } from '@typescript-eslint/utils' -import { - isCallExpression, - isIdentifier, - isJSXExpressionContainer, - isLiteral, - isTemplateLiteral, - type Node, -} from '../utils/nodes' +import { isCallExpression, isIdentifier, isJSXExpressionContainer, isLiteral, isTemplateLiteral } from '../utils/nodes' +import { type TSESTree } from '@typescript-eslint/utils' import { getArbitraryValue } from '@pandacss/shared' export const RULE_NAME = 'no-unsafe-token-fn-usage' @@ -21,8 +14,8 @@ const rule: Rule = createRule({ 'Prevent users from using the token function in situations where they could simply use the raw design token.', }, messages: { - noUnsafeTokenFnUsage: 'Unneccessary token function usage. Prefer design token', - replace: 'Replace token function with `{{safe}}`', + noUnsafeTokenFnUsage: 'Unnecessary token function usage. Prefer design token.', + replace: 'Replace token function with `{{safe}}`.', }, type: 'suggestion', hasSuggestions: true, @@ -30,35 +23,65 @@ const rule: Rule = createRule({ }, defaultOptions: [], create(context) { - const isUnsafeCallExpression = (node: TSESTree.CallExpression) => { - const tkImport = getTokenImport(context) + // Cache for getTokenImport result + let tokenImportCache: { alias: string } | null | undefined + + const getCachedTokenImport = (): { alias: string } | null | undefined => { + if (tokenImportCache !== undefined) { + return tokenImportCache + } + tokenImportCache = getTokenImport(context) + return tokenImportCache + } + + const isUnsafeCallExpression = (node: TSESTree.CallExpression): boolean => { + const tkImport = getCachedTokenImport() return isIdentifier(node.callee) && node.callee.name === tkImport?.alias } - const tokenWrap = (value?: string) => (value ? `token(${value})` : '') + const tokenWrap = (value?: string): string => (value ? `token(${value})` : '') - const handleRuntimeFm = (node: Node) => { + const isCompositeValue = (input?: string): boolean => { + if (!input) return false + // Regular expression to match token-only values, e.g., token('space.2') or {space.2} + const tokenRegex = /^(?:token\([^)]*\)|\{[^}]*\})$/ + return !tokenRegex.test(input) + } + + const sendReport = (node: TSESTree.Node, value: string) => { + const tkImports = extractTokens(value) + if (!tkImports.length) return + const token = tkImports[0].replace(/^[^.]*\./, '') + + context.report({ + node, + messageId: 'noUnsafeTokenFnUsage', + suggest: [ + { + messageId: 'replace', + data: { safe: token }, + fix: (fixer) => fixer.replaceText(node, `'${token}'`), + }, + ], + }) + } + + const handleRuntimeFm = (node: TSESTree.Node) => { if (!isCallExpression(node)) return if (!isUnsafeCallExpression(node)) return const value = node.arguments[0] if (isLiteral(value)) { - sendReport(node, tokenWrap(getArbitraryValue(value.value?.toString() ?? ''))) - } - if (isTemplateLiteral(value)) { - sendReport(node, tokenWrap(getArbitraryValue(value.quasis[0].value.raw))) + const val = getArbitraryValue(value.value?.toString() ?? '') + sendReport(node, tokenWrap(val)) + } else if (isTemplateLiteral(value) && value.expressions.length === 0) { + const val = getArbitraryValue(value.quasis[0].value.raw) + sendReport(node, tokenWrap(val)) } } - const isCompositeValue = (input?: string) => { - if (!input) return - // Regular expression to match token only values. i.e. token('space.2') or {space.2} - const tokenRegex = /^(?:token\([^)]*\)|\{[^}]*\})$/ - return !tokenRegex.test(input) - } - - const handleLiteral = (node: Node) => { + const handleLiteral = (node: TSESTree.Node) => { if (!isLiteral(node)) return const value = getArbitraryValue(node.value?.toString() ?? '') if (isCompositeValue(value)) return @@ -66,55 +89,69 @@ const rule: Rule = createRule({ sendReport(node, value) } - const handleTemplateLiteral = (node: Node) => { - if (!isTemplateLiteral(node)) return - if (node.expressions.length > 0) return + const handleTemplateLiteral = (node: TSESTree.Node) => { + if (!isTemplateLiteral(node) || node.expressions.length > 0) return - sendReport(node, getArbitraryValue(node.quasis[0].value.raw)) + const value = getArbitraryValue(node.quasis[0].value.raw) + sendReport(node, value) } - const sendReport = (node: any, value: string) => { - const tkImports = extractTokens(value) - if (!tkImports.length) return - const token = tkImports[0].replace(/^[^.]*\./, '') + // Cached versions of helper functions + const pandaPropCache = new WeakMap() + const isCachedPandaProp = (node: TSESTree.JSXAttribute): boolean => { + if (pandaPropCache.has(node)) { + return pandaPropCache.get(node)! + } + const result = isPandaProp(node, context) + pandaPropCache.set(node, result) + return !!result + } - return context.report({ - node, - messageId: 'noUnsafeTokenFnUsage', - suggest: [ - { - messageId: 'replace', - data: { safe: token }, - fix: (fixer) => { - return fixer.replaceTextRange(node.range, `'${token}'`) - }, - }, - ], - }) + const pandaAttributeCache = new WeakMap() + const isCachedPandaAttribute = (node: TSESTree.Property): boolean => { + if (pandaAttributeCache.has(node)) { + return pandaAttributeCache.get(node)! + } + const result = isPandaAttribute(node, context) + pandaAttributeCache.set(node, result) + return !!result + } + + const recipeVariantCache = new WeakMap() + const isCachedRecipeVariant = (node: TSESTree.Property): boolean => { + if (recipeVariantCache.has(node)) { + return recipeVariantCache.get(node)! + } + const result = isRecipeVariant(node, context) + recipeVariantCache.set(node, result) + return !!result } return { - JSXAttribute(node) { + JSXAttribute(node: TSESTree.JSXAttribute) { if (!node.value) return - if (!isPandaProp(node, context)) return + if (!isCachedPandaProp(node)) return handleLiteral(node.value) - if (!isJSXExpressionContainer(node.value)) return - - handleLiteral(node.value.expression) - handleTemplateLiteral(node.value.expression) - handleRuntimeFm(node.value.expression) + if (isJSXExpressionContainer(node.value)) { + const expression = node.value.expression + handleLiteral(expression) + handleTemplateLiteral(expression) + handleRuntimeFm(expression) + } }, - Property(node) { - if (!isCallExpression(node.value) && !isLiteral(node.value) && !isTemplateLiteral(node.value)) return - if (!isPandaAttribute(node, context)) return - if (isRecipeVariant(node, context)) return + Property(node: TSESTree.Property) { + if (!isCachedPandaAttribute(node)) return + if (isCachedRecipeVariant(node)) return - handleRuntimeFm(node.value) - handleLiteral(node.value) - handleTemplateLiteral(node.value) + const valueNode = node.value + if (isCallExpression(valueNode) || isLiteral(valueNode) || isTemplateLiteral(valueNode)) { + handleRuntimeFm(valueNode) + handleLiteral(valueNode) + handleTemplateLiteral(valueNode) + } }, } }, diff --git a/plugin/src/rules/prefer-atomic-properties.ts b/plugin/src/rules/prefer-atomic-properties.ts index 3066f38..99aeb9c 100644 --- a/plugin/src/rules/prefer-atomic-properties.ts +++ b/plugin/src/rules/prefer-atomic-properties.ts @@ -1,8 +1,8 @@ -import { isPandaAttribute, isPandaProp, isRecipeVariant, isValidProperty, resolveLonghand } from '../utils/helpers' +import type { TSESTree } from '@typescript-eslint/utils' import { type Rule, createRule } from '../utils' +import { isPandaAttribute, isPandaProp, isRecipeVariant, isValidProperty, resolveLonghand } from '../utils/helpers' import { compositeProperties } from '../utils/composite-properties' import { isIdentifier, isJSXIdentifier } from '../utils/nodes' -import type { TSESTree } from '@typescript-eslint/utils' export const RULE_NAME = 'prefer-atomic-properties' @@ -13,27 +13,83 @@ const rule: Rule = createRule({ description: 'Encourage the use of atomic properties instead of composite properties in the codebase.', }, messages: { - atomic: 'Use atomic properties of `{{composite}}` instead. Prefer: \n{{atomics}}', + atomic: 'Use atomic properties instead of `{{composite}}`. Prefer: \n{{atomics}}', }, type: 'suggestion', schema: [], }, defaultOptions: [], create(context) { - const resolveCompositeProperty = (name: string) => { - if (Object.hasOwn(compositeProperties, name)) return name + // Cache for resolved longhand properties + const longhandCache = new Map() + const getLonghand = (name: string): string => { + if (longhandCache.has(name)) { + return longhandCache.get(name)! + } const longhand = resolveLonghand(name, context) ?? name - if (isValidProperty(longhand, context) && Object.hasOwn(compositeProperties, longhand)) return longhand + longhandCache.set(name, longhand) + return longhand } - const sendReport = (node: TSESTree.Identifier | TSESTree.JSXIdentifier) => { - const cmp = resolveCompositeProperty(node.name) - if (!cmp) return + // Cache for composite property resolution + const compositePropertyCache = new Map() + + const resolveCompositeProperty = (name: string): string | undefined => { + if (compositePropertyCache.has(name)) { + return compositePropertyCache.get(name) + } - const atomics = compositeProperties[cmp].map((name) => `\`${name}\``).join(',\n') + if (Object.hasOwn(compositeProperties, name)) { + compositePropertyCache.set(name, name) + return name + } - return context.report({ + const longhand = getLonghand(name) + if (isValidProperty(longhand, context) && Object.hasOwn(compositeProperties, longhand)) { + compositePropertyCache.set(name, longhand) + return longhand + } + + compositePropertyCache.set(name, undefined) + return undefined + } + + // Caches for helper functions + const pandaPropCache = new WeakMap() + const isCachedPandaProp = (node: TSESTree.JSXAttribute): boolean => { + if (pandaPropCache.has(node)) { + return pandaPropCache.get(node)! + } + const result = isPandaProp(node, context) + pandaPropCache.set(node, result) + return !!result + } + + const pandaAttributeCache = new WeakMap() + const isCachedPandaAttribute = (node: TSESTree.Property): boolean => { + if (pandaAttributeCache.has(node)) { + return pandaAttributeCache.get(node)! + } + const result = isPandaAttribute(node, context) + pandaAttributeCache.set(node, result) + return !!result + } + + const recipeVariantCache = new WeakMap() + const isCachedRecipeVariant = (node: TSESTree.Property): boolean => { + if (recipeVariantCache.has(node)) { + return recipeVariantCache.get(node)! + } + const result = isRecipeVariant(node, context) + recipeVariantCache.set(node, result) + return !!result + } + + const sendReport = (node: TSESTree.Identifier | TSESTree.JSXIdentifier, composite: string) => { + const atomics = compositeProperties[composite].map((name) => `\`${name}\``).join(',\n') + + context.report({ node, messageId: 'atomic', data: { @@ -44,19 +100,25 @@ const rule: Rule = createRule({ } return { - JSXAttribute(node) { + JSXAttribute(node: TSESTree.JSXAttribute) { if (!isJSXIdentifier(node.name)) return - if (!isPandaProp(node, context)) return + if (!isCachedPandaProp(node)) return - sendReport(node.name) + const composite = resolveCompositeProperty(node.name.name) + if (!composite) return + + sendReport(node.name, composite) }, - Property(node) { + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return - if (!isPandaAttribute(node, context)) return - if (isRecipeVariant(node, context)) return + if (!isCachedPandaAttribute(node)) return + if (isCachedRecipeVariant(node)) return + + const composite = resolveCompositeProperty(node.key.name) + if (!composite) return - sendReport(node.key) + sendReport(node.key, composite) }, } }, diff --git a/plugin/src/rules/prefer-composite-properties.ts b/plugin/src/rules/prefer-composite-properties.ts index a848be1..3393e78 100644 --- a/plugin/src/rules/prefer-composite-properties.ts +++ b/plugin/src/rules/prefer-composite-properties.ts @@ -13,48 +13,108 @@ const rule: Rule = createRule({ description: 'Encourage the use of composite properties instead of atomic properties in the codebase.', }, messages: { - composite: 'Use composite property of `{{atomic}}` instead. \nPrefer: {{composite}}', + composite: 'Use composite property instead of `{{atomic}}`. Prefer: `{{composite}}`.', }, type: 'suggestion', schema: [], }, defaultOptions: [], create(context) { - const resolveCompositeProperty = (name: string) => { + // Cache for resolved longhand properties + const longhandCache = new Map() + + const getLonghand = (name: string): string => { + if (longhandCache.has(name)) { + return longhandCache.get(name)! + } const longhand = resolveLonghand(name, context) ?? name + longhandCache.set(name, longhand) + return longhand + } + + // Cache for composite property resolution + const compositePropertyCache = new Map() + + const resolveCompositeProperty = (name: string): string | undefined => { + if (compositePropertyCache.has(name)) { + return compositePropertyCache.get(name) + } + + const longhand = getLonghand(name) + + if (!isValidProperty(longhand, context)) { + compositePropertyCache.set(name, undefined) + return undefined + } + + const composite = Object.keys(compositeProperties).find((cpd) => compositeProperties[cpd].includes(longhand)) - if (!isValidProperty(longhand, context)) return - return Object.keys(compositeProperties).find((cpd) => compositeProperties[cpd].includes(longhand)) + compositePropertyCache.set(name, composite) + return composite } - const sendReport = (node: TSESTree.Identifier | TSESTree.JSXIdentifier) => { - const cmp = resolveCompositeProperty(node.name) - if (!cmp) return + // Caches for helper functions + const pandaPropCache = new WeakMap() + const isCachedPandaProp = (node: TSESTree.JSXAttribute): boolean => { + if (pandaPropCache.has(node)) { + return pandaPropCache.get(node)! + } + const result = isPandaProp(node, context) + pandaPropCache.set(node, result) + return !!result + } + + const pandaAttributeCache = new WeakMap() + const isCachedPandaAttribute = (node: TSESTree.Property): boolean => { + if (pandaAttributeCache.has(node)) { + return pandaAttributeCache.get(node)! + } + const result = isPandaAttribute(node, context) + pandaAttributeCache.set(node, result) + return !!result + } - return context.report({ + const recipeVariantCache = new WeakMap() + const isCachedRecipeVariant = (node: TSESTree.Property): boolean => { + if (recipeVariantCache.has(node)) { + return recipeVariantCache.get(node)! + } + const result = isRecipeVariant(node, context) + recipeVariantCache.set(node, result) + return !!result + } + + const sendReport = (node: TSESTree.Identifier | TSESTree.JSXIdentifier, composite: string) => { + context.report({ node, messageId: 'composite', data: { - composite: cmp, + composite, atomic: node.name, }, }) } return { - JSXAttribute(node) { + JSXAttribute(node: TSESTree.JSXAttribute) { if (!isJSXIdentifier(node.name)) return - if (!isPandaProp(node, context)) return + if (!isCachedPandaProp(node)) return + + const composite = resolveCompositeProperty(node.name.name) + if (!composite) return - sendReport(node.name) + sendReport(node.name, composite) }, - Property(node) { + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return - if (!isPandaAttribute(node, context)) return - if (isRecipeVariant(node, context)) return + if (!isCachedPandaAttribute(node)) return + if (isCachedRecipeVariant(node)) return + + const composite = resolveCompositeProperty(node.key.name) + if (!composite) return - sendReport(node.key) + sendReport(node.key, composite) }, } }, diff --git a/plugin/src/rules/prefer-longhand-properties.ts b/plugin/src/rules/prefer-longhand-properties.ts index ed8ea32..d204a82 100644 --- a/plugin/src/rules/prefer-longhand-properties.ts +++ b/plugin/src/rules/prefer-longhand-properties.ts @@ -13,8 +13,8 @@ const rule: Rule = createRule({ 'Discourage the use of shorthand properties and promote the preference for longhand properties in the codebase.', }, messages: { - longhand: 'Use longhand property of `{{shorthand}}` instead. Prefer `{{longhand}}`', - replace: 'Replace `{{shorthand}}` with `{{longhand}}`', + longhand: 'Use longhand property instead of `{{shorthand}}`. Prefer `{{longhand}}`.', + replace: 'Replace `{{shorthand}}` with `{{longhand}}`.', }, type: 'suggestion', hasSuggestions: true, @@ -22,16 +22,59 @@ const rule: Rule = createRule({ }, defaultOptions: [], create(context) { + // Cache for resolved longhand properties + const longhandCache = new Map() + + const getLonghand = (name: string): string | undefined => { + if (longhandCache.has(name)) { + return longhandCache.get(name)! + } + const longhand = resolveLonghand(name, context) + longhandCache.set(name, longhand) + return longhand + } + + // Caches for helper functions + const pandaPropCache = new WeakMap() + const isCachedPandaProp = (node: TSESTree.JSXAttribute): boolean => { + if (pandaPropCache.has(node)) { + return pandaPropCache.get(node)! + } + const result = isPandaProp(node, context) + pandaPropCache.set(node, result) + return !!result + } + + const pandaAttributeCache = new WeakMap() + const isCachedPandaAttribute = (node: TSESTree.Property): boolean => { + if (pandaAttributeCache.has(node)) { + return pandaAttributeCache.get(node)! + } + const result = isPandaAttribute(node, context) + pandaAttributeCache.set(node, result) + return !!result + } + + const recipeVariantCache = new WeakMap() + const isCachedRecipeVariant = (node: TSESTree.Property): boolean => { + if (recipeVariantCache.has(node)) { + return recipeVariantCache.get(node)! + } + const result = isRecipeVariant(node, context) + recipeVariantCache.set(node, result) + return !!result + } + const sendReport = (node: TSESTree.Identifier | TSESTree.JSXIdentifier) => { - const longhand = resolveLonghand(node.name, context)! - if (!longhand) return + const longhand = getLonghand(node.name) + if (!longhand || longhand === node.name) return const data = { shorthand: node.name, longhand, } - return context.report({ + context.report({ node, messageId: 'longhand', data, @@ -39,26 +82,24 @@ const rule: Rule = createRule({ { messageId: 'replace', data, - fix: (fixer) => { - return fixer.replaceTextRange(node.range, longhand) - }, + fix: (fixer) => fixer.replaceText(node, longhand), }, ], }) } return { - JSXAttribute(node) { + JSXAttribute(node: TSESTree.JSXAttribute) { if (!isJSXIdentifier(node.name)) return - if (!isPandaProp(node, context)) return + if (!isCachedPandaProp(node)) return sendReport(node.name) }, - Property(node) { + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return - if (!isPandaAttribute(node, context)) return - if (isRecipeVariant(node, context)) return + if (!isCachedPandaAttribute(node)) return + if (isCachedRecipeVariant(node)) return sendReport(node.key) }, diff --git a/plugin/src/rules/prefer-shorthand-properties.ts b/plugin/src/rules/prefer-shorthand-properties.ts index 86f8ec7..a63d655 100644 --- a/plugin/src/rules/prefer-shorthand-properties.ts +++ b/plugin/src/rules/prefer-shorthand-properties.ts @@ -13,8 +13,8 @@ const rule: Rule = createRule({ 'Discourage the use of longhand properties and promote the preference for shorthand properties in the codebase.', }, messages: { - shorthand: 'Use shorthand property of `{{longhand}}` instead. Prefer {{shorthand}}', - replace: 'Replace `{{longhand}}` with `{{shorthand}}`', + shorthand: 'Use shorthand property instead of `{{longhand}}`. Prefer `{{shorthand}}`.', + replace: 'Replace `{{longhand}}` with `{{shorthand}}`.', }, type: 'suggestion', hasSuggestions: true, @@ -22,21 +22,76 @@ const rule: Rule = createRule({ }, defaultOptions: [], create(context) { + // Cache for resolved longhand properties + const longhandCache = new Map() + + const getLonghand = (name: string): string | undefined => { + if (longhandCache.has(name)) { + return longhandCache.get(name)! + } + const longhand = resolveLonghand(name, context) + longhandCache.set(name, longhand) + return longhand + } + + // Cache for resolved shorthands + const shorthandsCache = new Map() + + const getShorthands = (name: string): string[] | undefined => { + if (shorthandsCache.has(name)) { + return shorthandsCache.get(name)! + } + const shorthands = resolveShorthands(name, context) + shorthandsCache.set(name, shorthands) + return shorthands + } + + // Caches for helper functions + const pandaPropCache = new WeakMap() + const isCachedPandaProp = (node: TSESTree.JSXAttribute): boolean => { + if (pandaPropCache.has(node)) { + return pandaPropCache.get(node)! + } + const result = isPandaProp(node, context) + pandaPropCache.set(node, result) + return !!result + } + + const pandaAttributeCache = new WeakMap() + const isCachedPandaAttribute = (node: TSESTree.Property): boolean => { + if (pandaAttributeCache.has(node)) { + return pandaAttributeCache.get(node)! + } + const result = isPandaAttribute(node, context) + pandaAttributeCache.set(node, result) + return !!result + } + + const recipeVariantCache = new WeakMap() + const isCachedRecipeVariant = (node: TSESTree.Property): boolean => { + if (recipeVariantCache.has(node)) { + return recipeVariantCache.get(node)! + } + const result = isRecipeVariant(node, context) + recipeVariantCache.set(node, result) + return !!result + } + const sendReport = (node: TSESTree.Identifier | TSESTree.JSXIdentifier) => { - const longhand = resolveLonghand(node.name, context) - if (longhand) return + const longhand = getLonghand(node.name) + if (longhand) return // If it's already shorthand, no need to report - const shorthands = resolveShorthands(node.name, context) - if (!shorthands) return + const shorthands = getShorthands(node.name) + if (!shorthands || shorthands.length === 0) return - const shorthand = shorthands.map((s) => `\`${s}\``)?.join(', ') + const shorthandList = shorthands.map((s) => `\`${s}\``).join(', ') const data = { longhand: node.name, - shorthand, + shorthand: shorthandList, } - return context.report({ + context.report({ node, messageId: 'shorthand', data, @@ -44,26 +99,24 @@ const rule: Rule = createRule({ { messageId: 'replace', data, - fix: (fixer) => { - return fixer.replaceTextRange(node.range, shorthands[0]) - }, + fix: (fixer) => fixer.replaceText(node, shorthands[0]), }, ], }) } return { - JSXAttribute(node) { + JSXAttribute(node: TSESTree.JSXAttribute) { if (!isJSXIdentifier(node.name)) return - if (!isPandaProp(node, context)) return + if (!isCachedPandaProp(node)) return sendReport(node.name) }, - Property(node) { + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return - if (!isPandaAttribute(node, context)) return - if (isRecipeVariant(node, context)) return + if (!isCachedPandaAttribute(node)) return + if (isCachedRecipeVariant(node)) return sendReport(node.key) }, diff --git a/plugin/src/rules/prefer-unified-property-style.ts b/plugin/src/rules/prefer-unified-property-style.ts index d132396..e6f84ca 100644 --- a/plugin/src/rules/prefer-unified-property-style.ts +++ b/plugin/src/rules/prefer-unified-property-style.ts @@ -2,6 +2,7 @@ import { isPandaAttribute, isPandaProp, isRecipeVariant, isValidProperty, resolv import { type Rule, createRule } from '../utils' import { compositeProperties } from '../utils/composite-properties' import { isIdentifier, isJSXIdentifier, isJSXOpeningElement, isObjectExpression } from '../utils/nodes' +import type { TSESTree } from '@typescript-eslint/utils' export const RULE_NAME = 'prefer-unified-property-style' @@ -10,40 +11,101 @@ const rule: Rule = createRule({ meta: { docs: { description: - 'Discourage against mixing atomic and composite forms of the same property in a style declaration. Atomic styles give more consistent results', + 'Discourage mixing atomic and composite forms of the same property in a style declaration. Atomic styles give more consistent results.', }, messages: { unify: - "You're mixing atomic {{atomicProperties}} with composite property {{composite}}. \nPrefer atomic styling to mixing atomic and composite properties. \nRemove `{{composite}}` and use one or more of {{atomics}} instead", + "You're mixing atomic {{atomicProperties}} with composite property `{{composite}}`. Prefer atomic styling to mixing atomic and composite properties. Remove `{{composite}}` and use one or more of {{atomics}} instead.", }, type: 'suggestion', schema: [], }, defaultOptions: [], create(context) { - const getLonghand = (name: string) => resolveLonghand(name, context) ?? name + // Cache for resolved longhand properties + const longhandCache = new Map() - const resolveCompositeProperty = (name: string) => { - if (name in compositeProperties) return name + const getLonghand = (name: string): string => { + if (longhandCache.has(name)) { + return longhandCache.get(name)! + } + const longhand = resolveLonghand(name, context) ?? name + longhandCache.set(name, longhand) + return longhand + } + + // Cache for composite property resolution + const compositePropertyCache = new Map() + + const resolveCompositeProperty = (name: string): string | undefined => { + if (compositePropertyCache.has(name)) { + return compositePropertyCache.get(name) + } + + if (name in compositeProperties) { + compositePropertyCache.set(name, name) + return name + } const longhand = getLonghand(name) - if (isValidProperty(longhand, context) && longhand in compositeProperties) return longhand + if (isValidProperty(longhand, context) && longhand in compositeProperties) { + compositePropertyCache.set(name, longhand) + return longhand + } + + compositePropertyCache.set(name, undefined) + return undefined + } + + // Caches for helper functions + const pandaPropCache = new WeakMap() + const isCachedPandaProp = (node: TSESTree.JSXAttribute): boolean => { + if (pandaPropCache.has(node)) { + return pandaPropCache.get(node)! + } + const result = isPandaProp(node, context) + pandaPropCache.set(node, result) + return !!result } - const sendReport = (node: any, cmp: string, siblings: string[]) => { - const _atomicProperties = siblings - .filter((prop) => compositeProperties[cmp].includes(getLonghand(prop))) + const pandaAttributeCache = new WeakMap() + const isCachedPandaAttribute = (node: TSESTree.Property): boolean => { + if (pandaAttributeCache.has(node)) { + return pandaAttributeCache.get(node)! + } + const result = isPandaAttribute(node, context) + pandaAttributeCache.set(node, result) + return !!result + } + + const recipeVariantCache = new WeakMap() + const isCachedRecipeVariant = (node: TSESTree.Property): boolean => { + if (recipeVariantCache.has(node)) { + return recipeVariantCache.get(node)! + } + const result = isRecipeVariant(node, context) + recipeVariantCache.set(node, result) + return !!result + } + + const sendReport = (node: TSESTree.Node, composite: string, siblings: string[]) => { + const atomicPropertiesSet = new Set( + siblings.filter((prop) => compositeProperties[composite].includes(getLonghand(prop))), + ) + + if (atomicPropertiesSet.size === 0) return + + const atomicProperties = Array.from(atomicPropertiesSet) .map((prop) => `\`${prop}\``) - if (!_atomicProperties.length) return + .join(', ') - const atomicProperties = _atomicProperties.join(', ') + (_atomicProperties.length === 1 ? ' style' : ' styles') - const atomics = compositeProperties[cmp].map((name) => `\`${name}\``).join(', ') + const atomics = compositeProperties[composite].map((name) => `\`${name}\``).join(', ') context.report({ node, messageId: 'unify', data: { - composite: cmp, + composite, atomicProperties, atomics, }, @@ -51,29 +113,33 @@ const rule: Rule = createRule({ } return { - JSXAttribute(node) { + JSXAttribute(node: TSESTree.JSXAttribute) { if (!isJSXIdentifier(node.name)) return - if (!isPandaProp(node, context)) return + if (!isCachedPandaProp(node)) return - const cmp = resolveCompositeProperty(node.name.name) - if (!cmp) return + const composite = resolveCompositeProperty(node.name.name) + if (!composite) return if (!isJSXOpeningElement(node.parent)) return const siblings = node.parent.attributes.map((attr: any) => attr.name.name) - sendReport(node, cmp, siblings) + + sendReport(node, composite, siblings) }, - Property(node) { + Property(node: TSESTree.Property) { if (!isIdentifier(node.key)) return - if (!isPandaAttribute(node, context)) return - if (isRecipeVariant(node, context)) return + if (!isCachedPandaAttribute(node)) return + if (isCachedRecipeVariant(node)) return - const cmp = resolveCompositeProperty(node.key.name) - if (!cmp) return + const composite = resolveCompositeProperty(node.key.name) + if (!composite) return if (!isObjectExpression(node.parent)) return - const siblings = node.parent.properties.map((prop: any) => isIdentifier(prop.key) && prop.key.name) - sendReport(node.key, cmp, siblings) + const siblings = node.parent.properties + .filter((prop): prop is TSESTree.Property => prop.type === 'Property') + .map((prop) => (isIdentifier(prop.key) ? prop.key.name : '')) + + sendReport(node.key, composite, siblings) }, } }, diff --git a/plugin/src/utils/helpers.ts b/plugin/src/utils/helpers.ts index 62774d9..dd726ba 100644 --- a/plugin/src/utils/helpers.ts +++ b/plugin/src/utils/helpers.ts @@ -47,7 +47,6 @@ export const getImportSpecifiers = (context: RuleContext) => { node.specifiers.forEach((specifier) => { if (!isImportSpecifier(specifier)) return - specifiers.push({ specifier, mod }) }) }) @@ -77,13 +76,20 @@ const _getImports = (context: RuleContext) => { return imports } +// Caching imports per context to avoid redundant computations +const importsCache = new WeakMap, ImportResult[]>() + export const getImports = (context: RuleContext) => { + if (importsCache.has(context)) { + return importsCache.get(context)! + } const imports = _getImports(context) - return imports.filter((imp) => syncAction('matchImports', getSyncOpts(context), imp)) + const filteredImports = imports.filter((imp) => syncAction('matchImports', getSyncOpts(context), imp)) + importsCache.set(context, filteredImports) + return filteredImports } -const isValidStyledProp = (node: T, context: RuleContext) => { - if (typeof node === 'string') return +const isValidStyledProp = (node: T, context: RuleContext) => { return isJSXIdentifier(node) && isValidProperty(node.name, context) } @@ -103,7 +109,8 @@ const findDeclaration = (name: string, context: RuleContext) => { ?.defs.find((d) => isIdentifier(d.name) && d.name.name === name)?.node if (isVariableDeclarator(decl)) return decl } catch (error) { - return + console.error('Error in findDeclaration:', error) + return undefined } } @@ -123,10 +130,6 @@ export const isValidFile = (context: RuleContext) => { return syncAction('isValidFile', getSyncOpts(context)) } -export const isConfigFile = (context: RuleContext) => { - return syncAction('isConfigFile', getSyncOpts(context)) -} - export const isValidProperty = (name: string, context: RuleContext, calleName?: string) => { return syncAction('isValidProperty', getSyncOpts(context), name, calleName) } @@ -198,6 +201,7 @@ export const isInJSXProp = (node: TSESTree.Property, context: RuleContext() + export const extractTokens = (value: string) => { const regex = /token\(([^"'(),]+)(?:,\s*([^"'(),]+))?\)|\{([^{}]+)\}/g const matches = [] @@ -255,9 +262,16 @@ export const extractTokens = (value: string) => { } export const getInvalidTokens = (value: string, context: RuleContext) => { + if (invalidTokensCache.has(value)) { + return invalidTokensCache.get(value)! + } + const tokens = extractTokens(value) if (!tokens.length) return [] - return syncAction('filterInvalidTokens', getSyncOpts(context), tokens) + + const invalidTokens = syncAction('filterInvalidTokens', getSyncOpts(context), tokens) + invalidTokensCache.set(value, invalidTokens) + return invalidTokens } export const getTokenImport = (context: RuleContext) => { diff --git a/plugin/src/utils/index.ts b/plugin/src/utils/index.ts index 86c1f79..312071b 100644 --- a/plugin/src/utils/index.ts +++ b/plugin/src/utils/index.ts @@ -4,25 +4,30 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { run } from './worker' -export const createRule: ReturnType<(typeof ESLintUtils)['RuleCreator']> = ESLintUtils.RuleCreator( +// Rule creator +export const createRule = ESLintUtils.RuleCreator( (name) => `https://github.com/chakra-ui/eslint-plugin-panda/blob/main/docs/rules/${name}.md`, ) -export type Rule = ReturnType> +// Define Rule type explicitly +export type Rule = ReturnType +// Determine the distribution directory const isBase = process.env.NODE_ENV !== 'test' || import.meta.url.endsWith('dist/index.js') export const distDir = fileURLToPath(new URL(isBase ? './' : '../../dist', import.meta.url)) +// Create synchronous function using synckit export const _syncAction = createSyncFn(join(distDir, 'utils/worker.mjs')) -// export const _syncAction = createSyncFn(join(distDir, 'utils/worker.mjs')) as typeof run -export const syncAction = ((...args: any) => { +// Define syncAction with proper typing and error handling +export const syncAction = ((...args: Parameters) => { try { return _syncAction(...args) } catch (error) { - return + console.error('syncAction error:', error) + return undefined } -}) as typeof run | ((...args: any) => undefined) +}) as typeof run export interface ImportResult { name: string diff --git a/plugin/src/utils/nodes.ts b/plugin/src/utils/nodes.ts index 2df2b05..cf9f0c0 100644 --- a/plugin/src/utils/nodes.ts +++ b/plugin/src/utils/nodes.ts @@ -1,40 +1,23 @@ import type { TSESTree } from '@typescript-eslint/utils' - import { isNodeOfType } from '@typescript-eslint/utils/ast-utils' import { AST_NODE_TYPES } from '@typescript-eslint/utils' export type Node = TSESTree.Node export const isIdentifier = isNodeOfType(AST_NODE_TYPES.Identifier) - export const isLiteral = isNodeOfType(AST_NODE_TYPES.Literal) - export const isTemplateLiteral = isNodeOfType(AST_NODE_TYPES.TemplateLiteral) - export const isArrayExpression = isNodeOfType(AST_NODE_TYPES.ArrayExpression) - export const isObjectExpression = isNodeOfType(AST_NODE_TYPES.ObjectExpression) - export const isMemberExpression = isNodeOfType(AST_NODE_TYPES.MemberExpression) - export const isVariableDeclarator = isNodeOfType(AST_NODE_TYPES.VariableDeclarator) - export const isVariableDeclaration = isNodeOfType(AST_NODE_TYPES.VariableDeclaration) - export const isJSXMemberExpression = isNodeOfType(AST_NODE_TYPES.JSXMemberExpression) - export const isJSXOpeningElement = isNodeOfType(AST_NODE_TYPES.JSXOpeningElement) - export const isJSXExpressionContainer = isNodeOfType(AST_NODE_TYPES.JSXExpressionContainer) - export const isJSXAttribute = isNodeOfType(AST_NODE_TYPES.JSXAttribute) - export const isJSXIdentifier = isNodeOfType(AST_NODE_TYPES.JSXIdentifier) - export const isCallExpression = isNodeOfType(AST_NODE_TYPES.CallExpression) - export const isImportDeclaration = isNodeOfType(AST_NODE_TYPES.ImportDeclaration) - export const isImportSpecifier = isNodeOfType(AST_NODE_TYPES.ImportSpecifier) - export const isProperty = isNodeOfType(AST_NODE_TYPES.Property) diff --git a/plugin/src/utils/worker.ts b/plugin/src/utils/worker.ts index b2fa48f..10c9311 100644 --- a/plugin/src/utils/worker.ts +++ b/plugin/src/utils/worker.ts @@ -6,8 +6,13 @@ import { findConfig } from '@pandacss/config' import path from 'path' import type { ImportResult } from '.' -let promise: Promise | undefined +type Opts = { + currentFile: string + configPath?: string +} + let configPath: string | undefined +let contextCache: { [configPath: string]: Promise } = {} async function _getContext(configPath: string | undefined) { if (!configPath) throw new Error('Invalid config path') @@ -26,8 +31,13 @@ export async function getContext(opts: Opts) { return ctx } else { configPath = configPath || findConfig({ cwd: opts.configPath ?? opts.currentFile }) - promise = promise || _getContext(configPath) - return await promise + + // Ensure that the context is refreshed when the configPath changes. + if (!contextCache[configPath]) { + contextCache[configPath] = _getContext(configPath) + } + + return await contextCache[configPath] } } @@ -53,10 +63,6 @@ const arePathsEqual = (path1: string, path2: string) => { return normalizedPath1 === normalizedPath2 } -async function isConfigFile(fileName: string): Promise { - return arePathsEqual(configPath!, fileName) -} - async function isValidFile(ctx: PandaContext, fileName: string): Promise { return ctx.getFiles().some((file) => arePathsEqual(file, fileName)) } @@ -106,15 +112,9 @@ async function matchImports(ctx: PandaContext, result: MatchImportResult) { }) } -type Opts = { - currentFile: string - configPath?: string -} - export function runAsync(action: 'filterInvalidTokens', opts: Opts, paths: string[]): Promise export function runAsync(action: 'isColorToken', opts: Opts, value: string): Promise export function runAsync(action: 'isColorAttribute', opts: Opts, attr: string): Promise -export function runAsync(action: 'isConfigFile', opts: Opts, fileName: string): Promise export function runAsync(action: 'isValidFile', opts: Opts, fileName: string): Promise export function runAsync(action: 'resolveShorthands', opts: Opts, name: string): Promise export function runAsync(action: 'resolveLongHand', opts: Opts, name: string): Promise @@ -140,8 +140,6 @@ export async function runAsync(action: string, opts: Opts, ...args: any): Promis case 'resolveShorthands': // @ts-expect-error cast return resolveShorthands(ctx, ...args) - case 'isConfigFile': - return isConfigFile(opts.currentFile) case 'isValidFile': return isValidFile(ctx, opts.currentFile) case 'isColorAttribute': @@ -159,7 +157,6 @@ export async function runAsync(action: string, opts: Opts, ...args: any): Promis export function run(action: 'filterInvalidTokens', opts: Opts, paths: string[]): string[] export function run(action: 'isColorToken', opts: Opts, value: string): boolean export function run(action: 'isColorAttribute', opts: Opts, attr: string): boolean -export function run(action: 'isConfigFile', opts: Opts): boolean export function run(action: 'isValidFile', opts: Opts): boolean export function run(action: 'resolveShorthands', opts: Opts, name: string): string[] | undefined export function run(action: 'resolveLongHand', opts: Opts, name: string): string | undefined diff --git a/plugin/tests/file-not-included.test.ts b/plugin/tests/file-not-included.test.ts index da4561c..dc39b06 100644 --- a/plugin/tests/file-not-included.test.ts +++ b/plugin/tests/file-not-included.test.ts @@ -28,7 +28,7 @@ tester.run(RULE_NAME, rule, { { code: invalidCode, filename: 'Invalid.tsx', - errors: 2, + errors: 1, }, ], }) From 121ad0f933c47ea603fed0ee3afedfdfbf452c2f Mon Sep 17 00:00:00 2001 From: anubra266 Date: Mon, 7 Oct 2024 22:28:11 -0500 Subject: [PATCH 2/2] chore: add changeset --- .changeset/wise-seas-beg.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wise-seas-beg.md diff --git a/.changeset/wise-seas-beg.md b/.changeset/wise-seas-beg.md new file mode 100644 index 0000000..6e2b42b --- /dev/null +++ b/.changeset/wise-seas-beg.md @@ -0,0 +1,5 @@ +--- +'@pandacss/eslint-plugin': minor +--- + +Use memoization in rules