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// Either utility keeps the content box the same width whether or not the scrollbar shows. Both
8// exemptions must be unprefixed: a variant such as lg: leaves every other state 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 /** Classes that apply in at least one render. */
22 readonly possible: Set<string>;
23 /** Classes that apply in every render, outside any conditional branch. */
24 readonly always: Set<string>;
25}
26
27/**
28 * The class names a className value can produce: string literals, template text, and the
29 * branches and arguments of conditionals and cn()-style calls. Conditions themselves are skipped.
30 */
31function collectClassNames(node: unknown, classNames: ClassNames, always: boolean) {
32 const expression = unwrapExpression(node);
33 if (Option.isNone(expression)) return;
34 const value = expression.value;
35
36 const addAll = (text: string | null) => {
37 for (const className of text?.split(/\s+/u) ?? []) {
38 if (!className) continue;
39 classNames.possible.add(className);
40 if (always) classNames.always.add(className);
41 }
42 };
43
44 switch (value.type) {
45 case "Literal":
46 if (typeof value.value === "string") addAll(value.value);
47 return;
48 case "TemplateLiteral":
49 value.quasis.forEach((quasi, index) => {
50 // A token touching an interpolation is only part of a class name, such as `${prefix}x`.
51 const tokens = (quasi.value.cooked ?? "").split(/\s+/u);
52 if (index > 0) tokens.shift();
53 if (!quasi.tail) tokens.pop();
54 addAll(tokens.join(" "));
55 });
56 for (const nested of value.expressions) collectClassNames(nested, classNames, always);
57 return;
58 case "JSXExpressionContainer":
59 collectClassNames(value.expression, classNames, always);
60 return;
61 case "ConditionalExpression":
62 collectClassNames(value.consequent, classNames, false);
63 collectClassNames(value.alternate, classNames, false);
64 return;
65 case "LogicalExpression":
66 collectClassNames(value.left, classNames, false);
67 collectClassNames(value.right, classNames, false);
68 return;
69 case "ArrayExpression":
70 for (const element of value.elements) collectClassNames(element, classNames, always);
71 return;
72 case "CallExpression":
73 for (const argument of value.arguments) collectClassNames(argument, classNames, always);
74 return;
75 }
76}
77
78function classNamesOf(element: ESTree.JSXElement): ClassNames {
79 const classNames: ClassNames = { possible: new Set(), always: new Set() };
80 for (const attribute of element.openingElement.attributes) {
81 if (
82 attribute.type === "JSXAttribute" &&
83 attribute.name.type === "JSXIdentifier" &&
84 attribute.name.name === "className"
85 ) {
86 collectClassNames(attribute.value, classNames, true);
87 }
88 }
89 return classNames;
90}
91
92const hasClass = (classNames: ReadonlySet<string>, pattern: RegExp) =>
93 [...classNames].some((className) => pattern.test(className));
94
95/**
96 * Reports a native vertical scroller that centers its content without reserving the scrollbar
97 * lane. A classic scrollbar narrows the scroller when it appears, so centered content jumps by
98 * half its width whenever the content grows past the fold.
99 */
100export default defineRule({
101 meta: {
102 type: "problem",
103 docs: {
104 description:
105 "Require scrollbar-gutter-both on overflow-auto scrollers that center their content, so it does not shift when the scrollbar appears.",
106 },
107 },
108 create(context) {
109 const reported = new Set<ESTree.JSXElement>();
110
111 const report = (scroller: ESTree.JSXElement, { always }: ClassNames) => {
112 if (reported.has(scroller)) return;
113 if (hasClass(always, RESERVES_GUTTER) || hasClass(always, HIDES_SCROLLBAR)) return;
114 reported.add(scroller);
115 context.report({
116 node: scroller.openingElement,
117 message:
118 "This scroller centers its content, which shifts sideways when the scrollbar appears. Add scrollbar-gutter-both.",
119 });
120 };
121
122 return {
123 JSXElement(node) {
124 const classNames = classNamesOf(node);
125 const { possible } = classNames;
126 if (possible.size === 0) return;
127
128 if (hasClass(possible, SCROLLS_VERTICALLY)) {
129 // A responsive or conditional direction can render as either axis, so check both.
130 const centersColumn =
131 hasClass(possible, COLUMN) && hasClass(possible, CENTERS_COLUMN_ITEMS);
132 const centersRow =
133 (!hasClass(classNames.always, ALWAYS_COLUMN) || hasClass(possible, ROW)) &&
134 hasClass(possible, CENTERS_ROW_ITEMS);
135 if (centersColumn || centersRow) report(node, classNames);
136 }
137
138 if (!hasClass(possible, CENTERED_BY_MARGIN)) return;
139 // The nearest enclosing scroller is the one whose scrollbar moves this element.
140 for (let ancestor: ESTree.Node | null = node.parent; ancestor; ancestor = ancestor.parent) {
141 // An element passed as a prop renders wherever that component puts it.
142 if (ancestor.type === "JSXAttribute") return;
143 if (ancestor.type !== "JSXElement") continue;
144 const ancestorClassNames = classNamesOf(ancestor);
145 if (!hasClass(ancestorClassNames.possible, SCROLLS_VERTICALLY)) continue;
146 report(ancestor, ancestorClassNames);
147 return;
148 }
149 },
150 };
151 },
152});