diff --git a/.changeset/calm-foxes-listen.md b/.changeset/calm-foxes-listen.md new file mode 100644 index 0000000..c801c61 --- /dev/null +++ b/.changeset/calm-foxes-listen.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-effector": minor +--- + +Improve `enforce-effect-naming-convention` to enforce naming in binding contexts (destructuring & function parameters) diff --git a/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.md b/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.md index 7cac3d8..90b1bb4 100644 --- a/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.md +++ b/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.md @@ -4,7 +4,9 @@ description: Enforce Fx as a suffix for any Effector Effect # effector/enforce-effect-naming-convention -Enforcing naming conventions helps keep the codebase consistent, and reduces overhead when thinking about how to name a variable with effect. Your effect should be distinguished by a suffix `Fx`. For example, `fetchUserInfoFx` is an effect, `fetchUserInfo` is not. +Enforces Effector naming conventions to reduce code reading overhead by clearly and consistently marking all `Effect`s with an `Fx` suffix across the codebase. + +`Effect`s must be distinguished from other variables by an `Fx` suffix. For example, `fetchUserInfoFx` is an effect, `fetchUserInfo` is not. ## Configuration @@ -20,8 +22,10 @@ Enforcing naming conventions helps keep the codebase consistent, and reduces ove ```ts // 👍 nice name -const fetchNameFx = createEffect() +const fetchUserFx = createEffect() +const { fetchUserFx, fetchPostFx } = useContext(ApiContext) // 👎 bad name -const fetchName = createEffect() +const fetchUser = createEffect() +const { fetchUser, fetchPost } = useContext(ApiContext) ``` diff --git a/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.test.ts b/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.test.ts index aca40d6..4c2f3e1 100644 --- a/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.test.ts +++ b/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.test.ts @@ -55,6 +55,76 @@ ruleTester.run("enforce-effect-naming-convention", rule, { const renamedFx = attachedFx `, }, + { + name: "effect as destructured argument", + code: ts` + import { type Effect, createEffect } from "effector" + + type QueryParams = { runFx: Effect } + + const alpha = ({ runFx = createEffect() }: QueryParams) => undefined + const beta = ({ runFx }: QueryParams) => undefined + const gamma = (runFx = createEffect()) => undefined + `, + }, + { + name: "shape destructuring -> ident", + code: ts` + import { createEffect } from "effector" + + const { fooFx } = { fooFx: createEffect() } + const { foo: fooFx } = { foo: createEffect() } + `, + }, + { + name: "shape destructuring -> assignment", + code: ts` + import { createEffect } from "effector" + + const { foo: fooFx = createEffect() } = { foo: null } + const { bar: barFx = createEffect() } = { bar: createEffect() } + `, + }, + { + name: "array destructuring -> ident", + code: ts` + import { createEffect, attach } from "effector" + + const baseFx = createEffect() + const [firstFx] = [createEffect()] + const [secondFx, thirdFx] = [attach({ effect: baseFx }), createEffect()] + `, + }, + { + name: "array destructuring -> assignment", + code: ts` + import { createEffect } from "effector" + + const [firstFx = createEffect()] = [null] + const [secondFx = createEffect()] = [createEffect()] + `, + }, + { + name: "mixed nested destructuring -> assignment", + code: ts` + import { createEffect } from "effector" + + const { + first: [secondFx = createEffect()], + } = { first: [null] } + `, + }, + { + name: "property in argument context", + code: ts` + import { createEffect, attach } from "effector" + + const sourceFx = createEffect() + + const attachedFx = attach({ effect: sourceFx }) + const grouped = { alpha: createEffect(), beta: attach({ effect: sourceFx }) } + `, + }, ], invalid: [ { @@ -163,5 +233,186 @@ ruleTester.run("enforce-effect-naming-convention", rule, { }, ], }, + { + name: "variable with type annotation", + code: ts` + import { createEffect, type Effect } from "effector" + + const fetch: Effect = createEffect() + `, + errors: [{ messageId: "invalid", line: 3, data: { current: "fetch", fixed: "fetchFx" } }], + }, + { + name: "shape destructuring", + code: ts` + import { createEffect } from "effector" + + const { first } = { first: createEffect() } + const { fooFx: first } = { fooFx: createEffect() } + `, + errors: [ + { + messageId: "invalid", + line: 3, + suggestions: [ + { + messageId: "rename", + data: { current: "first", fixed: "firstFx" }, + output: ts` + import { createEffect } from "effector" + + const { first: firstFx } = { first: createEffect() } + const { fooFx: first } = { fooFx: createEffect() } + `, + }, + ], + }, + { + messageId: "invalid", + line: 4, + suggestions: [ + { + messageId: "rename", + data: { current: "first", fixed: "firstFx" }, + output: ts` + import { createEffect } from "effector" + + const { first } = { first: createEffect() } + const { fooFx: firstFx } = { fooFx: createEffect() } + `, + }, + ], + }, + ], + }, + { + name: "shape destructuring with assignment", + code: ts` + import { createEffect, attach } from "effector" + + const sourceFx = createEffect() + + const { first = createEffect() } = { first: sourceFx } + const { second: beta = attach({ effect: sourceFx }) } = { second: sourceFx } + `, + errors: [ + { + messageId: "invalid", + line: 5, + suggestions: [ + { + messageId: "rename", + data: { current: "first", fixed: "firstFx" }, + output: ts` + import { createEffect, attach } from "effector" + + const sourceFx = createEffect() + + const { first: firstFx = createEffect() } = { first: sourceFx } + const { second: beta = attach({ effect: sourceFx }) } = { second: sourceFx } + `, + }, + ], + }, + { + messageId: "invalid", + line: 6, + suggestions: [ + { + messageId: "rename", + data: { current: "beta", fixed: "betaFx" }, + output: ts` + import { createEffect, attach } from "effector" + + const sourceFx = createEffect() + + const { first = createEffect() } = { first: sourceFx } + const { second: betaFx = attach({ effect: sourceFx }) } = { second: sourceFx } + `, + }, + ], + }, + ], + }, + { + name: "array destructuring", + code: ts` + import { createEffect } from "effector" + + const [first, second = createEffect()] = [createEffect()] + `, + errors: [ + { + messageId: "invalid", + line: 3, + suggestions: [ + { + messageId: "rename", + data: { current: "first", fixed: "firstFx" }, + output: ts` + import { createEffect } from "effector" + + const [firstFx, second = createEffect()] = [createEffect()] + `, + }, + ], + }, + { + messageId: "invalid", + line: 3, + suggestions: [ + { + messageId: "rename", + data: { current: "second", fixed: "secondFx" }, + output: ts` + import { createEffect } from "effector" + + const [first, secondFx = createEffect()] = [createEffect()] + `, + }, + ], + }, + ], + }, + { + name: "function parameter nested inferred destructuring", + code: ts` + import { type Effect } from "effector" + + type Config = { effect: Effect } + function test({ config: { effect } }: { config: Config }) {} + `, + errors: [ + { + messageId: "invalid", + line: 4, + suggestions: [ + { + messageId: "rename", + data: { current: "effect", fixed: "effectFx" }, + output: ts` + import { type Effect } from "effector" + + type Config = { effect: Effect } + function test({ config: { effect: effectFx } }: { config: Config }) {} + `, + }, + ], + }, + ], + }, + { + name: "function parameter inferred", + code: ts` + import { type Effect } from "effector" + + function alpha(effect: Effect) {} + const beta = (effect: Effect) => {} + `, + errors: [ + { messageId: "invalid", line: 3, data: { current: "effect", fixed: "effectFx" } }, + { messageId: "invalid", line: 4, data: { current: "effect", fixed: "effectFx" } }, + ], + }, ], }) diff --git a/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.ts b/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.ts index c9a86f2..b3a6187 100644 --- a/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.ts +++ b/src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.ts @@ -1,8 +1,13 @@ -import { ESLintUtils, type TSESTree as Node, type TSESLint } from "@typescript-eslint/utils" +import { ESLintUtils, TSESTree as Node, AST_NODE_TYPES as NodeType, type TSESLint } from "@typescript-eslint/utils" import { createRule } from "@/shared/create" import { isType } from "@/shared/is" +type Suggestion = TSESLint.SuggestionReportDescriptor<"invalid" | "rename"> + +type ShapeProperty = Node.Property & + ({ value: Node.Identifier } | { value: Node.AssignmentPattern & { left: Node.Identifier } }) + export default createRule({ name: "enforce-effect-naming-convention", meta: { @@ -11,7 +16,7 @@ export default createRule({ description: "Enforce Fx as a suffix for any Effector Effect.", }, messages: { - invalid: "Effect `{{ current }}` should be named with suffix, rename it to `{{ fixed }}`", + invalid: 'Effect "{{ current }}" should be named with `Fx` suffix, rename it to "{{ fixed }}"', rename: 'Rename "{{ current }}" to "{{ fixed }}"', }, schema: [], @@ -21,30 +26,69 @@ export default createRule({ create: (context) => { const services = ESLintUtils.getParserServices(context) - type VariableDeclarator = Node.VariableDeclarator & { id: Node.Identifier } - return { - [`VariableDeclarator[id.name!=${FxRegex}]`]: (node: VariableDeclarator) => { - const type = services.getTypeAtLocation(node) + [`${selector.variable}, ${selector.array.identifier}, ${selector.array.assignment}, ${selector.function.identifier}, ${selector.function.assignment}`]: + (node: Node.Identifier) => { + const type = services.getTypeAtLocation(node) + + const isEffect = isType.effect(type, services.program) + if (!isEffect) return + + const data = { current: node.name, fixed: node.name + "Fx" } + + // type annotation is included in `range` so we can't reliably replace text without erasing the annotation + if (node.typeAnnotation) return context.report({ node, messageId: "invalid", data }) + + const suggestion: Suggestion = { + messageId: "rename", + data: { current: node.name, fixed: data.fixed }, + fix: (fixer) => fixer.replaceText(node, data.fixed), + } + + context.report({ node, messageId: "invalid", data, suggest: [suggestion] }) + }, + + [`${selector.shape.identifier}, ${selector.shape.assignment}`]: (node: ShapeProperty) => { + const type = services.getTypeAtLocation(node.value) + const ident = node.value.type === NodeType.Identifier ? node.value : node.value.left const isEffect = isType.effect(type, services.program) if (!isEffect) return - const current = node.id.name - const fixed = current + "Fx" + const data = { current: ident.name, fixed: ident.name + "Fx" } - const data = { current, fixed } + // type annotation is included in `range` so we can't reliably replace text without erasing the annotation + if (ident.typeAnnotation) return context.report({ node: ident, messageId: "invalid", data }) - const suggestion = { - messageId: "rename" as const, - data: { current, fixed }, - fix: (fixer: TSESLint.RuleFixer) => fixer.replaceText(node.id, fixed), + const suggestion: Suggestion = { + messageId: "rename", + data: { current: ident.name, fixed: data.fixed }, + fix: (fixer) => + node.shorthand + ? fixer.insertTextAfter(node.key, `: ${data.fixed}`) // { x } -> { x: xFx } + : fixer.replaceText(ident, data.fixed), // { x: y } -> { x: yFx } } - context.report({ node: node.id, messageId: "invalid", data, suggest: [suggestion] }) + context.report({ node: ident, messageId: "invalid", data, suggest: [suggestion] }) }, } }, }) const FxRegex = /Fx$/ + +const selector = { + variable: `VariableDeclarator > Identifier.id[name!=${FxRegex}]`, + array: { + identifier: `ArrayPattern > Identifier.elements[name!=${FxRegex}]`, + assignment: `ArrayPattern > AssignmentPattern > Identifier.left[name!=${FxRegex}]`, + }, + shape: { + identifier: `ObjectPattern > Property:has(> Identifier.value[name!=${FxRegex}])`, + assignment: `ObjectPattern > Property:has(> AssignmentPattern:has(> Identifier.left[name!=${FxRegex}]))`, + }, + function: { + identifier: `:function > Identifier.params[name!=${FxRegex}]`, + assignment: `:function > AssignmentPattern > Identifier.left[name!=${FxRegex}]`, + }, +}