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;
7const OUTLINE_WIDTH = /^outline-(?:(\d+)|\[(?:length:)?(\d+(?:\.\d+)?)px\])$/u;
8const INWARD_OFFSET = /^-outline-offset-(\d+)$/u;
9
10function parseClass(token: string) {
11 const variants: string[] = [];
12 let depth = 0;
13 let start = 0;
14 for (let index = 0; index < token.length; index++) {
15 const character = token[index];
16 if (character === "[" || character === "(") depth++;
17 else if (character === "]" || character === ")") depth--;
18 else if (character === ":" && depth === 0) {
19 variants.push(token.slice(start, index));
20 start = index + 1;
21 }
22 }
23 return { variants, utility: token.slice(start).replace(/^!|!$/gu, "") };
24}
25
26type ParsedClass = ReturnType<typeof parseClass>;
27
28const covers = (companion: ParsedClass, indicator: ParsedClass) =>
29 companion.variants.every((variant) => indicator.variants.includes(variant));
30
31const isClassComposer = (node: ESTree.Node) =>
32 node.type === "CallExpression" &&
33 node.callee.type === "Identifier" &&
34 CLASS_COMPOSERS.has(node.callee.name);
35
36function collectStrings(node: unknown, out: string[]) {
37 if (Array.isArray(node)) {
38 for (const child of node) collectStrings(child, out);
39 return;
40 }
41 if (typeof node !== "object" || node === null || !("type" in node)) return;
42 const current = node as ESTree.Node;
43 if (/^(?:Conditional|Logical|Object)Expression$/u.test(current.type)) return;
44 if (current.type === "CallExpression" && !isClassComposer(current)) return;
45 if (current.type === "Literal" && typeof current.value === "string") out.push(current.value);
46 if (current.type === "TemplateElement") out.push(current.value.cooked ?? current.value.raw);
47 for (const [key, value] of Object.entries(current)) {
48 if (key !== "parent") collectStrings(value, out);
49 }
50}
51
52function scopeClasses(node: ESTree.Node, own: ParsedClass[]) {
53 const strings: string[] = [];
54 for (let current = node.parent; current; current = current.parent) {
55 if (/Statement$|Declaration$|^JSX(?:Attribute|Element)$/u.test(current.type)) break;
56 if (isClassComposer(current)) collectStrings(current, strings);
57 }
58 return [...own, ...strings.join(" ").split(/\s+/u).map(parseClass)];
59}
60
61function offenders(text: string, node: ESTree.Node) {
62 const own = text.split(/\s+/u).filter(Boolean).map(parseClass);
63 const found: string[] = [];
64 let scope: ParsedClass[] | undefined;
65 for (const token of own) {
66 const { utility, variants } = token;
67 const label = [...variants, utility].join(":");
68 if (/^outline-offset-|^\[outline-offset:/u.test(utility)) {
69 found.push(label);
70 continue;
71 }
72 if (!STATE_VARIANT.test(variants.join(":"))) continue;
73 if (RING_WIDTH.test(utility)) {
74 scope ??= scopeClasses(node, own);
75 const inset = scope.some((other) => other.utility === "ring-inset" && covers(other, token));
76 if (!inset) found.push(label);
77 continue;
78 }
79 const outline = OUTLINE_WIDTH.exec(utility);
80 if (outline) {
81 const width = Number(outline[1] ?? outline[2]);
82 if (width <= 2) continue;
83 scope ??= scopeClasses(node, own);
84 const inward = scope.some((other) => {
85 const offset = INWARD_OFFSET.exec(other.utility);
86 return offset !== null && Number(offset[1]) >= width && covers(other, token);
87 });
88 if (!inward) found.push(label);
89 }
90 }
91 return found;
92}
93
94export default defineRule({
95 meta: {
96 type: "problem",
97 docs: {
98 description:
99 "Keep focus and selection indicators inside their element, where an ancestor cannot clip them.",
100 },
101 },
102 create(context) {
103 const check = (node: ESTree.Node, text: string) => {
104 for (const utility of offenders(text, node)) {
105 context.report({
106 node,
107 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.`,
108 });
109 }
110 };
111 return {
112 Literal(node) {
113 if (typeof node.value === "string") check(node, node.value);
114 },
115 TemplateLiteral(node) {
116 check(node, node.quasis.map((part) => part.value.cooked ?? part.value.raw).join(" "));
117 },
118 };
119 },
120});