1import { defineRule, type ESTree } from "@oxlint/plugins";
2
3const MESSAGE = "Catch known tags with `Effect.catchTags({ Tag: handler })`, even for one tag.";
4
5/** Reports `Effect.catchTag`, through any namespace import of `effect/Effect` or a named import. */
6export default defineRule({
7 meta: {
8 type: "suggestion",
9 docs: {
10 description: "Require `Effect.catchTags` over `Effect.catchTag`.",
11 },
12 },
13 create(context) {
14 // Whether `identifier` is bound by `import * as X from "effect/Effect"`, not a shadowing local.
15 const isEffectNamespace = (identifier: ESTree.IdentifierReference) => {
16 let scope = context.sourceCode.getScope(identifier);
17 while (true) {
18 const variable = scope.set.get(identifier.name);
19 if (variable !== undefined) {
20 return variable.defs.some(
21 (def) =>
22 def.type === "ImportBinding" &&
23 def.node.type === "ImportNamespaceSpecifier" &&
24 def.parent?.type === "ImportDeclaration" &&
25 def.parent.source.value === "effect/Effect",
26 );
27 }
28 if (scope.upper === null) return false;
29 scope = scope.upper;
30 }
31 };
32
33 return {
34 ImportDeclaration(node) {
35 if (node.source.value !== "effect/Effect") return;
36 for (const specifier of node.specifiers) {
37 if (
38 specifier.type === "ImportSpecifier" &&
39 (specifier.imported.type === "Identifier"
40 ? specifier.imported.name
41 : specifier.imported.value) === "catchTag"
42 ) {
43 context.report({ node: specifier, message: MESSAGE });
44 }
45 }
46 },
47 MemberExpression(node) {
48 if (
49 !node.computed &&
50 node.object.type === "Identifier" &&
51 node.property.type === "Identifier" &&
52 node.property.name === "catchTag" &&
53 isEffectNamespace(node.object)
54 ) {
55 context.report({ node, message: MESSAGE });
56 }
57 },
58 };
59 },
60});