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 ``
- * @param node
- * @returns
- */
- const vAttributeVisitor: RuleFunction = (node) => {
- if (!isValidVAttribute(node, settings)) return;
- if (node.value?.type === "VLiteral") {
- sortNodeArgumentValue(node); // 🏁
- } else if (node.value?.type === "VExpressionContainer") {
- const expressionContainer =
- node.value as unknown as VueAST.VExpressionContainer;
- switch (expressionContainer.expression?.type) {
- case "Literal": {
- sortNodeArgumentValue(node, expressionContainer.expression); // 🏁
- break;
- }
- case "CallExpression": {
- if (
- // @ts-expect-error Argument of type 'ESLintCallExpression' is not assignable to parameter of type 'CallExpression'.ts(2345)
- isValidCallExpression(expressionContainer.expression, settings)
- ) {
- for (const argument of expressionContainer.expression.arguments) {
- sortNodeArgumentValue(node, argument as SupportedNode); // 🏁
- }
- }
- break;
- }
- case "ArrayExpression": {
- for (const argument of expressionContainer.expression.elements) {
- sortNodeArgumentValue(node, argument); // 🏁
- }
- break;
- }
- case "ObjectExpression": {
- for (const property of expressionContainer.expression.properties) {
- if (property.type === "Property") {
- sortNodeArgumentValue(node, property); // 🏁
- }
- }
- break;
- }
- }
- }
- };
-
- const callExpressionVisitor: RuleFunction = (
- node
- ) => {
- if (isValidCallExpression(node, settings)) {
- for (const argument of node.arguments) {
- sortNodeArgumentValue(node, argument as SupportedNode); // 🏁
- }
- }
- };
-
- const taggedTemplateExpressionVisitor: RuleFunction<
- TSESTree.TaggedTemplateExpression
- > = (node) => {
- if (!settings.functions || settings.functions.length === 0) return;
- const tagName = getTagNameFromTaggedTemplateExpression(node);
- if (!settings.functions.includes(tagName)) return;
- sortNodeArgumentValue(node, node.quasi); // 🏁
- };
-
- /**
- * Script visitor is used within both JSX and Vue SFC files (inside
- * CallExpression declared in the script section of a Vue SFC
- */
- CallExpression: callExpressionVisitor,
- /**
- * Only the JSXAttributes
- * @example
- * JSXAttributes
- */
- JSXAttribute: attributeVisitor,
- /**
- * In JSX + inside
+ * CallExpression declared in the script section of a Vue SFC
+ */
+ CallExpression(node: TSESTree.Node) {
+ const callExpressionNode = node as TSESTree.CallExpression;
+
+ const literals = getLiteralsFromNode(
+ settings,
+ context,
+ callExpressionNode,
+ callExpressionNode,
+ 0
+ );
+ lintLiterals(context, settings, literals);
+ },
+
+ /**
+ * Only the JSXAttributes
+ * @example
+ * JSXAttributes
+ */
+ JSXAttribute(node: TSESTree.Node) {
+ const jsxAttributeNode = node as TSESTree.JSXAttribute;
+
+ const literals = getLiteralsFromNode(
+ settings,
+ context,
+ jsxAttributeNode,
+ jsxAttributeNode,
+ 0
+ );
+ lintLiterals(context, settings, literals);
+ },
+
+ /**
+ * In JSX + inside section of Vue SFC…
+ * @example
+ * const classes = tw`flex`;
+ */
+ TaggedTemplateExpression(node: TSESTree.Node) {
+ if (!settings.functions || settings.functions.length === 0) return;
+ const taggedTemplateExpressionNode =
+ node as TSESTree.TaggedTemplateExpression;
+ const tagName = getTagNameFromTaggedTemplateExpression(
+ taggedTemplateExpressionNode
+ );
+ if (!settings.functions.includes(tagName)) return;
+
+ const literals = getLiteralsFromNode(
+ settings,
+ context,
+ taggedTemplateExpressionNode,
+ taggedTemplateExpressionNode,
+ 0
+ );
+ lintLiterals(context, settings, literals);
+ },
+
+ /**
+ * Useful for regular HTML (non JSX)
+ * @example
+ * TextAttribute
+ */
+ TextAttribute(node: TSESTree.Node) {
+ const textAttributeNode = node as unknown as TextAttribute;
+ if (!isValidTextAttribute(textAttributeNode, settings)) return;
+ const literals = [textAttributeNode];
+ lintLiterals(context, settings, literals);
+ },
+ };
+};
+
+/**
+ * Generate a list of RuleListeners
+ * @example // Returns
+ * {
+ * CallExpression: (node: TSESTree.Node) => void;
+ * ...
+ * JSXAttribute: (node: TSESTree.Node) => void;
+ * }
+ */
+export const createTemplateVisitors = (
+ context: TRuleContext,
+ settings: PluginSettings,
+ // @ts-expect-error 'options' is declared but its value is never read.ts(6133)
+ options: TOptions,
+ lintLiterals: (
+ context: TRuleContext,
+ settings: PluginSettings,
+ literals: Array
+ ) => void
+): RuleListener => {
+ return {
+ /**
+ * Visitor for VAttribute within Vue SFC's ``
+ * @example
+ *
+ *
+ *
+ */
+ VAttribute(node: VueAST.VAttribute) {
+ if (!isValidVAttribute(node, settings)) return;
+ if (node.value?.type === "VLiteral") {
+ lintLiterals(context, settings, [node.value]);
+ }
+ // @ts-expect-error Types have no overlap.ts(2367)
+ if (node.value?.type !== "VExpressionContainer") return;
+
+ const container = node.value as unknown as VueAST.VExpressionContainer;
+ const expression = container.expression;
+ if (!expression) return;
+ const expressionType = container.expression?.type || "";
+ if (!expressionType) return;
+ if (
+ ![
+ "ArrayExpression",
+ "CallExpression",
+ "Literal",
+ "ObjectExpression",
+ ].includes(expressionType)
+ )
+ return;
+ const current = expression as TSESTree.Node;
+ const literals: Array = [];
+ switch (expressionType) {
+ case "ArrayExpression":
+ case "CallExpression":
+ case "Literal": {
+ literals.push(
+ ...getLiteralsFromNode(settings, context, current, node, 0)
+ );
+ break;
+ }
+ case "ObjectExpression": {
+ /**
+ * In Vue classnames in ObjectExpression are stored in the keys
+ * @example
+ * vue
+ */
+ if ("properties" in expression) {
+ for (const property of expression.properties) {
+ if (property.type === "Property") {
+ const key = property.key as TSESTree.Node;
+ literals.push(
+ ...getLiteralsFromNode(settings, context, key, node, 0)
+ );
+ }
+ }
+ }
+ break;
+ }
+ }
+ lintLiterals(context, settings, literals);
+ },
+ };
+};