diff --git a/.gitignore b/.gitignore index 61450cc..350b148 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.idea +.idea/**/* + # Logs logs *.log diff --git a/config/recommended.js b/config/recommended.js new file mode 100644 index 0000000..e69de29 diff --git a/index.js b/index.js new file mode 100644 index 0000000..e69de29 diff --git a/src/index.ts b/src/index.ts index bac8f24..58edc74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import noUnnecessaryCombination from "./rules/no-unnecessary-combination/no-unne import noUnnecessaryDuplication from "./rules/no-unnecessary-duplication/no-unnecessary-duplication" import noUselessMethods from "./rules/no-useless-methods/no-useless-methods" import noWatch from "./rules/no-watch/no-watch" +import preferSingleBinding from "./rules/prefer-single-binding/prefer-single-binding" import preferUseUnit from "./rules/prefer-useUnit/prefer-useUnit" import requirePickupInPersist from "./rules/require-pickup-in-persist/require-pickup-in-persist" import strictEffectHandlers from "./rules/strict-effect-handlers/strict-effect-handlers" @@ -47,6 +48,7 @@ const base = { "prefer-useUnit": preferUseUnit, "require-pickup-in-persist": requirePickupInPersist, "strict-effect-handlers": strictEffectHandlers, + "prefer-single-binding": preferSingleBinding, }, } diff --git a/src/rules/prefer-single-binding/prefer-single-binding.md b/src/rules/prefer-single-binding/prefer-single-binding.md new file mode 100644 index 0000000..5ac1ddc --- /dev/null +++ b/src/rules/prefer-single-binding/prefer-single-binding.md @@ -0,0 +1,588 @@ +# effector/prefer-single-binding + +[Related documentation](https://effector.dev/en/api/effector-react/useunit/) + +Recommends combining multiple `useUnit` calls into a single call for better performance and cleaner code. + +## Rule Details + +This rule detects when multiple `useUnit` hooks are called in the same component and suggests combining them into a single call. + +Multiple `useUnit` calls can lead to: +- **Performance overhead**: Each `useUnit` creates separate subscriptions without batch-updates +- **Code duplication**: Repetitive hook calls make code harder to read +- **Maintenance issues**: Harder to track all units used in a component + +### Examples + +```tsx +// 👎 incorrect - multiple useUnit calls +const Component = () => { + const [store] = useUnit([$store]); + const [event] = useUnit([$event]); + + return ; +}; +``` + +```tsx +// 👍 correct - single useUnit call +const Component = () => { + const [store, event] = useUnit([$store, $event]); + + return ; +}; +``` + +## Options + +This rule accepts an options object with the following properties: + +```typescript +type Options = { + allowSeparateStoresAndEvents?: boolean; + enforceStoresAndEventsSeparation?: boolean; +}; +``` + +### `allowSeparateStoresAndEvents` + +**Default:** `false` + +When set to `true`, allows separate `useUnit` calls for stores and events, but still enforces combining multiple calls within each group. + +The rule uses heuristics to determine whether a unit is a store or an event: +- **Stores**: Names starting with `$`, or matching patterns like `is*`, `has*`, `*Store`, `*State`, `data`, `value`, `items` +- **Events**: Names ending with `*Event`, `*Changed`, `*Triggered`, `*Clicked`, `*Pressed`, or starting with `on*`, `handle*`, `set*`, `update*`, `submit*` + +#### Configuration + +```javascript +// .eslintrc.js +export default { + rules: { + 'effector/prefer-single-binding': ['warn', { + allowSeparateStoresAndEvents: true + }] + } +}; +``` + +#### Examples with `allowSeparateStoresAndEvents: true` + +```tsx +// 👍 correct - separate groups for stores and events +const Component = () => { + const [userName, userAge] = useUnit([$userName, $userAge]); + const [updateUser, deleteUser] = useUnit([updateUserEvent, deleteUserEvent]); + + return ( +
+

{userName}, {userAge}

+ + +
+ ); +}; +``` + +```tsx +// 👎 incorrect - multiple stores in separate calls +const Component = () => { + const [userName] = useUnit([$userName]); + const [userAge] = useUnit([$userAge]); + const [updateUser, deleteUser] = useUnit([updateUserEvent, deleteUserEvent]); + + return
...
; +}; +``` + +```tsx +// 👎 incorrect - multiple events in separate calls +const Component = () => { + const [userName, userAge] = useUnit([$userName, $userAge]); + const [updateUser] = useUnit([updateUserEvent]); + const [deleteUser] = useUnit([deleteUserEvent]); + + return
...
; +}; +``` + +### `enforceStoresAndEventsSeparation` + +**Default:** `false` + +When set to `true`, enforces separation of stores and events into different `useUnit` calls. This option detects when a single `useUnit` call contains both stores and events and suggests splitting them. + +This is useful when you want to maintain clear logical separation between state (stores) and actions (events) in your components. + +#### Configuration + +```javascript +// .eslintrc.js +export default { + rules: { + 'effector/prefer-single-binding': ['warn', { + enforceStoresAndEventsSeparation: true + }] + } +}; +``` + +#### Examples with `enforceStoresAndEventsSeparation: true` + +```tsx +// 👎 incorrect - mixed stores and events +const Component = () => { + const [value, setValue] = useUnit([$store, event]); + + return ; +}; +``` + +```tsx +// 👍 correct - separated stores and events +const Component = () => { + const [value] = useUnit([$store]); + const [setValue] = useUnit([event]); + + return ; +}; +``` + +```tsx +// 👎 incorrect - mixed in object form +const Component = () => { + const { value, setValue } = useUnit({ + value: $store, + setValue: event + }); + + return ; +}; +``` + +```tsx +// 👍 correct - separated in object form +const Component = () => { + const { value } = useUnit({ value: $store }); + const { setValue } = useUnit({ setValue: event }); + + return ; +}; +``` + +### Combining both options + +You can use both options together to enforce a specific code style: + +```javascript +// .eslintrc.js +export default { + rules: { + 'effector/prefer-single-binding': ['warn', { + allowSeparateStoresAndEvents: true, + enforceStoresAndEventsSeparation: true + }] + } +}; +``` + +With both options enabled: +- Mixed `useUnit` calls will be split into separate calls for stores and events +- Multiple calls of the same type (stores or events) will be combined + +```tsx +// 👎 incorrect - mixed types +const Component = () => { + const [value1, setValue1, value2, setValue2] = useUnit([ + $store1, + event1, + $store2, + event2 + ]); + + return null; +}; +``` + +```tsx +// 👍 correct - separated and combined by type +const Component = () => { + const [value1, value2] = useUnit([$store1, $store2]); + const [setValue1, setValue2] = useUnit([event1, event2]); + + return null; +}; +``` + +#### Working with models + +This combination is especially useful when working with Effector models: + +```tsx +// 👎 incorrect - mixed stores and events +const Component = () => { + const [isFormSent, submit, reset, isLoading] = useUnit([ + FormModel.$isFormSent, + FormModel.submitForm, + FormModel.resetForm, + FormModel.$isLoading, + ]); + + return ( +
+ {isLoading && } + + + + ); +}; +``` + +```tsx +// 👍 correct - stores and events are separated by logical groups +const Component = () => { + // All stores from the model + const [isFormSent, isLoading] = useUnit([ + FormModel.$isFormSent, + FormModel.$isLoading, + ]); + + // All events from the model + const [submit, reset] = useUnit([ + FormModel.submitForm, + FormModel.resetForm, + ]); + + return ( +
+ {isLoading && } + + + + ); +}; +``` + +## Why is this important? + +### Performance + +Each `useUnit` call creates its own subscription management overhead. Combining them reduces: +- Number of hook calls +- Subscription management overhead +- Re-render coordination complexity + +### Code clarity + +A single `useUnit` call (or logically separated calls) makes it easier to: +- See all dependencies at a glance +- Understand component's reactive logic +- Maintain and refactor code + +## Array shape examples + +```tsx +// 👎 incorrect +const Component = () => { + const [userName] = useUnit([$userName]); + const [userAge] = useUnit([$userAge]); + const [updateUser] = useUnit([updateUserEvent]); + + return ( +
+

{userName}, {userAge}

+ +
+ ); +}; +``` + +```tsx +// 👍 correct - combined (default behavior) +const Component = () => { + const [userName, userAge, updateUser] = useUnit([ + $userName, + $userAge, + updateUserEvent, + ]); + + return ( +
+

{userName}, {userAge}

+ +
+ ); +}; +``` + +```tsx +// 👍 also correct - separated (with enforceStoresAndEventsSeparation: true) +const Component = () => { + const [userName, userAge] = useUnit([$userName, $userAge]); + const [updateUser] = useUnit([updateUserEvent]); + + return ( +
+

{userName}, {userAge}

+ +
+ ); +}; +``` + +## Object shape examples + +```tsx +// 👎 incorrect +const Component = () => { + const { userName } = useUnit({ userName: $userName }); + const { userAge } = useUnit({ userAge: $userAge }); + const { updateUser } = useUnit({ updateUser: updateUserEvent }); + + return ( +
+

{userName}, {userAge}

+ +
+ ); +}; +``` + +```tsx +// 👍 correct - combined (default behavior) +const Component = () => { + const { userName, userAge, updateUser } = useUnit({ + userName: $userName, + userAge: $userAge, + updateUser: updateUserEvent, + }); + + return ( +
+

{userName}, {userAge}

+ +
+ ); +}; +``` + +```tsx +// 👍 also correct - separated (with enforceStoresAndEventsSeparation: true) +const Component = () => { + const { userName, userAge } = useUnit({ + userName: $userName, + userAge: $userAge, + }); + const { updateUser } = useUnit({ updateUser: updateUserEvent }); + + return ( +
+

{userName}, {userAge}

+ +
+ ); +}; +``` + +## Real-world example + +```tsx +import React from "react"; +import { createEvent, createStore } from "effector"; +import { useUnit } from "effector-react"; + +const $userName = createStore("John"); +const $userEmail = createStore("john@example.com"); +const $isLoading = createStore(false); +const updateNameEvent = createEvent(); +const updateEmailEvent = createEvent(); + +// 👎 incorrect - scattered useUnit calls (default behavior) +const UserProfile = () => { + const [userName] = useUnit([$userName]); + const [userEmail] = useUnit([$userEmail]); + const [isLoading] = useUnit([$isLoading]); + const [updateName] = useUnit([updateNameEvent]); + const [updateEmail] = useUnit([updateEmailEvent]); + + return ( +
+ {isLoading ? ( +

Loading...

+ ) : ( + <> + updateName(e.target.value)} /> + updateEmail(e.target.value)} /> + + )} +
+ ); +}; + +// 👍 correct - single useUnit call (default behavior) +const UserProfile = () => { + const [userName, userEmail, isLoading, updateName, updateEmail] = useUnit([ + $userName, + $userEmail, + $isLoading, + updateNameEvent, + updateEmailEvent, + ]); + + return ( +
+ {isLoading ? ( +

Loading...

+ ) : ( + <> + updateName(e.target.value)} /> + updateEmail(e.target.value)} /> + + )} +
+ ); +}; + +// 👍 also correct - separated stores and events +// (with allowSeparateStoresAndEvents: true or enforceStoresAndEventsSeparation: true) +const UserProfile = () => { + const [userName, userEmail, isLoading] = useUnit([ + $userName, + $userEmail, + $isLoading, + ]); + + const [updateName, updateEmail] = useUnit([ + updateNameEvent, + updateEmailEvent, + ]); + + return ( +
+ {isLoading ? ( +

Loading...

+ ) : ( + <> + updateName(e.target.value)} /> + updateEmail(e.target.value)} /> + + )} +
+ ); +}; +``` + +## Auto-fix + +This rule provides automatic fixes based on the configuration: + +### Default behavior +When you run ESLint with the `--fix` flag, it will combine all `useUnit` calls into a single one: + +```bash +eslint --fix your-file.tsx +``` + +### With `enforceStoresAndEventsSeparation: true` +The auto-fix will split mixed `useUnit` calls into separate calls for stores and events: + +```tsx +// Before +const [value, setValue] = useUnit([$store, event]); + +// After auto-fix +const [value] = useUnit([$store]); +const [setValue] = useUnit([event]); +``` + +### With both options enabled +The auto-fix will both split mixed calls and combine multiple calls of the same type: + +```tsx +// Before +const [value1] = useUnit([$store1]); +const [value2, handler] = useUnit([$store2, event1]); +const [handler2] = useUnit([event2]); + +// After auto-fix +const [value1, value2] = useUnit([$store1, $store2]); +const [handler, handler2] = useUnit([event1, event2]); +``` + +## Configuration examples + +### Strict single call (default) +```javascript +// .eslintrc.js +export default { + rules: { + 'effector/prefer-single-binding': 'warn' + } +}; +``` + +### Allow stores/events separation +```javascript +// .eslintrc.js +export default { + rules: { + 'effector/prefer-single-binding': ['warn', { + allowSeparateStoresAndEvents: true + }] + } +}; +``` + +### Enforce stores/events separation +```javascript +// .eslintrc.js +export default { + rules: { + 'effector/prefer-single-binding': ['warn', { + enforceStoresAndEventsSeparation: true + }] + } +}; +``` + +### Enforce separation and combine duplicates +```javascript +// .eslintrc.js +export default { + rules: { + 'effector/prefer-single-binding': ['warn', { + allowSeparateStoresAndEvents: true, + enforceStoresAndEventsSeparation: true + }] + } +}; +``` + +## When Not To Use It + +In rare cases, you might want to keep `useUnit` calls separate for specific reasons: + +```tsx +/* eslint-disable effector/prefer-single-binding */ +const Component = () => { + const [userStore] = useUnit([$userStore]); + + // Some complex logic that depends on userStore... + if (!userStore) return null; + + const [settingsStore] = useUnit([$settingsStore]); + + return null; +}; +/* eslint-enable effector/prefer-single-binding */ +``` + +However, even in these cases, consider refactoring to use a single `useUnit` call (or enabling the appropriate options) for better performance and clarity. + +## References + +- [useUnit API documentation](https://effector.dev/en/api/effector-react/useunit/) +- [Effector React hooks best practices](https://effector.dev/en/api/effector-react/) +``` diff --git a/src/rules/prefer-single-binding/prefer-single-binding.test.ts b/src/rules/prefer-single-binding/prefer-single-binding.test.ts new file mode 100644 index 0000000..a8dff05 --- /dev/null +++ b/src/rules/prefer-single-binding/prefer-single-binding.test.ts @@ -0,0 +1,386 @@ +import { RuleTester } from "@typescript-eslint/rule-tester" + +import rule from "./prefer-single-binding" + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaVersion: 2020, + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, + }, +}) + +ruleTester.run("effector/prefer-single-binding", rule, { + valid: [ + // With enforceStoresAndEventsSeparation - already separated + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [store] = useUnit([$store]); + const [event] = useUnit([event]); + return null; + }; + `, + options: [{ enforceStoresAndEventsSeparation: true }], + }, + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [isFormSent] = useUnit([HelpFormModel.$isFormSent]); + const [sent, submit] = useUnit([HelpFormModel.sentFormChanged, HelpFormModel.submitHelpForm]); + return null; + }; + `, + options: [{ allowSeparateStoresAndEvents: true }], + }, + // Once useUnit call - OK + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [store, event] = useUnit([$store, event]); + + return null; + }; + `, + }, + // Once useUnit with object-shape - OK + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const { store, event } = useUnit({ store: $store, event: event }); + + return null; + }; + `, + }, + // useUnit outside of components - dont check + { + code: ` + import { useUnit } from "effector-react"; + const store = useUnit([$store]); + const event = useUnit([event]); + `, + }, + ], + + invalid: [ + // Two useUnit calls with array-shape + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [store] = useUnit([$store]); + const [event] = useUnit([event]); + + return null; + }; + `, + errors: [ + { + messageId: "multipleUseUnit", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [store, event] = useUnit([$store, event]); + + return null; + }; + `, + }, + // Three useUnit with array-shape + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [store] = useUnit([$store]); + const [event] = useUnit([event]); + const [another] = useUnit([$another]); + + return null; + }; + `, + errors: [ + { + messageId: "multipleUseUnit", + }, + { + messageId: "multipleUseUnit", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [store, event, another] = useUnit([$store, event, $another]); + + return null; + }; + `, + }, + // Two useUnit calls with object-shape + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const { store } = useUnit({ store: $store }); + const { event } = useUnit({ event: event }); + + return null; + }; + `, + errors: [ + { + messageId: "multipleUseUnit", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const { store, event } = useUnit({ store: $store, event: event }); + + return null; + }; + `, + }, + // Multiple useUnit calls + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [store1, store2] = useUnit([$store1, $store2]); + const [event1, event2] = useUnit([event1, event2]); + + return null; + }; + `, + errors: [ + { + messageId: "multipleUseUnit", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [store1, store2, event1, event2] = useUnit([$store1, $store2, event1, event2]); + + return null; + }; + `, + }, + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [isFormSent] = useUnit([HelpFormModel.$isFormSent]); + const [sent] = useUnit([HelpFormModel.sentFormChanged]); + const [submit] = useUnit([HelpFormModel.submitHelpForm]); + return null; + }; + `, + options: [{ allowSeparateStoresAndEvents: true }], + errors: [ + { + messageId: "multipleUseUnit", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [isFormSent] = useUnit([HelpFormModel.$isFormSent]); + const [sent, submit] = useUnit([HelpFormModel.sentFormChanged, HelpFormModel.submitHelpForm]); + return null; + }; + `, + }, + // MemberExpression - два стора должны объединиться + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [isFormSent] = useUnit([HelpFormModel.$isFormSent]); + const [isLoading] = useUnit([HelpFormModel.$isLoading]); + const [submit] = useUnit([HelpFormModel.submitHelpForm]); + return null; + }; + `, + options: [{ allowSeparateStoresAndEvents: true }], + errors: [ + { + messageId: "multipleUseUnit", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [isFormSent, isLoading] = useUnit([HelpFormModel.$isFormSent, HelpFormModel.$isLoading]); + const [submit] = useUnit([HelpFormModel.submitHelpForm]); + return null; + }; + `, + }, + // Смешанные паттерны именования + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [isVisible] = useUnit([Model.isVisible]); + const [hasError] = useUnit([Model.hasError]); + const [onClick] = useUnit([Model.onClick]); + const [handleSubmit] = useUnit([Model.handleSubmit]); + return null; + }; + `, + options: [{ allowSeparateStoresAndEvents: true }], + errors: [ + { + messageId: "multipleUseUnit", + }, + { + messageId: "multipleUseUnit", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [isVisible, hasError] = useUnit([Model.isVisible, Model.hasError]); + const [onClick, handleSubmit] = useUnit([Model.onClick, Model.handleSubmit]); + return null; + }; + `, + }, + // Без опции - все должно объединиться + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [isFormSent] = useUnit([HelpFormModel.$isFormSent]); + const [sent] = useUnit([HelpFormModel.sentFormChanged]); + const [submit] = useUnit([HelpFormModel.submitHelpForm]); + return null; + }; + `, + errors: [ + { + messageId: "multipleUseUnit", + }, + { + messageId: "multipleUseUnit", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [isFormSent, sent, submit] = useUnit([HelpFormModel.$isFormSent, HelpFormModel.sentFormChanged, HelpFormModel.submitHelpForm]); + return null; + }; + `, + }, + // enforceStoresAndEventsSeparation - mixed array + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [value, setValue] = useUnit([$store, event]); + return null; + }; + `, + options: [{ enforceStoresAndEventsSeparation: true }], + errors: [ + { + messageId: "mixedStoresAndEvents", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [value] = useUnit([$store]); + const [setValue] = useUnit([event]); + return null; + }; + `, + }, + // enforceStoresAndEventsSeparation - mixed array with multiple items + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [value1, value2, handler1, handler2] = useUnit([$store1, $store2, event1, event2]); + return null; + }; + `, + options: [{ enforceStoresAndEventsSeparation: true }], + errors: [ + { + messageId: "mixedStoresAndEvents", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [value1, value2] = useUnit([$store1, $store2]); + const [handler1, handler2] = useUnit([event1, event2]); + return null; + }; + `, + }, + // enforceStoresAndEventsSeparation - mixed object + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const { value, setValue } = useUnit({ value: $store, setValue: event }); + return null; + }; + `, + options: [{ enforceStoresAndEventsSeparation: true }], + errors: [ + { + messageId: "mixedStoresAndEvents", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const { value } = useUnit({ value: $store }); + const { setValue } = useUnit({ setValue: event }); + return null; + }; + `, + }, + // Real example with model + { + code: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [isFormSent, submit, reset] = useUnit([ + FormModel.$isFormSent, + FormModel.submitForm, + FormModel.resetForm, + ]); + return null; + }; + `, + options: [{ enforceStoresAndEventsSeparation: true }], + errors: [ + { + messageId: "mixedStoresAndEvents", + }, + ], + output: ` + import { useUnit } from "effector-react"; + const Component = () => { + const [isFormSent] = useUnit([FormModel.$isFormSent]); + const [submit, reset] = useUnit([FormModel.submitForm, FormModel.resetForm]); + return null; + }; + `, + }, + ], +}) diff --git a/src/rules/prefer-single-binding/prefer-single-binding.ts b/src/rules/prefer-single-binding/prefer-single-binding.ts new file mode 100644 index 0000000..83a1c2b --- /dev/null +++ b/src/rules/prefer-single-binding/prefer-single-binding.ts @@ -0,0 +1,453 @@ +import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils" +import type { RuleContext, RuleFix, RuleFixer } from "@typescript-eslint/utils/ts-eslint" + +import { createRule } from "@/shared/create" + +type MessageIds = "multipleUseUnit" | "mixedStoresAndEvents" +type Options = [{ allowSeparateStoresAndEvents?: boolean; enforceStoresAndEventsSeparation?: boolean }?] +type Context = RuleContext +type Fixer = RuleFixer + +type UseUnitCall = { + statement: TSESTree.VariableDeclaration + declarator: TSESTree.VariableDeclarator + init: TSESTree.CallExpression + id: TSESTree.VariableDeclarator["id"] +} + +type ElementInfo = { + element: TSESTree.Expression + type: string + index: number +} + +type PropertyInfo = { + property: TSESTree.Property + type: string + index: number +} + +type MixedTypes = { + stores: ElementInfo[] | PropertyInfo[] + events: ElementInfo[] | PropertyInfo[] + allTypes: (ElementInfo | PropertyInfo)[] + isObject?: boolean +} + +export default createRule({ + name: "prefer-single-binding", + meta: { + type: "suggestion", + docs: { + description: "Recommend using a single useUnit call instead of multiple", + }, + messages: { + multipleUseUnit: + "Multiple useUnit calls detected. Consider combining them into a single call for better performance.", + mixedStoresAndEvents: + "useUnit call contains both stores and events. Consider separating them into different calls.", + }, + schema: [ + { + type: "object", + properties: { + allowSeparateStoresAndEvents: { + type: "boolean", + default: false, + }, + enforceStoresAndEventsSeparation: { + type: "boolean", + default: false, + }, + }, + additionalProperties: false, + }, + ], + fixable: "code", + }, + defaultOptions: [], + create: (context) => { + const options = context.options[0] ?? {} + const allowSeparateStoresAndEvents = options.allowSeparateStoresAndEvents ?? false + const enforceStoresAndEventsSeparation = options.enforceStoresAndEventsSeparation ?? false + + return { + "FunctionDeclaration, FunctionExpression, ArrowFunctionExpression"( + node: TSESTree.FunctionDeclaration | TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression, + ) { + const body = node.body.type === AST_NODE_TYPES.BlockStatement ? node.body.body : null + if (!body) return + + const useUnitCalls: UseUnitCall[] = [] + + body.forEach((statement) => { + if (statement.type === AST_NODE_TYPES.VariableDeclaration && statement.declarations.length > 0) { + statement.declarations.forEach((declarator) => { + if ( + declarator.init && + declarator.init.type === AST_NODE_TYPES.CallExpression && + declarator.init.callee.type === AST_NODE_TYPES.Identifier && + declarator.init.callee.name === "useUnit" + ) { + useUnitCalls.push({ + statement, + declarator, + init: declarator.init, + id: declarator.id, + }) + } + }) + } + }) + + if (enforceStoresAndEventsSeparation) { + useUnitCalls.forEach((call) => { + const mixedTypes = checkMixedTypes(call) + if (mixedTypes) { + context.report({ + node: call.init, + messageId: "mixedStoresAndEvents", + fix(fixer) { + return generateSeparationFix(fixer, call, mixedTypes, context) + }, + }) + } + }) + return + } + + if (useUnitCalls.length > 1) { + if (allowSeparateStoresAndEvents) { + const groups = groupByType(useUnitCalls) + if (groups.stores.length > 1) reportMultipleCalls(context, groups.stores) + if (groups.events.length > 1) reportMultipleCalls(context, groups.events) + if (groups.unknown.length > 1) reportMultipleCalls(context, groups.unknown) + } else { + useUnitCalls.forEach((call, index) => { + if (index > 0) { + context.report({ + node: call.init, + messageId: "multipleUseUnit", + fix(fixer) { + return generateFix(fixer, useUnitCalls, context) + }, + }) + } + }) + } + } + }, + } + }, +}) + +function checkMixedTypes(call: UseUnitCall): MixedTypes | null { + const argument = call.init.arguments[0] + if (!argument || argument.type === AST_NODE_TYPES.SpreadElement) return null + + if (argument.type === AST_NODE_TYPES.ArrayExpression) { + const elements = argument.elements.filter( + (el): el is TSESTree.Expression => el !== null && el.type !== AST_NODE_TYPES.SpreadElement, + ) + if (elements.length === 0) return null + + const types: ElementInfo[] = elements.map((element, index) => ({ + element, + type: getElementType(element), + index, + })) + + const stores = types.filter((t) => t.type === "store") + const events = types.filter((t) => t.type === "event") + + if (stores.length > 0 && events.length > 0) { + return { stores, events, allTypes: types } + } + } + + if (argument.type === AST_NODE_TYPES.ObjectExpression) { + const properties = argument.properties.filter( + (prop): prop is TSESTree.Property => prop.type === AST_NODE_TYPES.Property, + ) + if (properties.length === 0) return null + + const types: PropertyInfo[] = properties.map((prop, index) => ({ + property: prop, + type: getElementType(prop.value as TSESTree.Expression), + index, + })) + + const stores = types.filter((t) => t.type === "store") + const events = types.filter((t) => t.type === "event") + + if (stores.length > 0 && events.length > 0) { + return { stores, events, allTypes: types, isObject: true } + } + } + + return null +} + +function generateSeparationFix(fixer: Fixer, call: UseUnitCall, mixedTypes: MixedTypes, context: Context): RuleFix[] { + const sourceCode = context.sourceCode + + const { stores, events, isObject } = mixedTypes + const fixes: RuleFix[] = [] + + if (isObject) { + const storeInfos = stores as PropertyInfo[] + const eventInfos = events as PropertyInfo[] + + const storeProps = storeInfos.map((s) => sourceCode.getText(s.property)) + const eventProps = eventInfos.map((e) => sourceCode.getText(e.property)) + + const storeKeys = storeInfos.map((s) => { + const key = s.property.key + return key.type === AST_NODE_TYPES.Identifier ? key.name : sourceCode.getText(key) + }) + + const eventKeys = eventInfos.map((e) => { + const key = e.property.key + return key.type === AST_NODE_TYPES.Identifier ? key.name : sourceCode.getText(key) + }) + + const statementRange = call.statement.range + if (!statementRange) return [] + const lineStart = sourceCode.text.lastIndexOf("\n", statementRange[0] - 1) + 1 + const indent = sourceCode.text.slice(lineStart, statementRange[0]) + + const storesCode = `const { ${storeKeys.join(", ")} } = useUnit({ ${storeProps.join(", ")} });` + const eventsCode = `const { ${eventKeys.join(", ")} } = useUnit({ ${eventProps.join(", ")} });` + + fixes.push(fixer.replaceText(call.statement, `${storesCode}\n${indent}${eventsCode}`)) + } else { + const storeInfos = stores as ElementInfo[] + const eventInfos = events as ElementInfo[] + + const storeElements = storeInfos.map((s) => sourceCode.getText(s.element)) + const eventElements = eventInfos.map((e) => sourceCode.getText(e.element)) + + const destructured = call.id.type === AST_NODE_TYPES.ArrayPattern ? call.id.elements : [] + + const storeNames = storeInfos + .map((s) => { + const el = destructured[s.index] + return el ? sourceCode.getText(el) : null + }) + .filter((x): x is string => x !== null) + + const eventNames = eventInfos + .map((e) => { + const el = destructured[e.index] + return el ? sourceCode.getText(el) : null + }) + .filter((x): x is string => x !== null) + + const statementRange = call.statement.range + if (!statementRange) return [] + const lineStart = sourceCode.text.lastIndexOf("\n", statementRange[0] - 1) + 1 + const indent = sourceCode.text.slice(lineStart, statementRange[0]) + + const storesCode = `const [${storeNames.join(", ")}] = useUnit([${storeElements.join(", ")}]);` + const eventsCode = `const [${eventNames.join(", ")}] = useUnit([${eventElements.join(", ")}]);` + + fixes.push(fixer.replaceText(call.statement, `${storesCode}\n${indent}${eventsCode}`)) + } + + return fixes +} + +function getElementType(element: TSESTree.Node | null | undefined): string { + if (!element) return "unknown" + + if (element.type === AST_NODE_TYPES.Identifier) { + return element.name.startsWith("$") ? "store" : "event" + } + + if (element.type === AST_NODE_TYPES.MemberExpression) { + const property = element.property + if (property.type === AST_NODE_TYPES.Identifier) { + const name = property.name + + if (name.startsWith("$")) return "store" + + const eventPatterns = [ + /Event$/i, + /Changed$/i, + /Triggered$/i, + /Clicked$/i, + /Pressed$/i, + /^on[A-Z]/, + /^handle[A-Z]/, + /^set[A-Z]/, + /^update[A-Z]/, + /^submit[A-Z]/, + ] + + const storePatterns = [/^is[A-Z]/, /^has[A-Z]/, /Store$/i, /State$/i, /^data$/i, /^value$/i, /^items$/i] + + if (eventPatterns.some((p) => p.test(name))) return "event" + if (storePatterns.some((p) => p.test(name))) return "store" + + return "event" + } + } + + return "unknown" +} + +function getUnitType(call: UseUnitCall): string { + const argument = call.init.arguments[0] + if (!argument || argument.type === AST_NODE_TYPES.SpreadElement) return "unknown" + + const scoreTypes = (elements: string[]): string => { + const storeCount = elements.filter((t) => t === "store").length + const eventCount = elements.filter((t) => t === "event").length + const unknownCount = elements.filter((t) => t === "unknown").length + + if (storeCount === elements.length) return "store" + if (eventCount === elements.length) return "event" + if (unknownCount === elements.length) return "unknown" + if (storeCount > eventCount && storeCount > unknownCount) return "store" + if (eventCount > storeCount && eventCount > unknownCount) return "event" + return "unknown" + } + + if (argument.type === AST_NODE_TYPES.ArrayExpression) { + const elements = argument.elements.filter( + (el): el is TSESTree.Expression => el !== null && el.type !== AST_NODE_TYPES.SpreadElement, + ) + if (elements.length === 0) return "unknown" + return scoreTypes(elements.map((el) => getElementType(el))) + } + + if (argument.type === AST_NODE_TYPES.ObjectExpression) { + const properties = argument.properties.filter( + (prop): prop is TSESTree.Property => prop.type === AST_NODE_TYPES.Property, + ) + if (properties.length === 0) return "unknown" + return scoreTypes(properties.map((prop) => getElementType(prop.value as TSESTree.Expression))) + } + + return "unknown" +} + +function groupByType(useUnitCalls: UseUnitCall[]) { + const stores: UseUnitCall[] = [] + const events: UseUnitCall[] = [] + const unknown: UseUnitCall[] = [] + + useUnitCalls.forEach((call) => { + const type = getUnitType(call) + if (type === "store") stores.push(call) + else if (type === "event") events.push(call) + else unknown.push(call) + }) + + return { stores, events, unknown } +} + +function reportMultipleCalls(context: Context, calls: UseUnitCall[]) { + calls.forEach((call, index) => { + if (index > 0) { + context.report({ + node: call.init, + messageId: "multipleUseUnit", + fix(fixer) { + return generateFix(fixer, calls, context) + }, + }) + } + }) +} + +function generateFix(fixer: Fixer, useUnitCalls: UseUnitCall[], context: Context): RuleFix[] | null { + const sourceCode = context.sourceCode + + const firstArg = useUnitCalls[0]?.init.arguments[0] + if (!firstArg || firstArg.type === AST_NODE_TYPES.SpreadElement) return null + + const isArrayForm = firstArg.type === AST_NODE_TYPES.ArrayExpression + const isObjectForm = firstArg.type === AST_NODE_TYPES.ObjectExpression + + const allSameForm = useUnitCalls.every((call) => { + const arg = call.init.arguments[0] + if (!arg || arg.type === AST_NODE_TYPES.SpreadElement) return false + if (isArrayForm) return arg.type === AST_NODE_TYPES.ArrayExpression + if (isObjectForm) return arg.type === AST_NODE_TYPES.ObjectExpression + return false + }) + + if (!allSameForm) return null + + const fixes: RuleFix[] = [] + + const removeStatement = (statement?: TSESTree.VariableDeclaration) => { + const range = statement?.range + if (!range) return + + let startIndex = range[0] + const lineStart = sourceCode.text.lastIndexOf("\n", startIndex - 1) + 1 + const textBefore = sourceCode.text.slice(lineStart, startIndex) + + if (/^\s*$/.test(textBefore)) startIndex = lineStart + + const endIndex = range[1] + const nextChar = sourceCode.text[endIndex] + const removeEnd = nextChar === "\n" || nextChar === "\r" ? endIndex + 1 : endIndex + + fixes.push(fixer.removeRange([startIndex, removeEnd])) + } + + if (isArrayForm) { + const allElements: string[] = [] + const allDestructured: string[] = [] + + useUnitCalls.forEach((call) => { + const arg = call.init.arguments[0] + if (arg && arg.type === AST_NODE_TYPES.ArrayExpression) { + arg.elements.forEach((el) => { + if (el) allElements.push(sourceCode.getText(el)) + }) + } + + if (call.id.type === AST_NODE_TYPES.ArrayPattern) { + call.id.elements.forEach((el) => { + if (el) allDestructured.push(sourceCode.getText(el)) + }) + } + }) + + const combinedCode = `const [${allDestructured.join(", ")}] = useUnit([${allElements.join(", ")}]);` + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + useUnitCalls[0]?.statement && fixes.push(fixer.replaceText(useUnitCalls[0]?.statement, combinedCode)) + + for (let i = 1; i < useUnitCalls.length; i++) { + removeStatement(useUnitCalls[i]?.statement) + } + } else if (isObjectForm) { + const allProperties: string[] = [] + const allDestructuredProps: string[] = [] + + useUnitCalls.forEach((call) => { + const arg = call.init.arguments[0] + if (arg && arg.type === AST_NODE_TYPES.ObjectExpression) { + arg.properties.forEach((prop) => allProperties.push(sourceCode.getText(prop))) + } + + if (call.id.type === AST_NODE_TYPES.ObjectPattern) { + call.id.properties.forEach((prop) => allDestructuredProps.push(sourceCode.getText(prop))) + } + }) + + const combinedCode = `const { ${allDestructuredProps.join(", ")} } = useUnit({ ${allProperties.join(", ")} });` + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + useUnitCalls[0]?.statement && fixes.push(fixer.replaceText(useUnitCalls[0]?.statement, combinedCode)) + + for (let i = 1; i < useUnitCalls.length; i++) { + removeStatement(useUnitCalls[i]?.statement) + } + } + + return fixes +} diff --git a/src/ruleset.ts b/src/ruleset.ts index b11fbd2..9ac9888 100644 --- a/src/ruleset.ts +++ b/src/ruleset.ts @@ -13,6 +13,7 @@ const recommended = { "effector/no-unnecessary-duplication": "warn", "effector/no-useless-methods": "error", "effector/no-watch": "warn", + "effector/prefer-single-binding": "warn", } satisfies TSESLint.Linter.RulesRecord const patronum = {