1import { defineRule, type Variable } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, unwrapExpression } from "../utils.ts";
5
6const APPEARANCE_VARIANT_PATTERN = /\b(?:dark|light):(?=\S)/u;
7const APPEARANCE_VARIANT_MESSAGE =
8 "dark:/light: utilities do not follow registered custom themes; use an adaptive semantic token.";
9
10const literalStringValue = (node: unknown): Option.Option<string> => {
11 if (typeof node !== "object" || node === null) return Option.none();
12 if (!("type" in node) || node.type !== "Literal") return Option.none();
13 if (!("value" in node) || typeof node.value !== "string") return Option.none();
14 return Option.some(node.value);
15};
16
17const reportsAppearanceVariant = (value: string) => APPEARANCE_VARIANT_PATTERN.test(value);
18
19const importsModule = (source: string, modulePath: string): boolean =>
20 source.replace(/\.[cm]?[jt]sx?$/u, "").endsWith(modulePath);
21
22const readAllowUniwindTheme = (options: ReadonlyArray<unknown>): boolean => {
23 const [first] = options;
24 return (
25 typeof first === "object" &&
26 first !== null &&
27 "allowUniwindTheme" in first &&
28 first.allowUniwindTheme === true
29 );
30};
31
32export default defineRule({
33 meta: {
34 type: "problem",
35 docs: {
36 description:
37 "Keep mobile theme styling on semantic Uniwind classes. Scope the rule to mobile sources and exempt reviewed native interop boundaries in the lint config.",
38 },
39 schema: [
40 {
41 type: "object",
42 properties: {
43 allowUniwindTheme: {
44 type: "boolean",
45 description:
46 "Permit useUniwindTheme in a reviewed native/third-party interop boundary that cannot consume a className.",
47 },
48 },
49 additionalProperties: false,
50 },
51 ],
52 defaultOptions: [{ allowUniwindTheme: false }],
53 },
54 create(context) {
55 const allowUniwindTheme = readAllowUniwindTheme(context.options);
56 const uniwindNamespaces = new Set<Variable>();
57
58 const resolveVariable = (node: unknown): Variable | undefined => {
59 const identifier = unwrapExpression(node);
60 if (Option.isNone(identifier) || identifier.value.type !== "Identifier") return undefined;
61
62 let scope = context.sourceCode.getScope(identifier.value);
63 while (true) {
64 const variable = scope.set.get(identifier.value.name);
65 if (variable !== undefined || scope.upper === null) return variable;
66 scope = scope.upper;
67 }
68 };
69
70 return {
71 ImportDeclaration(node) {
72 const source = literalStringValue(node.source);
73 if (Option.isNone(source)) return;
74 const declaredVariables = context.sourceCode.getDeclaredVariables(node);
75
76 for (const specifier of node.specifiers) {
77 const local = unwrapExpression(specifier.local);
78 const importedName =
79 specifier.type === "ImportSpecifier"
80 ? getPropertyName(specifier.imported)
81 : Option.none();
82 const isTypeOnly =
83 node.importKind === "type" ||
84 (specifier.type === "ImportSpecifier" && specifier.importKind === "type");
85
86 if (
87 !isTypeOnly &&
88 specifier.type === "ImportNamespaceSpecifier" &&
89 Option.isSome(local) &&
90 local.value.type === "Identifier"
91 ) {
92 const localName = local.value.name;
93 const variable = declaredVariables.find((candidate) => candidate.name === localName);
94 if (source.value === "uniwind" && variable !== undefined) {
95 uniwindNamespaces.add(variable);
96 }
97 }
98
99 if (
100 !isTypeOnly &&
101 source.value === "uniwind" &&
102 Option.isSome(importedName) &&
103 importedName.value === "useCSSVariable"
104 ) {
105 context.report({
106 node: specifier,
107 message:
108 "Use a semantic className instead of useCSSVariable; it adds a React theme subscription.",
109 });
110 }
111
112 if (!isTypeOnly && importsModule(source.value, "/useThemeColor")) {
113 context.report({
114 node: specifier,
115 message: "useThemeColor was replaced by semantic Uniwind classes.",
116 });
117 }
118
119 if (
120 !isTypeOnly &&
121 !allowUniwindTheme &&
122 importsModule(source.value, "/useUniwindTheme")
123 ) {
124 context.report({
125 node: specifier,
126 message:
127 "Use className for theme styling, or review this native/third-party interop boundary and enable allowUniwindTheme for it in the lint config.",
128 });
129 }
130 }
131 },
132 MemberExpression(node) {
133 const object = unwrapExpression(node.object);
134 if (Option.isNone(object) || object.value.type !== "Identifier") return;
135
136 const property = getPropertyName(node.property);
137 if (Option.isNone(property)) return;
138
139 const namespace = resolveVariable(object.value);
140 if (
141 namespace !== undefined &&
142 uniwindNamespaces.has(namespace) &&
143 property.value === "useCSSVariable"
144 ) {
145 context.report({
146 node,
147 message:
148 "Use a semantic className instead of useCSSVariable; it adds a React theme subscription.",
149 });
150 }
151 },
152 VariableDeclarator(node) {
153 const initializer = unwrapExpression(node.init);
154 const binding = unwrapExpression(node.id);
155 if (
156 Option.isNone(initializer) ||
157 initializer.value.type !== "Identifier" ||
158 Option.isNone(binding)
159 ) {
160 return;
161 }
162
163 const namespace = resolveVariable(initializer.value);
164 if (namespace === undefined || !uniwindNamespaces.has(namespace)) return;
165
166 if (binding.value.type === "Identifier") {
167 const bindingName = binding.value.name;
168 const variable = context.sourceCode
169 .getDeclaredVariables(node)
170 .find((candidate) => candidate.name === bindingName);
171 if (variable !== undefined) uniwindNamespaces.add(variable);
172 return;
173 }
174
175 if (binding.value.type !== "ObjectPattern") return;
176
177 const declaredVariables = context.sourceCode.getDeclaredVariables(node);
178 for (const propertyNode of binding.value.properties) {
179 if (propertyNode.type === "RestElement") {
180 const restBinding = unwrapExpression(propertyNode.argument);
181 if (Option.isNone(restBinding) || restBinding.value.type !== "Identifier") continue;
182
183 const restName = restBinding.value.name;
184 const variable = declaredVariables.find((candidate) => candidate.name === restName);
185 if (variable !== undefined) uniwindNamespaces.add(variable);
186 continue;
187 }
188
189 if (propertyNode.type !== "Property") continue;
190 const property = getPropertyName(propertyNode.key);
191 if (Option.isNone(property)) continue;
192
193 if (property.value === "useCSSVariable") {
194 context.report({
195 node: propertyNode,
196 message:
197 "Use a semantic className instead of useCSSVariable; it adds a React theme subscription.",
198 });
199 }
200 }
201 },
202 Literal(node) {
203 if (typeof node.value !== "string" || !reportsAppearanceVariant(node.value)) return;
204 context.report({ node, message: APPEARANCE_VARIANT_MESSAGE });
205 },
206 TemplateElement(node) {
207 if (!reportsAppearanceVariant(node.value.cooked ?? "")) return;
208 context.report({ node, message: APPEARANCE_VARIANT_MESSAGE });
209 },
210 };
211 },
212});