| 1 | import { defineRule, type Variable } from "@oxlint/plugins"; |
| 2 | import * as Option from "effect/Option"; |
| 3 | |
| 4 | import { getPropertyName, unwrapExpression } from "../utils.ts"; |
| 5 | |
| 6 | const APPEARANCE_VARIANT_PATTERN = /\b(?:dark|light):(?=\S)/u; |
| 7 | const APPEARANCE_VARIANT_MESSAGE = |
| 8 | "dark:/light: utilities do not follow registered custom themes; use an adaptive semantic token."; |
| 9 | |
| 10 | const 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 | |
| 17 | const reportsAppearanceVariant = (value: string) => APPEARANCE_VARIANT_PATTERN.test(value); |
| 18 | |
| 19 | const importsModule = (source: string, modulePath: string): boolean => |
| 20 | source.replace(/\.[cm]?[jt]sx?$/u, "").endsWith(modulePath); |
| 21 | |
| 22 | const 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 | |
| 32 | export 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 | }); |