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;
9// Calls that declare a test or a group of tests; all of them expose `.each`.
10const TEST_FUNCTIONS = new Set(["describe", "it", "suite", "test"]);
11// Modifiers whose tester also exposes `.each` (@effect/vitest's `it.effect` and `it.live`).
12const EACH_CAPABLE_MODIFIERS = new Set(["effect", "live"]);
13const LOOPS = new Set(["ForStatement", "ForInStatement", "ForOfStatement"]);
14const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]);
15
16/** Returns the reported name for `it(…)`, `describe(…)`, `it.effect(…)`, and friends. */
17const testCallName = (callee: unknown): Option.Option<string> => {
18 const expression = unwrapExpression(callee);
19 if (Option.isNone(expression)) return Option.none();
20
21 if (expression.value.type === "Identifier") {
22 return TEST_FUNCTIONS.has(expression.value.name)
23 ? Option.some(expression.value.name)
24 : Option.none();
25 }
26
27 if (expression.value.type !== "MemberExpression") return Option.none();
28 const object = unwrapExpression(expression.value.object);
29 const property = getPropertyName(expression.value.property);
30 if (Option.isNone(object) || Option.isNone(property)) return Option.none();
31 if (!EACH_CAPABLE_MODIFIERS.has(property.value)) return Option.none();
32 if (object.value.type !== "Identifier" || !TEST_FUNCTIONS.has(object.value.name)) {
33 return Option.none();
34 }
35 return Option.some(`${object.value.name}.${property.value}`);
36};
37
38/**
39 * Reports a test declaration that runs once per loop iteration. A loop around a
40 * `describe` is reported on the `describe`, so the tests inside it stay quiet.
41 */
42const isInsideLoop = (node: ESTree.Node): boolean => {
43 let current = node.parent;
44 while (current) {
45 if (LOOPS.has(current.type)) return true;
46 // Anything inside a function (a describe body, a helper, a test body) runs
47 // once per call of that function, not once per iteration.
48 if (FUNCTIONS.has(current.type)) return false;
49 current = current.parent;
50 }
51 return false;
52};
53
54export default defineRule({
55 meta: {
56 type: "suggestion",
57 docs: {
58 description:
59 "Disallow declaring tests inside a for loop; use it.each / it.effect.each / describe.each instead.",
60 },
61 },
62 create(context) {
63 if (!TEST_FILE_PATTERN.test(context.filename)) return {};
64 if (NODE_TEST_IMPORT_PATTERN.test(context.sourceCode.text)) return {};
65
66 return {
67 CallExpression(node) {
68 const name = testCallName(node.callee);
69 if (Option.isNone(name)) return;
70 if (!isInsideLoop(node)) return;
71
72 context.report({
73 node: node.callee,
74 message: `Do not call ${name.value}(…) inside a for loop. Use ${name.value}.each(cases)(name, …) instead.`,
75 });
76 },
77 };
78 },
79});