1import { defineRule, type ESTree } from "@oxlint/plugins";
2
3const CLASS_COMPOSERS = new Set(["cn", "clsx", "classNames", "cva", "twMerge"]);
4const STATE_VARIANT =
5 /(?:^|[^\w])(?:focus(?:-visible|-within)?|selected|checked|pressed)(?=$|[^\w])/u;
6const RING_WIDTH = /^ring(?:-(?:[1-9]\d*|\[[^\]]+\]|\(length:[^)]+\)))?$/u;
7// Pixel widths only; arbitrary values like `outline-[#243c5a]` are colors.
8const OUTLINE_WIDTH = /^outline-(?:(\d+)|\[(?:length:)?(\d+(?:\.\d+)?)px\])$/u;
9const INWARD_OFFSET = /^-outline-offset-(\d+)$/u;
10
11/** Splits `dark:[&_a]:ring-2!` into its variants and utility, ignoring colons inside brackets. */
12function parseClass(token: string) {
13 const variants: string[] = [];
14 let depth = 0;
15 let start = 0;
16 for (let index = 0; index < token.length; index++) {
17 const character = token[index];
18 if (character === "[" || character === "(") depth++;
19 else if (character === "]" || character === ")") depth--;
20 else if (character === ":" && depth === 0) {
21 variants.push(token.slice(start, index));
22 start = index + 1;
23 }
24 }
25 return { variants, utility: token.slice(start).replace(/^!|!$/gu, "") };
26}
27
28type ParsedClass = ReturnType<typeof parseClass>;
29
30/** A companion applies when its variants are a subset of the indicator's variants. */
31const covers = (companion: ParsedClass, indicator: ParsedClass) =>
32 companion.variants.every((variant) => indicator.variants.includes(variant));
33
34const isClassComposer = (node: ESTree.Node) =>
35 node.type === "CallExpression" &&
36 node.callee.type === "Identifier" &&
37 CLASS_COMPOSERS.has(node.callee.name);
38
39/** Strings a composition always includes; branches, class maps, and other calls are not guaranteed. */
40function collectStrings(node: unknown, out: string[]) {
41 if (Array.isArray(node)) {
42 for (const child of node) collectStrings(child, out);
43 return;
44 }
45 if (typeof node !== "object" || node === null || !("type" in node)) return;
46 const current = node as ESTree.Node;
47 if (/^(?:Conditional|Logical|Object)Expression$/u.test(current.type)) return;
48 if (current.type === "CallExpression" && !isClassComposer(current)) return;
49 if (current.type === "Literal" && typeof current.value === "string") out.push(current.value);
50 if (current.type === "TemplateElement") out.push(current.value.cooked ?? current.value.raw);
51 for (const [key, value] of Object.entries(current)) {
52 if (key !== "parent") collectStrings(value, out);
53 }
54}
55
56/** Classes guaranteed alongside a node by each enclosing `cn(...)`, so sibling companions count. */
57function scopeClasses(node: ESTree.Node, own: ParsedClass[]) {
58 const strings: string[] = [];
59 for (let current = node.parent; current; current = current.parent) {
60 if (/Statement$|Declaration$|^JSX(?:Attribute|Element)$/u.test(current.type)) break;
61 if (isClassComposer(current)) collectStrings(current, strings);
62 }
63 return [...own, ...strings.join(" ").split(/\s+/u).map(parseClass)];
64}
65
66function offenders(text: string, node: ESTree.Node) {
67 const own = text.split(/\s+/u).filter(Boolean).map(parseClass);
68 const found: string[] = [];
69 let scope: ParsedClass[] | undefined;
70 for (const token of own) {
71 const { utility, variants } = token;
72 const label = [...variants, utility].join(":");
73 // Positive offsets push the outline out of the element; the app default is inward.
74 if (/^outline-offset-|^\[outline-offset:/u.test(utility)) {
75 found.push(label);
76 continue;
77 }
78 if (!STATE_VARIANT.test(variants.join(":"))) continue;
79 if (RING_WIDTH.test(utility)) {
80 scope ??= scopeClasses(node, own);
81 const inset = scope.some((other) => other.utility === "ring-inset" && covers(other, token));
82 if (!inset) found.push(label);
83 continue;
84 }
85 const outline = OUTLINE_WIDTH.exec(utility);
86 if (outline) {
87 const width = Number(outline[1] ?? outline[2]);
88 if (width <= 2) continue;
89 scope ??= scopeClasses(node, own);
90 const inward = scope.some((other) => {
91 const offset = INWARD_OFFSET.exec(other.utility);
92 return offset !== null && Number(offset[1]) >= width && covers(other, token);
93 });
94 if (!inward) found.push(label);
95 }
96 }
97 return found;
98}
99
100export default defineRule({
101 meta: {
102 type: "problem",
103 docs: {
104 description:
105 "Keep focus and selection indicators inside their element, where an ancestor cannot clip them.",
106 },
107 },
108 create(context) {
109 const check = (node: ESTree.Node, text: string) => {
110 for (const utility of offenders(text, node)) {
111 context.report({
112 node,
113 message: `${utility} paints a focus or selection indicator outside its element, where an ancestor can clip it. Add ring-inset under the same variant, drop the positive outline offset, or use a negative offset at least as wide as the outline.`,
114 });
115 }
116 };
117 return {
118 Literal(node) {
119 if (typeof node.value === "string") check(node, node.value);
120 },
121 TemplateLiteral(node) {
122 check(node, node.quasis.map((part) => part.value.cooked ?? part.value.raw).join(" "));
123 },
124 };
125 },
126});