Skip to content

Commit a7b9f0f

Browse files
feat: enforces-negative-arbitrary-values (#449)
1 parent 0290c86 commit a7b9f0f

5 files changed

Lines changed: 362 additions & 6 deletions

File tree

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
1313
💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
1414

15-
| Name                       | Description | 💼 | ⚠️ | 🔧 | 💡 |
16-
| :--------------------------------------------------------------------- | :---------------------------------------------------------------------------------- | :-- | :-- | :-- | :-- |
17-
| [classnames-order](docs/rules/classnames-order.md) | Enforces a consistent order for the Tailwind CSS classnames, based on the compiler. | || 🔧 | |
18-
| [enforces-shorthand](docs/rules/enforces-shorthand.md) | Avoid using multiple Tailwind CSS classnames when not required. | || 🔧 | |
19-
| [no-contradicting-classname](docs/rules/no-contradicting-classname.md) | Avoid contradicting Tailwind CSS classnames. || | | 💡 |
20-
| [no-custom-classname](docs/rules/no-custom-classname.md) | Detects classnames which do not belong to Tailwind CSS. | || | 💡 |
15+
| Name                               | Description | 💼 | ⚠️ | 🔧 | 💡 |
16+
| :------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------- | :-- | :-- | :-- | :-- |
17+
| [classnames-order](docs/rules/classnames-order.md) | Enforces a consistent order for the Tailwind CSS classnames, based on the compiler. | || 🔧 | |
18+
| [enforces-negative-arbitrary-values](docs/rules/enforces-negative-arbitrary-values.md) | Warns about `-` prefixed classnames using arbitrary values. | || 🔧 | |
19+
| [enforces-shorthand](docs/rules/enforces-shorthand.md) | Avoid using multiple Tailwind CSS classnames when not required. | || 🔧 | |
20+
| [no-contradicting-classname](docs/rules/no-contradicting-classname.md) | Avoid contradicting Tailwind CSS classnames. || | | 💡 |
21+
| [no-custom-classname](docs/rules/no-custom-classname.md) | Detects classnames which do not belong to Tailwind CSS. | || | 💡 |
2122

2223
<!-- end auto-generated rules list -->
2324

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Warns about `-` prefixed classnames using arbitrary values
2+
3+
⚠️ This rule _warns_ in the ✅ `recommended` config.
4+
5+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
6+
7+
<!-- end auto-generated rule header -->
8+
9+
## Rule Details
10+
11+
Examples of **incorrect** code for this rule:
12+
13+
```html
14+
<section class="-m-[-5px]">
15+
<pre class="-z-[1]">enforces-negative-arbitrary-values</pre>
16+
</section>
17+
```
18+
19+
Examples of **correct** code for this rule:
20+
21+
```html
22+
<section class="m-[5px]">
23+
<pre class="z-[-1]">enforces-negative-arbitrary-values</pre>
24+
</section>
25+
```
26+
27+
The first invalid classname `-m-[-5px]` uses a double negation which:
28+
29+
- is harder to read
30+
- may be the doppelgänger of an already used `m-[-5px]`
31+
32+
The second invalid classname `-z-[1]`:
33+
34+
- feels odd
35+
- its `z-[-1]` version is nicer
36+
37+
## Options
38+
39+
<!-- begin auto-generated rule options list -->
40+
41+
<!-- end auto-generated rule options list -->
42+
43+
There are no specific options for this rule, yet it uses the general [settings](../../README.md#settings).

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
classnamesOrder,
66
RULE_NAME as CLASSNAMES_ORDER,
77
} from "./rules/classnames-order";
8+
import {
9+
enforcesNegativeArbitraryValues,
10+
RULE_NAME as ENFORCES_NEGATIVE_ARBITRARY_VALUES,
11+
} from "./rules/enforces-negative-arbitrary-values";
812
import {
913
enforcesShorthand,
1014
RULE_NAME as ENFORCES_SHORTHAND,
@@ -41,6 +45,7 @@ const plugin = {
4145
},
4246
rules: {
4347
[CLASSNAMES_ORDER]: classnamesOrder,
48+
[ENFORCES_NEGATIVE_ARBITRARY_VALUES]: enforcesNegativeArbitraryValues,
4449
[ENFORCES_SHORTHAND]: enforcesShorthand,
4550
[NO_CUSTOM_CLASSNAME]: noCustomClassname,
4651
[NO_CONTRADICTING_CLASSNAME]: noContradictingClassname,
@@ -49,6 +54,7 @@ const plugin = {
4954

5055
const recommended = {
5156
[CLASSNAMES_ORDER]: "warn",
57+
[ENFORCES_NEGATIVE_ARBITRARY_VALUES]: "warn",
5258
[ENFORCES_SHORTHAND]: "warn",
5359
[NO_CUSTOM_CLASSNAME]: "warn",
5460
[NO_CONTRADICTING_CLASSNAME]: "error",
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import * as Parser from "@typescript-eslint/parser";
2+
import { RuleTester, TestCaseError } from "@typescript-eslint/rule-tester";
3+
4+
import { generalSettings } from "../utils/parser/test-helpers";
5+
import {
6+
enforcesNegativeArbitraryValues,
7+
type MessageIds,
8+
RULE_NAME,
9+
} from "./enforces-negative-arbitrary-values";
10+
11+
const generateError = (
12+
oldClassName: string,
13+
newClassName: string,
14+
): TestCaseError<MessageIds> => {
15+
return {
16+
messageId: "fix:irregular-negative",
17+
data: { oldClassName: oldClassName, newClassName: newClassName },
18+
};
19+
};
20+
21+
const ruleTester = new RuleTester({
22+
languageOptions: {
23+
parser: Parser,
24+
parserOptions: {
25+
ecmaFeatures: {
26+
jsx: true,
27+
},
28+
},
29+
},
30+
settings: {
31+
tailwindcss: {
32+
...generalSettings,
33+
},
34+
},
35+
});
36+
37+
type SingleErrorTestCase = {
38+
code: string;
39+
invalidClass: string;
40+
patchedClass: string;
41+
output: string;
42+
};
43+
44+
const singleErrorTestCases: Array<SingleErrorTestCase> = [
45+
{
46+
code: `ctl('-m-[-10px]')`,
47+
invalidClass: "-m-[-10px]",
48+
patchedClass: "m-[10px]",
49+
output: `ctl('m-[10px]')`,
50+
},
51+
{
52+
code: `ctl('-m-[10px]')`,
53+
invalidClass: "-m-[10px]",
54+
patchedClass: "m-[-10px]",
55+
output: `ctl('m-[-10px]')`,
56+
},
57+
{
58+
code: `ctl('dark:-m-[-10px]')`,
59+
invalidClass: "dark:-m-[-10px]",
60+
patchedClass: "dark:m-[10px]",
61+
output: `ctl('dark:m-[10px]')`,
62+
},
63+
];
64+
65+
type MultipleErrorsTestCase = {
66+
code: string;
67+
invalidClasses: Array<string>;
68+
patchedClasses: Array<string>;
69+
outputs: Array<string>;
70+
};
71+
72+
const multipleErrorsTestCases: Array<MultipleErrorsTestCase> = [
73+
{
74+
code: `
75+
ctl(\`
76+
lg:pt-[3px]
77+
dark:-m-[-123px]
78+
-m-[6px]
79+
\`)`,
80+
invalidClasses: ["dark:-m-[-123px]", "-m-[6px]"],
81+
patchedClasses: ["dark:m-[123px]", "m-[-6px]"],
82+
outputs: [
83+
`
84+
ctl(\`
85+
lg:pt-[3px]
86+
dark:m-[123px]
87+
-m-[6px]
88+
\`)`,
89+
`
90+
ctl(\`
91+
lg:pt-[3px]
92+
dark:m-[123px]
93+
m-[-6px]
94+
\`)`,
95+
],
96+
},
97+
];
98+
99+
ruleTester.run(RULE_NAME, enforcesNegativeArbitraryValues, {
100+
valid:
101+
// JSX
102+
["ctl('m-[-10px]')"].map((testedJsxCode) => ({
103+
code: testedJsxCode,
104+
})),
105+
invalid: [
106+
...singleErrorTestCases.map(
107+
({ code, invalidClass, patchedClass, output }) => ({
108+
code: code,
109+
errors: [generateError(invalidClass, patchedClass)],
110+
output: output,
111+
}),
112+
),
113+
...multipleErrorsTestCases.map(
114+
({ code, invalidClasses, patchedClasses, outputs }) => ({
115+
code: code,
116+
errors: invalidClasses.map((invalidClass, index) =>
117+
generateError(invalidClass, patchedClasses[index]),
118+
),
119+
output: outputs,
120+
}),
121+
),
122+
],
123+
});
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* @fileoverview Warns about `-` prefixed classnames using arbitrary values.
3+
* @author François Massart
4+
*/
5+
6+
import { RuleCreator } from "@typescript-eslint/utils/eslint-utils";
7+
import { RuleContext as TSESLintRuleContext } from "@typescript-eslint/utils/ts-eslint";
8+
9+
import urlCreator from "../url-creator";
10+
import { joiner } from "../utils/joiner";
11+
import {
12+
parsePluginSettings,
13+
PluginSettings,
14+
} from "../utils/parse-plugin-settings";
15+
import {
16+
getBaseClassname,
17+
getModifiersPrefix,
18+
} from "../utils/parser/classname";
19+
import {
20+
dissectAtomicNode,
21+
generateLocForClassname,
22+
getClassnamesFromValue,
23+
} from "../utils/parser/node";
24+
import { defineVisitors, GenericRuleContext } from "../utils/parser/visitors";
25+
import {
26+
AtomicNode,
27+
createScriptVisitors,
28+
createTemplateVisitors,
29+
} from "../utils/rule";
30+
31+
export { ESLintUtils } from "@typescript-eslint/utils";
32+
33+
export const RULE_NAME = "enforces-negative-arbitrary-values";
34+
35+
// Message IDs don't need to be prefixed, I just find it easier to keep track of them this way
36+
export type MessageIds = "fix:irregular-negative";
37+
38+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
39+
export type RuleOptions = {};
40+
41+
type Options = [RuleOptions];
42+
43+
type RuleContext = TSESLintRuleContext<MessageIds, Options>;
44+
45+
// The Rule creator returns a function that is used to create a well-typed ESLint rule
46+
// The parameter passed into RuleCreator is a URL generator function.
47+
export const createRule = RuleCreator(urlCreator);
48+
49+
const propertiesPattern = [
50+
// inset, inset-x, inset-y, scale, scale-x, scale-y
51+
"((inset|scale)(?:-(?:x|y))?)",
52+
// simple properties
53+
"(m|top|right|bottom|left|z|order|tracking|indent|(backdrop-)?hue-rotate)|(space-(x|y))",
54+
// scroll-m, scroll-mx, scroll-my, scroll-mt, scroll-mb, scroll-ml, scroll-mr, scroll-ms, scroll-me, scroll-mbs, scroll-mbe
55+
"(scroll-m(?:x|y|s|e|bs|be|t|r|b|l|))",
56+
// skew, skew-x, skew-y, translate, translate-x, translate-y, rotate, rotate-x, rotate-y, rotate-z
57+
"((skew|translate|rotate)(?:-(?:x|y|z))?)",
58+
].join("|");
59+
// Matches classnames that start with '-' and contain arbitrary values (e.g., -m-[10px])
60+
const regexPattern = [
61+
"^",
62+
"(?<negative>-)",
63+
"(?<property>(" + propertiesPattern + "))",
64+
String.raw`-\[(?<arbitraryValue>.*)\]`,
65+
"$",
66+
].join("");
67+
68+
const negativeArbitraryRegEx = new RegExp(regexPattern);
69+
70+
const negativeArbitraryClassnames = (
71+
context: RuleContext,
72+
settings: PluginSettings,
73+
options: RuleOptions,
74+
literals: Array<AtomicNode>,
75+
) => {
76+
// console.log(options);
77+
const genericContext = context as unknown as GenericRuleContext;
78+
for (const node of literals) {
79+
const { originalClassNamesValue, start, end, prefix, suffix } =
80+
dissectAtomicNode(node, genericContext);
81+
// Process the extracted classnames and report
82+
const { classNames, whitespaces, headSpace, tailSpace } =
83+
getClassnamesFromValue(originalClassNamesValue);
84+
for (const [index, targetClassName] of classNames.entries()) {
85+
const baseClass = getBaseClassname(targetClassName);
86+
const modifiers = getModifiersPrefix(targetClassName);
87+
const match = baseClass.match(negativeArbitraryRegEx);
88+
89+
if (!match?.groups) continue;
90+
91+
const patchedLoc = generateLocForClassname(
92+
node,
93+
targetClassName,
94+
originalClassNamesValue,
95+
genericContext,
96+
);
97+
98+
const { property = "", arbitraryValue = "" } = match.groups;
99+
const arbitraryValuePatched = arbitraryValue.startsWith("-")
100+
? arbitraryValue.slice(1)
101+
: "-" + arbitraryValue;
102+
const patchedClass = `${modifiers}${property}-[${arbitraryValuePatched}]`;
103+
104+
// Patch the problematic classname
105+
classNames[index] = patchedClass;
106+
107+
// Generates the "cleaned" attribute value
108+
let patchedValue = joiner({
109+
classNames,
110+
whitespaces,
111+
headSpace,
112+
tailSpace,
113+
validator: (candidate) => candidate !== targetClassName,
114+
});
115+
patchedValue = prefix + patchedValue + suffix;
116+
117+
context.report({
118+
loc: patchedLoc,
119+
messageId: "fix:irregular-negative",
120+
data: {
121+
oldClassName: targetClassName,
122+
newClassName: patchedClass,
123+
},
124+
fix: (fixer) => fixer.replaceTextRange([start, end], patchedValue),
125+
});
126+
}
127+
}
128+
};
129+
130+
export const enforcesNegativeArbitraryValues = createRule<Options, MessageIds>({
131+
name: RULE_NAME,
132+
meta: {
133+
docs: {
134+
description:
135+
"Warns about `-` prefixed classnames using arbitrary values.",
136+
},
137+
hasSuggestions: false,
138+
messages: {
139+
"fix:irregular-negative": `Replace '{{oldClassName}}' by '{{newClassName}}'`,
140+
},
141+
fixable: "code",
142+
// Schema is also parsed by `eslint-doc-generator`
143+
schema: [
144+
{
145+
type: "object",
146+
properties: {},
147+
additionalProperties: false,
148+
},
149+
],
150+
defaultOptions: [{}],
151+
type: "problem",
152+
},
153+
/**
154+
* About `defaultOptions`:
155+
* - `defaultOptions` is not parsed to generate the documentation
156+
* - `defaultOptions` is used when options are NOT provided in the rules configuration
157+
* - If some configuration is provided as the second argument, `defaultOptions` is ignored completely (not merged)
158+
* - In other words, the `defaultOptions` is only used when the rule is used WITHOUT any configuration
159+
*/
160+
defaultOptions: [{}],
161+
create: (context, options) => {
162+
// Merged settings
163+
const settings = parsePluginSettings(context.settings);
164+
165+
return defineVisitors(
166+
context as unknown as Readonly<GenericRuleContext>,
167+
// Template visitor is only used within Vue SFC files (inside <template> section).
168+
createTemplateVisitors(
169+
context,
170+
settings,
171+
options,
172+
negativeArbitraryClassnames,
173+
),
174+
// Script visitor is used within both JSX and Vue SFC files (inside <script> section).
175+
createScriptVisitors(
176+
context,
177+
settings,
178+
options,
179+
negativeArbitraryClassnames,
180+
),
181+
);
182+
},
183+
});

0 commit comments

Comments
 (0)