1import { defineRule } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4
5import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
6
7const COMPILER_METHODS = new Set<keyof typeof Schema>([
8 "is",
9 "asserts",
10 "decodeEffect",
11 "decodeExit",
12 "decodeOption",
13 "decodePromise",
14 "decodeResult",
15 "decodeSync",
16 "decodeUnknownExit",
17 "decodeUnknownEffect",
18 "decodeUnknownOption",
19 "decodeUnknownPromise",
20 "decodeUnknownResult",
21 "decodeUnknownSync",
22
23 "encodeExit",
24 "encodeEffect",
25 "encodeOption",
26 "encodePromise",
27 "encodeResult",
28 "encodeSync",
29 "encodeUnknownExit",
30 "encodeUnknownEffect",
31 "encodeUnknownOption",
32 "encodeUnknownPromise",
33 "encodeUnknownResult",
34 "encodeUnknownSync",
35]);
36
37const getSchemaCompilerMethod = (callee: unknown): Option.Option<string> => {
38 const expression = unwrapExpression(callee);
39 if (Option.isNone(expression) || expression.value.type !== "MemberExpression") {
40 return Option.none();
41 }
42
43 const object = unwrapExpression(expression.value.object);
44 if (!isIdentifier(object, "Schema")) return Option.none();
45
46 return Option.filter(getPropertyName(expression.value.property), (method) =>
47 COMPILER_METHODS.has(method as keyof typeof Schema),
48 );
49};
50
51const isStaticSchemaReference = (node: unknown): boolean => {
52 const expression = unwrapExpression(node);
53 if (Option.isNone(expression)) return false;
54
55 if (expression.value.type === "Identifier") {
56 const [firstChar] = expression.value.name;
57 return firstChar !== undefined && firstChar.toUpperCase() === firstChar;
58 }
59
60 return expression.value.type === "MemberExpression";
61};
62
63const isNestedStaticSchemaCall = (node: unknown): boolean => {
64 const expression = unwrapExpression(node);
65 if (Option.isNone(expression) || expression.value.type !== "CallExpression") return false;
66
67 const callee = unwrapExpression(expression.value.callee);
68 if (Option.isNone(callee) || callee.value.type !== "MemberExpression") return false;
69
70 const object = unwrapExpression(callee.value.object);
71 if (!isIdentifier(object, "Schema")) return false;
72
73 const method = getPropertyName(callee.value.property);
74 if (Option.isSome(method) && method.value === "fromJsonString") {
75 const firstArg = expression.value.arguments[0];
76 return isStaticSchemaReference(firstArg) || isNestedStaticSchemaCall(firstArg);
77 }
78
79 return true;
80};
81
82const isImmediatelyInvoked = (node: unknown): boolean => {
83 const expression = unwrapExpression(node);
84 if (Option.isNone(expression)) return false;
85
86 const parent =
87 "parent" in expression.value ? unwrapExpression(expression.value.parent) : Option.none();
88 return (
89 Option.isSome(parent) &&
90 parent.value.type === "CallExpression" &&
91 unwrapExpression(parent.value.callee).pipe(
92 Option.exists((callee) => callee === expression.value),
93 )
94 );
95};
96
97const messageHigh = (method: string) =>
98 `Hoist Schema.${method}(...) to module scope: both the inline schema literal and the compiled function are rebuilt on every call. Move the compiled function to a module-level const.`;
99
100const messageMedium = (method: string) =>
101 `Hoist Schema.${method}(...) to module scope: the compiled function is rebuilt on every call. Move it to a module-level const.`;
102
103export default defineRule({
104 meta: {
105 type: "problem",
106 docs: {
107 description:
108 "Disallow Schema decoder/encoder compiler calls inside function bodies; hoist them to module scope.",
109 },
110 },
111 createOnce(context) {
112 let functionDepth = 0;
113
114 const resetFunctionDepth = () => {
115 functionDepth = 0;
116 };
117
118 const enterFunction = () => {
119 functionDepth++;
120 };
121
122 const exitFunction = () => {
123 functionDepth--;
124 };
125
126 return {
127 before: resetFunctionDepth,
128 FunctionDeclaration: enterFunction,
129 "FunctionDeclaration:exit": exitFunction,
130 FunctionExpression: enterFunction,
131 "FunctionExpression:exit": exitFunction,
132 ArrowFunctionExpression: enterFunction,
133 "ArrowFunctionExpression:exit": exitFunction,
134 CallExpression(node) {
135 if (functionDepth === 0) return;
136
137 const method = getSchemaCompilerMethod(node.callee);
138 if (Option.isNone(method)) return;
139 if (!isImmediatelyInvoked(node)) return;
140
141 const firstArg = node.arguments[0];
142 const high = firstArg && isNestedStaticSchemaCall(firstArg);
143 if (!high && !isStaticSchemaReference(firstArg)) return;
144
145 context.report({
146 node: node.callee,
147 message: high ? messageHigh(method.value) : messageMedium(method.value),
148 });
149 },
150 };
151 },
152});