8000 feat(rule): classnames-order by francoismassart · Pull Request #417 · francoismassart/eslint-plugin-tailwindcss · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
more tests
  • Loading branch information
francoismassart committed Aug 18, 2025
commit 1ea030894940ea65a1719ca215472ae035e21f13
24 changes: 12 additions & 12 deletion 8000 s src/rules/classnames-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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 {
import type {
ScriptVisitor,
SupportedChildNode,
SupportedNode,
Expand All @@ -19,22 +19,22 @@ import {
import urlCreator from "../url-creator";
import { parsePluginSettings } from "../utils/parse-plugin-settings";
import {
extractClassnamesFromValue,
extractRangeFromNode,
extractValueFromNode,
getClassnamesFromValue,
getRangeFromNode,
getTagNameFromTaggedTemplateExpression,
getTemplateElementAffixes,
isExpressionAttributeValue,
isLiteralAttributeValue,
getValueFromNodeAtom,
} from "../utils/parser/node";
import { defineVisitors, GenericRuleContext } from "../utils/parser/visitors";
import { getSortedClassNamesWorker } from "../utils/tailwindcss-api";
import {
isLiteralAttributeValue,
isValidCallExpression,
isValidExpressionAttributeValue,
isValidJSXAttribute,
isValidTextAttribute,
isValidVAttribute,
} from "../utils/visitors-validation";
} from "../utils/parser/visitors-validation";
import { getSortedClassNamesWorker } from "../utils/tailwindcss-api";

export { ESLintUtils } from "@typescript-eslint/utils";

Expand Down Expand Up @@ -104,10 +104,10 @@ export const classnamesOrder = createRule<Options, MessageIds>({
let suffix = "";
if (child === undefined) {
// Simple case: the node is a JSXAttribute or TextAttribute or VueAST.VAttribute
originalClassNamesValue = extractValueFromNode(
originalClassNamesValue = getValueFromNodeAtom(
node as ValueSupportedNode
);
const range = extractRangeFromNode(node as ValueSupportedNode);
const range = getRangeFromNode(node as ValueSupportedNode);
if (node.type === "TextAttribute") {
[start, end] = range;
} else {
Expand Down Expand Up @@ -215,7 +215,7 @@ export const classnamesOrder = createRule<Options, MessageIds>({
// Process the extracted classnames and report
{
const { classNames, whitespaces, headSpace, tailSpace } =
extractClassnamesFromValue(originalClassNamesValue);
getClassnamesFromValue(originalClassNamesValue);
// Skip empty/Single className
if (classNames.length <= 1) return;

Expand Down Expand Up @@ -264,7 +264,7 @@ export const classnamesOrder = createRule<Options, MessageIds>({
if (isLiteralAttributeValue(node)) {
sortNodeArgumentValue(node);
}
if (isExpressionAttributeValue(node)) {
if (isValidExpressionAttributeValue(node)) {
// @ts-expect-error Property 'expression' does not exist on type. ts(2339)
sortNodeArgumentValue(node, node.value.expression);
}
8000 Expand Down
214 changes: 137 additions & 77 deletions src/utils/parser/node.spec.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,149 @@
import * as AngularParser from "@angular-eslint/template-parser";
import * as Parser from "@typescript-eslint/parser";
import { TSESTree } from "@typescript-eslint/utils";
import { expect, test } from "vitest";

import { GenericElement, TextAttribute } from "../../types";
import { extractValueFromNode, isLiteralAttributeValue } from "./node";
import {
getClassnamesFromValue,
getJSXAttributeName,
getRangeFromNode,
getTagNameFromTaggedTemplateExpression,
getTemplateElementAffixes,
getValueFromNodeAtom,
getVAttributeName,
} from "./node";
import {
getFirstHTMLOpeningElement,
getFirstJSXOpeningElement,
getHTMLAttribute,
getJSXAttribute,
htmlAttribute,
jsxAttribute,
vAttribute,
} from "./test-helpers";

const withJSX = {
ecmaFeatures: { jsx: true },
};
test("getValueFromNodeAtom", () => {
[
// No value
[htmlAttribute(`<h1 class>html</h1>`), ""],
// Normal case: "TextAttribute" via AngularParser (HTML)
< 8000 span class='blob-code-inner blob-code-marker ' data-code-marker="+"> [htmlAttribute(`<h1 class="flex">html</h1>`), "flex"],
// JSXLiteral
[jsxAttribute(`<h1 class="m-0 flex">jsx</h1>`), "m-0 flex"],
// JSXExpressionContainer
[jsxAttribute(`<h1 className={'block'}>jsx</h1>`), "block"],
].map(([input, expected]) => {
// @ts-expect-error Argument of type 'string | TextAttribute' is not assignable to parameter of type 'ValueSupportedNode'.
expect(getValueFromNodeAtom(input)).toBe(expected);
});
});

const getFirstHTMLOpeningElement = (code: string) => {
const program = AngularParser.parse(code, { filePath: "node.spec.ts" });
const node = program.templateNodes.at(0);
if (node === undefined) {
throw new Error("No TemplateNode found");
}
return node;
};
test("getClassnamesFromValue", () => {
expect(getClassnamesFromValue(`flex grow`)).toStrictEqual({
classNames: ["flex", "grow"],
whitespaces: [" "],
headSpace: false,
tailSpace: false,
});
expect(getClassnamesFromValue(` flex grow`)).toStrictEqual({
classNames: ["flex", "grow"],
whitespaces: [" ", " "],
headSpace: true,
tailSpace: false,
});
expect(getClassnamesFromValue(` flex grow `)).toStrictEqual({
classNames: ["flex", "grow"],
whitespaces: [" ", " ", " "],
headSpace: true,
tailSpace: true,
});
expect(getClassnamesFromValue(`flex grow `)).toStrictEqual({
classNames: ["flex", "grow"],
whitespaces: [" ", " "],
headSpace: false,
tailSpace: true,
});
});

const getHTMLAttribute = (node: GenericElement): TextAttribute => {
const htmlAttribute = node.attributes.at(0);
if (htmlAttribute === undefined) {
throw new Error("No HTMLAttribute found");
}
return htmlAttribute;
};
test("getRangeFromNode", () => {
// No value
const emptyHtml = getFirstHTMLOpeningElement(`<h1 hidden>html</h1>`);
const emptyAttribute = getHTMLAttribute(emptyHtml);
expect(getRangeFromNode(emptyAttribute)).toStrictEqual([0, 0]);
// Normal case: "TextAttribute" via AngularParser (HTML)
const simpleHtml = getFirstHTMLOpeningElement(`<h1 class="flex">html</h1>`);
const textAttribute = getHTMLAttribute(simpleHtml);
expect(getRangeFromNode(textAttribute)).toStrictEqual([
`<h1 class="`.length,
`<h1 class="flex`.length,
]);
// JSX attribute
const jsx = getFirstJSXOpeningElement(`<h1 class={'flex'}>html</h1>`);
const jsxAttribute = getJSXAttribute(jsx);
expect(getRangeFromNode(jsxAttribute)).toStrictEqual([
`<h1 class={`.length,
`<h1 class={'flex'`.length,
]);
// default
const defaultJsx = getFirstJSXOpeningElement(`<h1 class="flex">html</h1>`);
const defaultJsxAttribute = getJSXAttribute(defaultJsx);
expect(getRangeFromNode(defaultJsxAttribute)).toStrictEqual([
`<h1 class=`.length,
`<h1 class="flex'`.length,
]);
});

const getFirstJSXOpeningElement = (code: string) => {
const program = Parser.parse(code, withJSX);
const body = program.body.at(0);
if (body === undefined) {
throw new Error("No ProgramStatement found");
}
if (body.type !== TSESTree.AST_NODE_TYPES.ExpressionStatement) {
throw new Error("No ExpressionStatement found");
}
if (body.expression.type !== TSESTree.AST_NODE_TYPES.JSXElement) {
throw new Error("No JSXElement found");
}
return body.expression.openingElement;
};
test("getTemplateElementAffixes", () => {
expect(
getTemplateElementAffixes("`relative grid`", "relative grid")
).toStrictEqual(["`", "`"]);
expect(getTemplateElementAffixes("`absolute ${", "absolute ")).toStrictEqual([
"`",
"${",
]);
expect(getTemplateElementAffixes("`} ${", " ")).toStrictEqual(["`}", "${"]);
});

const getJSXAttribute = (node: TSESTree.JSXOpeningElement) => {
const jsxAttribute = node.attributes.at(0);
if (jsxAttribute === undefined) throw new Error("No JSXAttribute found");
if (jsxAttribute.type === TSESTree.AST_NODE_TYPES.JSXSpreadAttribute)
throw new Error("Unsupported JSXSpreadAttribute found");
return jsxAttribute;
};
test("getTagNameFromTaggedTemplateExpression", () => {
const attribute = jsxAttribute("<h1 className={tw`flex`}>html</h1>");
if (
attribute.value?.type === TSESTree.AST_NODE_TYPES.JSXExpressionContainer &&
attribute.value.expression.type ===
TSESTree.AST_NODE_TYPES.TaggedTemplateExpression
) {
expect(
getTagNameFromTaggedTemplateExpression(attribute.value.expression)
).toStrictEqual("tw");
} else {
throw new Error(
"Invalid attribute value for `getTagNameFromTaggedTemplateExpression`"
);
}
});

test("isLiteralAttributeValue", () => {
// Normal case: "TextAttribute" via AngularParser (HTML)
const html = getFirstHTMLOpeningElement(`<h1 class="flex">normal</h1>`);
const textAttribute = getHTMLAttribute(html);
expect(isLiteralAttributeValue(textAttribute)).toBe(true);
// No value case via AngularParser (HTML)
const hidden = getFirstHTMLOpeningElement(`<h1 hidden>no value</h1>`);
const hiddenAttribute = getHTMLAttribute(hidden);
expect(isLiteralAttributeValue(hiddenAttribute)).toBe(false);
// Normal case (JSX)
const jsx = getFirstJSXOpeningElement(`<h1 className="flex">normal jsx</h1>`);
const jsxAttribute = getJSXAttribute(jsx);
expect(isLiteralAttributeValue(jsxAttribute)).toBe(true);
// No value case (JSX)
const hiddenJsx = getFirstJSXOpeningElement(`<h1 hidden>hidden jsx</h1>`);
const jsxHiddenAttribute = getJSXAttribute(hiddenJsx);
expect(isLiteralAttributeValue(jsxHiddenAttribute)).toBe(false);
// CallExpression (JSX)
const callJsx = getFirstJSXOpeningElement(`<h1 class={ctl('flex')}>ctl</h1>`);
const callAttribute = getJSXAttribute(callJsx);
expect(isLiteralAttributeValue(callAttribute)).toBe(false);
test("getJSXAttributeName", () => {
[
// JSXLiteral
[jsxAttribute(`<h1 class="m-0 flex">jsx</h1>`), "class"],
// JSXExpressionContainer
[jsxAttribute(`<h1 className={'block'}>jsx</h1>`), "className"],
// JSXNamespacedName
[jsxAttribute(`<h1 ns:className={'block'}>jsx</h1>`), "ns:className"],
].map(([input, expected]) => {
// @ts-expect-error Argument of type 'string | JSXAttribute' is not assignable to parameter of type 'JSXAttribute'.
expect(getJSXAttributeName(input)).toBe(expected);
});
});

test("extractValueFromNode", () => {
// Normal case: "TextAttribute" via AngularParser (HTML)
const html = getFirstHTMLOpeningElement(`<h1 class="flex">html</h1>`);
const textAttribute = getHTMLAttribute(html);
expect(extractValueFromNode(textAttribute)).toBe("flex");
// JSXLiteral
const jsx = getFirstJSXOpeningElement(`<h1 class="m-0 flex">jsx</h1>`);
const attribute = getJSXAttribute(jsx);
expect(extractValueFromNode(attribute)).toBe("m-0 flex");
// JSXExpressionContainer
const literal = getFirstJSXOpeningElement(`<h1 className={'block'}>jsx</h1>`);
const literalExpression = getJSXAttribute(literal);
expect(extractValueFromNode(literalExpression)).toBe("block");
test("getVAttributeName", () => {
[
// VIdentifier
[`<h1 class="m-0 flex">jsx</h1>`, "class"],
// VDirectiveKey
[`<h1 v-bind:attr="{'block': true}">jsx</h1>`, "attr"],
// VDirectiveKey
[`<h1 :short="{'block': true}">jsx</h1>`, "short"],
].map(([templateCode, expected]) => {
const input = vAttribute(`<template>${templateCode}</template>`);
// @ts-expect-error Argument of type 'string | VAttribute' is not assignable to parameter of type 'VAttribute'.
expect(getVAttributeName(input)).toBe(expected);
});
});
Loading