1import { defineRule, type ESTree } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, unwrapExpression } from "../utils.ts";
5
6const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
7// node:test has no `.each`, so files written against it keep their loops.
8const NODE_TEST_IMPORT_PATTERN = /["']node:test["']/u;
9const TEST_FUNCTIONS = new Set(["describe", "it", "suite", "test"]);
10const EACH_CAPABLE_MODIFIERS = new Set(["effect", "live"]);
11const LOOPS = new Set(["ForStatement", "ForInStatement", "ForOfStatement"]);
12const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]);
13
14const testCallName = (callee: unknown): Option.Option<string> => {
15 const expression = unwrapExpression(callee);
16 if (Option.isNone(expression)) return Option.none();
17
18 if (expression.value.type === "Identifier") {
19 return TEST_FUNCTIONS.has(expression.value.name)
20 ? Option.some(expression.value.name)
21 : Option.none();
22 }
23
24 if (expression.value.type !== "MemberExpression") return Option.none();
25 const object = unwrapExpression(expression.value.object);
26 const property = getPropertyName(expression.value.property);
27 if (Option.isNone(object) || Option.isNone(property)) return Option.none();
28 if (!EACH_CAPABLE_MODIFIERS.has(property.value)) return Option.none();
29 if (object.value.type !== "Identifier" || !TEST_FUNCTIONS.has(object.value.name)) {
30 return Option.none();
31 }
32 return Option.some(`${object.value.name}.${property.value}`);
33};
34
35const isInsideLoop = (node: ESTree.Node): boolean => {
36 let current = node.parent;
37 while (current) {
38 if (LOOPS.has(current.type)) return true;
39 if (FUNCTIONS.has(current.type)) return false;
40 current = current.parent;
41 }
42 return false;
43};
44
45export default defineRule({
46 meta: {
47 type: "suggestion",
48 docs: {
49 description:
50 "Disallow declaring tests inside a for loop; use it.each / it.effect.each / describe.each instead.",
51 },
52 },
53 create(context) {
54 if (!TEST_FILE_PATTERN.test(context.filename)) return {};
55 if (NODE_TEST_IMPORT_PATTERN.test(context.sourceCode.text)) return {};
56
57 return {
58 CallExpression(node) {
59 const name = testCallName(node.callee);
60 if (Option.isNone(name)) return;
61 if (!isInsideLoop(node)) return;
62
63 context.report({
64 node: node.callee,
65 message: `Do not call ${name.value}(…) inside a for loop. Use ${name.value}.each(cases)(name, …) instead.`,
66 });
67 },
68 };
69 },
70});