1import { defineRule } from "@oxlint/plugins";
2
3const COMBINATOR_PATTERN = /[\s>+~]/u;
4// A class, id, attribute, or leading tag narrows a compound. Negated ones don't,
5// so `:not(...)` is removed before this check.
6const NARROWING_PATTERN = /[.#[]|^[a-z]/iu;
7const ROOT_COMPOUND_PATTERN = /^(?:html|body|:root)(?![\w-])/iu;
8
9// group-[...] and peer-[...] match the .group/.peer element, in-[...] matches an
10// ancestor, and data-[...] and aria-[...] hold attribute values, not selectors.
11const GROUP_PREFIX_PATTERN = /(?:^|:)(?:group|peer)-$/u;
12const ANCESTOR_PREFIX_PATTERN = /(?:^|:)in-$/u;
13const ATTRIBUTE_PREFIX_PATTERN = /(?:^|:)(?:data|aria)-$/u;
14const QUOTED_PATTERN = /(["'])(?:\\.|(?!\1).)*\1/gu;
15
16// A variant's closing bracket is followed by ":" or a group/peer name like "/row:".
17const VARIANT_END_PATTERN = /^(?:\/[\w-]+)?:/u;
18
19/** Tailwind arbitrary variants in a class token: top-level `[...]` groups used as variants. */
20function variantGroups(token: string): { prefix: string; group: string }[] {
21 const groups: { prefix: string; group: string }[] = [];
22 let depth = 0;
23 let start = -1;
24 for (let index = 0; index < token.length; index++) {
25 const char = token[index];
26 if (char === "[") {
27 if (depth === 0) start = index + 1;
28 depth++;
29 } else if (char === "]" && depth > 0) {
30 depth--;
31 if (depth === 0 && VARIANT_END_PATTERN.test(token.slice(index + 1))) {
32 groups.push({ prefix: token.slice(0, start - 1), group: token.slice(start, index) });
33 }
34 }
35 }
36 return groups;
37}
38
39/** Index of the ")" closing the "(" at `open`, or the selector's length. */
40function closingParen(selector: string, open: number): number {
41 let depth = 0;
42 for (let index = open; index < selector.length; index++) {
43 if (selector[index] === "(") depth++;
44 else if (selector[index] === ")" && --depth === 0) return index;
45 }
46 return selector.length;
47}
48
49/**
50 * Whether nothing after `from` in the enclosing branch is joined by a combinator,
51 * i.e. the element ending at `from` is the subject that its wrapper matches.
52 */
53function isWrapperSubject(selector: string, from: number): boolean {
54 let depth = 0;
55 for (let index = from; index < selector.length; index++) {
56 const char = selector[index] ?? "";
57 if (char === "(") depth++;
58 else if (char === ")") {
59 if (depth === 0) return true;
60 depth--;
61 } else if (depth > 0) continue;
62 else if (char === ",") return true;
63 else if (COMBINATOR_PATTERN.test(char)) {
64 const next = selector.slice(index).trimStart()[0];
65 if (/\s/u.test(char) && (next === undefined || next === ")" || next === ",")) continue;
66 return false;
67 }
68 }
69 return true;
70}
71
72/** The compound selector each `:has(` in `selector` is attached to. */
73function hasCompounds(selector: string): string[] {
74 const compounds: string[] = [];
75 let index = selector.indexOf(":has(");
76 while (index !== -1) {
77 let compound = "";
78 let depth = 0;
79 let subjectEnd = closingParen(selector, index + ":has".length) + 1;
80 // Other branches of a selector list are skipped until the wrapper that holds
81 // them opens. An unbalanced "(" means the :has() sits inside :not()/:is()/
82 // :where(); the compound outside that wrapper applies only when the :has()
83 // is in the wrapper's subject position.
84 let skippingBranch = false;
85 for (let position = index - 1; position >= 0; position--) {
86 const char = selector[position] ?? "";
87 if (char === ")") depth++;
88 else if (char === "(") {
89 if (depth > 0) depth--;
90 else {
91 if (!isWrapperSubject(selector, subjectEnd)) break;
92 skippingBranch = false;
93 subjectEnd = closingParen(selector, position) + 1;
94 }
95 } else if (depth === 0 && char === ",") {
96 skippingBranch = true;
97 continue;
98 } else if (depth === 0 && !skippingBranch && COMBINATOR_PATTERN.test(char)) break;
99 if (!skippingBranch) compound = char + compound;
100 }
101 compounds.push(compound);
102 index = selector.indexOf(":has(", index + 1);
103 }
104 return compounds;
105}
106
107/** `compound` without any `:not(...)`, including one left open around the `:has()`. */
108function withoutNegations(compound: string): string {
109 let result = "";
110 let index = 0;
111 while (index < compound.length) {
112 if (!compound.startsWith(":not(", index)) {
113 result += compound[index];
114 index++;
115 continue;
116 }
117 let depth = 0;
118 for (index += ":not".length; index < compound.length; index++) {
119 if (compound[index] === "(") depth++;
120 else if (compound[index] === ")" && --depth === 0) break;
121 }
122 index++;
123 }
124 return result;
125}
126
127/** Arbitrary variants in `text` whose `:has()` is unanchored or anchored to the document root. */
128function findUnscopedHasVariants(text: string): string[] {
129 // Selectors are ASCII case-insensitive.
130 if (!text.toLowerCase().includes(":has(")) return [];
131 const offenders: string[] = [];
132 for (const token of text.split(/\s+/u)) {
133 for (const { prefix, group } of variantGroups(token)) {
134 if (ATTRIBUTE_PREFIX_PATTERN.test(prefix)) continue;
135 // Tailwind writes spaces as "_", and "&" is the element carrying the class.
136 // A selector without "&" applies to that element, as `&:is(...)`.
137 const relative = group.toLowerCase().replace(QUOTED_PATTERN, '""').replaceAll("_", " ");
138 const owner = GROUP_PREFIX_PATTERN.test(prefix) ? ".group" : ".self";
139 const selector = ANCESTOR_PREFIX_PATTERN.test(prefix)
140 ? `:is(${relative.replaceAll("&", "*")}) .self`
141 : relative.includes("&")
142 ? relative.replaceAll("&", owner)
143 : `${owner}:is(${relative})`;
144 const unscoped = hasCompounds(selector).some((compound) => {
145 const anchor = withoutNegations(compound);
146 return !NARROWING_PATTERN.test(anchor) || ROOT_COMPOUND_PATTERN.test(anchor);
147 });
148 if (unscoped) offenders.push(`[${group}]`);
149 }
150 }
151 return offenders;
152}
153
154export default defineRule({
155 meta: {
156 type: "problem",
157 docs: {
158 description:
159 "Disallow Tailwind arbitrary variants with a :has() that is not anchored to a class, attribute, id, or tag.",
160 },
161 },
162 create(context) {
163 const message = (variant: string) =>
164 `Anchor the :has() in ${variant} to a class, attribute, or tag below the document root, e.g. [&+[data-x]_…] or a has-* variant. Chrome evaluates an unanchored :has() on every ancestor, so any DOM change then restyles the whole page.`;
165 return {
166 Literal(node) {
167 if (typeof node.value !== "string") return;
168 for (const variant of findUnscopedHasVariants(node.value)) {
169 context.report({ node, message: message(variant) });
170 }
171 },
172 TemplateElement(node) {
173 for (const variant of findUnscopedHasVariants(node.value.cooked ?? node.value.raw)) {
174 context.report({ node, message: message(variant) });
175 }
176 },
177 };
178 },
179});