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
16const VARIANT_END_PATTERN = /^(?:\/[\w-]+)?:/u;
17
18function variantGroups(token: string): { prefix: string; group: string }[] {
19 const groups: { prefix: string; group: string }[] = [];
20 let depth = 0;
21 let start = -1;
22 for (let index = 0; index < token.length; index++) {
23 const char = token[index];
24 if (char === "[") {
25 if (depth === 0) start = index + 1;
26 depth++;
27 } else if (char === "]" && depth > 0) {
28 depth--;
29 if (depth === 0 && VARIANT_END_PATTERN.test(token.slice(index + 1))) {
30 groups.push({ prefix: token.slice(0, start - 1), group: token.slice(start, index) });
31 }
32 }
33 }
34 return groups;
35}
36
37function closingParen(selector: string, open: number): number {
38 let depth = 0;
39 for (let index = open; index < selector.length; index++) {
40 if (selector[index] === "(") depth++;
41 else if (selector[index] === ")" && --depth === 0) return index;
42 }
43 return selector.length;
44}
45
46function isWrapperSubject(selector: string, from: number): boolean {
47 let depth = 0;
48 for (let index = from; index < selector.length; index++) {
49 const char = selector[index] ?? "";
50 if (char === "(") depth++;
51 else if (char === ")") {
52 if (depth === 0) return true;
53 depth--;
54 } else if (depth > 0) continue;
55 else if (char === ",") return true;
56 else if (COMBINATOR_PATTERN.test(char)) {
57 const next = selector.slice(index).trimStart()[0];
58 if (/\s/u.test(char) && (next === undefined || next === ")" || next === ",")) continue;
59 return false;
60 }
61 }
62 return true;
63}
64
65function hasCompounds(selector: string): string[] {
66 const compounds: string[] = [];
67 let index = selector.indexOf(":has(");
68 while (index !== -1) {
69 let compound = "";
70 let depth = 0;
71 let subjectEnd = closingParen(selector, index + ":has".length) + 1;
72 // Other branches of a selector list are skipped until the wrapper that holds
73 // them opens. An unbalanced "(" means the :has() sits inside :not()/:is()/
74 // :where(); the compound outside that wrapper applies only when the :has()
75 // is in the wrapper's subject position.
76 let skippingBranch = false;
77 for (let position = index - 1; position >= 0; position--) {
78 const char = selector[position] ?? "";
79 if (char === ")") depth++;
80 else if (char === "(") {
81 if (depth > 0) depth--;
82 else {
83 if (!isWrapperSubject(selector, subjectEnd)) break;
84 skippingBranch = false;
85 subjectEnd = closingParen(selector, position) + 1;
86 }
87 } else if (depth === 0 && char === ",") {
88 skippingBranch = true;
89 continue;
90 } else if (depth === 0 && !skippingBranch && COMBINATOR_PATTERN.test(char)) break;
91 if (!skippingBranch) compound = char + compound;
92 }
93 compounds.push(compound);
94 index = selector.indexOf(":has(", index + 1);
95 }
96 return compounds;
97}
98
99function withoutNegations(compound: string): string {
100 let result = "";
101 let index = 0;
102 while (index < compound.length) {
103 if (!compound.startsWith(":not(", index)) {
104 result += compound[index];
105 index++;
106 continue;
107 }
108 let depth = 0;
109 for (index += ":not".length; index < compound.length; index++) {
110 if (compound[index] === "(") depth++;
111 else if (compound[index] === ")" && --depth === 0) break;
112 }
113 index++;
114 }
115 return result;
116}
117
118function findUnscopedHasVariants(text: string): string[] {
119 if (!text.toLowerCase().includes(":has(")) return [];
120 const offenders: string[] = [];
121 for (const token of text.split(/\s+/u)) {
122 for (const { prefix, group } of variantGroups(token)) {
123 if (ATTRIBUTE_PREFIX_PATTERN.test(prefix)) continue;
124 // Tailwind writes spaces as "_", and "&" is the element carrying the class.
125 // A selector without "&" applies to that element, as `&:is(...)`.
126 const relative = group.toLowerCase().replace(QUOTED_PATTERN, '""').replaceAll("_", " ");
127 const owner = GROUP_PREFIX_PATTERN.test(prefix) ? ".group" : ".self";
128 const selector = ANCESTOR_PREFIX_PATTERN.test(prefix)
129 ? `:is(${relative.replaceAll("&", "*")}) .self`
130 : relative.includes("&")
131 ? relative.replaceAll("&", owner)
132 : `${owner}:is(${relative})`;
133 const unscoped = hasCompounds(selector).some((compound) => {
134 const anchor = withoutNegations(compound);
135 return !NARROWING_PATTERN.test(anchor) || ROOT_COMPOUND_PATTERN.test(anchor);
136 });
137 if (unscoped) offenders.push(`[${group}]`);
138 }
139 }
140 return offenders;
141}
142
143export default defineRule({
144 meta: {
145 type: "problem",
146 docs: {
147 description:
148 "Disallow Tailwind arbitrary variants with a :has() that is not anchored to a class, attribute, id, or tag.",
149 },
150 },
151 create(context) {
152 const message = (variant: string) =>
153 `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.`;
154 return {
155 Literal(node) {
156 if (typeof node.value !== "string") return;
157 for (const variant of findUnscopedHasVariants(node.value)) {
158 context.report({ node, message: message(variant) });
159 }
160 },
161 TemplateElement(node) {
162 for (const variant of findUnscopedHasVariants(node.value.cooked ?? node.value.raw)) {
163 context.report({ node, message: message(variant) });
164 }
165 },
166 };
167 },
168});