1import { defineRule, type ESTree } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { unwrapExpression } from "../utils.ts";
5
6const SCROLLS_VERTICALLY = /(?:^|:)overflow-(?:y-)?auto$/u;
7// Both exemptions must be unprefixed: a variant such as lg: leaves every other state
8// unprotected.
9const RESERVES_GUTTER =
10 /^(?:scrollbar-gutter-(?:stable|both)|\[scrollbar-gutter:stable(?:_both-edges)?\])$/u;
11const HIDES_SCROLLBAR = /^\[scrollbar-width:none\]$/u;
12const CENTERED_BY_MARGIN = /(?:^|:)mx?-auto$/u;
13const COLUMN = /(?:^|:)flex-col(?:-reverse)?$/u;
14const ALWAYS_COLUMN = /^flex-col(?:-reverse)?$/u;
15const ROW = /(?:^|:)flex-row(?:-reverse)?$/u;
16const CENTERS_COLUMN_ITEMS = /(?:^|:)(?:items-center(?:-safe)?|place-items-center)$/u;
17const CENTERS_ROW_ITEMS =
18 /(?:^|:)(?:justify-center(?:-safe)?|\[justify-content:safe_center\]|place-items-center|place-content-center)$/u;
19
20interface ClassNames {
21 readonly possible: Set<string>;
22 readonly always: Set<string>;
23}
24
25function collectClassNames(node: unknown, classNames: ClassNames, always: boolean) {
26 const expression = unwrapExpression(node);
27 if (Option.isNone(expression)) return;
28 const value = expression.value;
29
30 const addAll = (text: string | null) => {
31 for (const className of text?.split(/\s+/u) ?? []) {
32 if (!className) continue;
33 classNames.possible.add(className);
34 if (always) classNames.always.add(className);
35 }
36 };
37
38 switch (value.type) {
39 case "Literal":
40 if (typeof value.value === "string") addAll(value.value);
41 return;
42 case "TemplateLiteral":
43 value.quasis.forEach((quasi, index) => {
44 // A token touching an interpolation is only part of a class name, such as `${prefix}x`.
45 const tokens = (quasi.value.cooked ?? "").split(/\s+/u);
46 if (index > 0) tokens.shift();
47 if (!quasi.tail) tokens.pop();
48 addAll(tokens.join(" "));
49 });
50 for (const nested of value.expressions) collectClassNames(nested, classNames, always);
51 return;
52 case "JSXExpressionContainer":
53 collectClassNames(value.expression, classNames, always);
54 return;
55 case "ConditionalExpression":
56 collectClassNames(value.consequent, classNames, false);
57 collectClassNames(value.alternate, classNames, false);
58 return;
59 case "LogicalExpression":
60 collectClassNames(value.left, classNames, false);
61 collectClassNames(value.right, classNames, false);
62 return;
63 case "ArrayExpression":
64 for (const element of value.elements) collectClassNames(element, classNames, always);
65 return;
66 case "CallExpression":
67 for (const argument of value.arguments) collectClassNames(argument, classNames, always);
68 return;
69 }
70}
71
72function classNamesOf(element: ESTree.JSXElement): ClassNames {
73 const classNames: ClassNames = { possible: new Set(), always: new Set() };
74 for (const attribute of element.openingElement.attributes) {
75 if (
76 attribute.type === "JSXAttribute" &&
77 attribute.name.type === "JSXIdentifier" &&
78 attribute.name.name === "className"
79 ) {
80 collectClassNames(attribute.value, classNames, true);
81 }
82 }
83 return classNames;
84}
85
86const hasClass = (classNames: ReadonlySet<string>, pattern: RegExp) =>
87 [...classNames].some((className) => pattern.test(className));
88
89export default defineRule({
90 meta: {
91 type: "problem",
92 docs: {
93 description:
94 "Require scrollbar-gutter-both on overflow-auto scrollers that center their content, so it does not shift when the scrollbar appears.",
95 },
96 },
97 create(context) {
98 const reported = new Set<ESTree.JSXElement>();
99
100 const report = (scroller: ESTree.JSXElement, { always }: ClassNames) => {
101 if (reported.has(scroller)) return;
102 if (hasClass(always, RESERVES_GUTTER) || hasClass(always, HIDES_SCROLLBAR)) return;
103 reported.add(scroller);
104 context.report({
105 node: scroller.openingElement,
106 message:
107 "This scroller centers its content, which shifts sideways when the scrollbar appears. Add scrollbar-gutter-both.",
108 });
109 };
110
111 return {
112 JSXElement(node) {
113 const classNames = classNamesOf(node);
114 const { possible } = classNames;
115 if (possible.size === 0) return;
116
117 if (hasClass(possible, SCROLLS_VERTICALLY)) {
118 const centersColumn =
119 hasClass(possible, COLUMN) && hasClass(possible, CENTERS_COLUMN_ITEMS);
120 const centersRow =
121 (!hasClass(classNames.always, ALWAYS_COLUMN) || hasClass(possible, ROW)) &&
122 hasClass(possible, CENTERS_ROW_ITEMS);
123 if (centersColumn || centersRow) report(node, classNames);
124 }
125
126 if (!hasClass(possible, CENTERED_BY_MARGIN)) return;
127 for (let ancestor: ESTree.Node | null = node.parent; ancestor; ancestor = ancestor.parent) {
128 // An element passed as a prop renders wherever that component puts it.
129 if (ancestor.type === "JSXAttribute") return;
130 if (ancestor.type !== "JSXElement") continue;
131 const ancestorClassNames = classNamesOf(ancestor);
132 if (!hasClass(ancestorClassNames.possible, SCROLLS_VERTICALLY)) continue;
133 report(ancestor, ancestorClassNames);
134 return;
135 }
136 },
137 };
138 },
139});