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