diff --git a/src/rules/classnames-order.ts b/src/rules/classnames-order.ts index be04b72..de08ca1 100644 --- a/src/rules/classnames-order.ts +++ b/src/rules/classnames-order.ts @@ -5,35 +5,23 @@ import { TSESTree } from "@typescript-eslint/utils"; import { RuleCreator } from "@typescript-eslint/utils/eslint-utils"; -import { RuleFunction } from "@typescript-eslint/utils/ts-eslint"; -import { AST as VueAST } from "vue-eslint-parser"; +import { RuleContext as TSESLintRuleContext } from "@typescript-eslint/utils/ts-eslint"; -import type { - ScriptVisitor, - SupportedChildNode, - SupportedNode, - TemplateVisitor, - TextAttribute, - ValueSupportedNode, -} from "../types"; import urlCreator from "../url-creator"; -import { parsePluginSettings } from "../utils/parse-plugin-settings"; +import { + parsePluginSettings, + PluginSettings, +} from "../utils/parse-plugin-settings"; import { getClassnamesFromValue, - getRangeFromNode, - getTagNameFromTaggedTemplateExpression, getTemplateElementAffixes, - getValueFromNodeAtom, } from "../utils/parser/node"; import { defineVisitors, GenericRuleContext } from "../utils/parser/visitors"; import { - isLiteralAttributeValue, - isValidCallExpression, - isValidExpressionAttributeValue, - isValidJSXAttribute, - isValidTextAttribute, - isValidVAttribute, -} from "../utils/parser/visitors-validation"; + AtomicNode, + createScriptVisitors, + createTemplateVisitors, +} from "../utils/rule"; import { getSortedClassNamesWorker } from "../utils/tailwindcss-api"; export { ESLintUtils } from "@typescript-eslint/utils"; @@ -48,10 +36,107 @@ export type RuleOptions = {}; type Options = [RuleOptions]; +type RuleContext = TSESLintRuleContext; + // The Rule creator returns a function that is used to create a well-typed ESLint rule // The parameter passed into RuleCreator is a URL generator function. export const createRule = RuleCreator(urlCreator); +const sortClassnames = ( + context: RuleContext, + settings: PluginSettings, + literals: Array +) => { + for (const node of literals) { + let originalClassNamesValue = ""; + let start = 0; + let end = 0; + let prefix = ""; + let suffix = ""; + switch (node.type) { + case TSESTree.AST_NODE_TYPES.Literal: { + originalClassNamesValue = "" + node.value; + [start, end] = node.range; + start++; + end--; + break; + } + case TSESTree.AST_NODE_TYPES.TemplateElement: { + originalClassNamesValue = node.value.raw; + if (originalClassNamesValue === "") { + break; + } + [start, end] = node.range; + // https://github.com/eslint/eslint/issues/13360 + // The problem is that range computation includes the backticks (`test`) + // but `value.raw` does not include them, so there is a mismatch. + // start/end does not include the backticks, therefore it matches value.raw. + const rawCode = context.sourceCode.getText( + node as unknown as TSESTree.Node + ); + [prefix, suffix] = getTemplateElementAffixes( + rawCode, + originalClassNamesValue + ); + break; + } + case "TextAttribute": { + originalClassNamesValue = node.value; + start = node.valueSpan.fullStart.offset; + end = node.valueSpan.end.offset; + break; + } + case "VLiteral": { + originalClassNamesValue = "" + node.value; + [start, end] = node.range; + start++; + end--; + break; + } + default: { + // console.log(index, "Unhandled literal type", literal.type); + break; + } + } + // Process the extracted classnames and report + { + const { classNames, whitespaces, headSpace, tailSpace } = + getClassnamesFromValue(originalClassNamesValue); + // Skip empty/Single className + if (classNames.length <= 1) continue; + const orderedClassNames = getSortedClassNamesWorker( + settings.cssConfigPath, + classNames + ); + + // Generates the validated/sorted attribute value + let validatedClassNamesValue = ""; + for (let index = 0; index < orderedClassNames.length; index++) { + const w = whitespaces[index] ?? ""; + const cls = orderedClassNames[index]; + validatedClassNamesValue += headSpace ? `${w}${cls}` : `${cls}${w}`; + if (headSpace && tailSpace && index === orderedClassNames.length - 1) { + validatedClassNamesValue += whitespaces.at(-1) ?? ""; + } + } + + if (originalClassNamesValue !== validatedClassNamesValue) { + validatedClassNamesValue = prefix + validatedClassNamesValue + suffix; + context.report({ + node: node as TSESTree.Node, + messageId: "fix:sort", + fix: function (fixer) { + return fixer.replaceTextRange( + [start, end], + validatedClassNamesValue + ); + }, + }); + } + } + } +}; + export const classnamesOrder = createRule({ name: RULE_NAME, meta: { @@ -82,308 +167,16 @@ export const classnamesOrder = createRule({ * - In other words, the `defaultOptions` is only used when the rule is used WITHOUT any configuration */ defaultOptions: [{}], - // create: (context, options) - create: (context) => { + create: (context, options) => { // Merged settings const settings = parsePluginSettings(context.settings); - /** - * Recursive function crawling into child nodes - * @param node The root node of the current parsing (JSXAttribute) - * @param child The child node of the root node - * @returns {void} - */ - const sortNodeArgumentValue = ( - node: SupportedNode, - child?: SupportedChildNode - ) => { - let originalClassNamesValue = ""; - let start: number; - let end: number; - let prefix = ""; - let suffix = ""; - if (child === undefined) { - // Simple case: the node is a JSXAttribute or TextAttribute or VueAST.VAttribute - // and may not need to be called recursively - originalClassNamesValue = getValueFromNodeAtom( - node as ValueSupportedNode - ); - [start, end] = getRangeFromNode(node as ValueSupportedNode); - } else if (child !== undefined) { - switch (child.type) { - case "TemplateLiteral": { - for (const exp of child.expressions) { - sortNodeArgumentValue(node, exp); // ↩️ - } - for (const quasis of child.quasis) { - sortNodeArgumentValue(node, quasis); // ↩️ - } - return; - } - case "ConditionalExpression": { - sortNodeArgumentValue(node, child.consequent); // ↩️ - sortNodeArgumentValue(node, child.alternate); // ↩️ - return; - } - case "LogicalExpression": { - sortNodeArgumentValue(node, child.right); // ↩️ - return; - } - case "ArrayExpression": { - for (const element of child.elements) { - sortNodeArgumentValue(node, element); // ↩️ - } - return; - } - case "ObjectExpression": { - // e.g. `{ 'bg-active': isActive }` - // `classnames({ 'bg-active': isActive })` - const isUsedByClassNamesPlugin = - node.type === "CallExpression" && - node.callee && - node.callee.type === "Identifier" && - node.callee.name === "classnames"; - const isVue = - node.type === "VAttribute" && - node.key && - // @ts-expect-error This comparison appears to be unintentional because the types '"VIdentifier"' and '"VDirectiveKey"' have no overlap.ts(2367) - node.key.type === "VDirectiveKey"; - for (const property of child.properties) { - if (property.type === "SpreadElement") { - /**/ - } - if (property.type === "Property") { - /**/ - } - if (property.type === "SpreadProperty") { - /**/ - } - if (property.type === "ExperimentalSpreadProperty") { - /**/ - } - if (property.type !== "Property") return; - const propertyValue = - isUsedByClassNamesPlugin || isVue - ? property.key - : property.value; - // @ts-expect-error Type 'ESLintObjectPattern' is not assignable to type 'SupportedChildNode | undefined'. - sortNodeArgumentValue(node, propertyValue); // ↩️ - } - return; - } - case "Property": { - sortNodeArgumentValue(node, child.key); // ↩️ - break; - } - - case "Literal": { - originalClassNamesValue = "" + child.value; - start = child.range[0] + 1; - end = child.range[1] - 1; - break; - } - case "TemplateElement": { - originalClassNamesValue = child.value.raw; - if (originalClassNamesValue === "") { - return; - } - [start, end] = child.range; - // https://github.com/eslint/eslint/issues/13360 - // The problem is that range computation includes the backticks (`test`) - // but `value.raw` does not include them, so there is a mismatch. - // start/end does not include the backticks, therefore it matches value.raw. - const rawCode = context.sourceCode.getText( - child as unknown as TSESTree.Node - ); - [prefix, suffix] = getTemplateElementAffixes( - rawCode, - originalClassNamesValue - ); - break; - } - default: - // console.log("child.type: " + child.type); - } - } - // Process the extracted classnames and report - { - const { classNames, whitespaces, headSpace, tailSpace } = - getClassnamesFromValue(originalClassNamesValue); - // Skip empty/Single className - if (classNames.length <= 1) return; - - const orderedClassNames = getSortedClassNamesWorker( - settings.cssConfigPath, - classNames - ); - - // Generates the validated/sorted attribute value - let validatedClassNamesValue = ""; - for (let index = 0; index < orderedClassNames.length; index++) { - const w = whitespaces[index] ?? ""; - const cls = orderedClassNames[index]; - validatedClassNamesValue += headSpace ? `${w}${cls}` : `${cls}${w}`; - if ( - headSpace && - tailSpace && - index === orderedClassNames.length - 1 - ) { - validatedClassNamesValue += whitespaces.at(-1) ?? ""; - } - } - - if (originalClassNamesValue !== validatedClassNamesValue) { - validatedClassNamesValue = prefix + validatedClassNamesValue + suffix; - context.report({ - node: node as TSESTree.Node, - messageId: "fix:sort", - fix: function (fixer) { - return fixer.replaceTextRange( - [start, end], - validatedClassNamesValue - ); - }, - }); - } - } - return; - }; - - /** - * Visitor used for both JSX and simple text attributes - */ - const attributeVisitor: RuleFunction = (node) => { - if (!isValidJSXAttribute(node, settings)) return; - if (isLiteralAttributeValue(node)) { - sortNodeArgumentValue(node); // 🏁 - } - if (isValidExpressionAttributeValue(node)) { - // @ts-expect-error Property 'expression' does not exist on type. ts(2339) - sortNodeArgumentValue(node, node.value.expression); // 🏁 - } - }; - - // @ts-expect-error Type 'TextAttribute' does not satisfy the constraint 'NodeOrTokenData'. - const textAttributeVisitor: RuleFunction = (node) => { - if (!isValidTextAttribute(node, settings)) return; - if (isLiteralAttributeValue(node)) { - sortNodeArgumentValue(node); // 🏁 - } - }; - - /** - * Visitor for VAttribute within Vue SFC's `