diff --git a/.changeset/small-months-exercise.md b/.changeset/small-months-exercise.md new file mode 100644 index 000000000..1f017385d --- /dev/null +++ b/.changeset/small-months-exercise.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-svelte': major +--- + +Adds `prefer-let` rule diff --git a/README.md b/README.md index 35c16c8e8..d55422f97 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,7 @@ These rules relate to better ways of doing things to help you avoid problems: | [svelte/no-useless-mustaches](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-useless-mustaches/) | disallow unnecessary mustache interpolations | :wrench: | | [svelte/prefer-const](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-const/) | Require `const` declarations for variables that are never reassigned after declared | :wrench: | | [svelte/prefer-destructured-store-props](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-destructured-store-props/) | destructure values from object stores for better change tracking & fewer redraws | :bulb: | +| [svelte/prefer-let](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-let/) | Prefer `let` over `const` for Svelte 5 reactive variable declarations. | :wrench: | | [svelte/require-each-key](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-each-key/) | require keyed `{#each}` block | | | [svelte/require-event-dispatcher-types](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-event-dispatcher-types/) | require type parameters for `createEventDispatcher` | | | [svelte/require-optimized-style-attribute](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-optimized-style-attribute/) | require style attributes that can be optimized | | diff --git a/docs/rules.md b/docs/rules.md index 047b2ab29..b6dfa93df 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -67,6 +67,7 @@ These rules relate to better ways of doing things to help you avoid problems: | [svelte/no-useless-mustaches](./rules/no-useless-mustaches.md) | disallow unnecessary mustache interpolations | :wrench: | | [svelte/prefer-const](./rules/prefer-const.md) | Require `const` declarations for variables that are never reassigned after declared | :wrench: | | [svelte/prefer-destructured-store-props](./rules/prefer-destructured-store-props.md) | destructure values from object stores for better change tracking & fewer redraws | :bulb: | +| [svelte/prefer-let](./rules/prefer-let.md) | Prefer `let` over `const` for Svelte 5 reactive variable declarations. | :wrench: | | [svelte/require-each-key](./rules/require-each-key.md) | require keyed `{#each}` block | | | [svelte/require-event-dispatcher-types](./rules/require-event-dispatcher-types.md) | require type parameters for `createEventDispatcher` | | | [svelte/require-optimized-style-attribute](./rules/require-optimized-style-attribute.md) | require style attributes that can be optimized | | diff --git a/docs/rules/prefer-let.md b/docs/rules/prefer-let.md new file mode 100644 index 000000000..d56ec034b --- /dev/null +++ b/docs/rules/prefer-let.md @@ -0,0 +1,58 @@ +--- +pageClass: 'rule-details' +sidebarDepth: 0 +title: 'svelte/prefer-let' +description: 'Prefer `let` over `const` for Svelte 5 reactive variable declarations.' +--- + +# svelte/prefer-let + +> Prefer `let` over `const` for Svelte 5 reactive variable declarations. + +- :exclamation: **_This rule has not been released yet._** +- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule. + +## :book: Rule Details + +This rule reports usages of `const` variable declarations on Svelte reactive +function assignments. While values may not be reassigned in the code itself, +they are reassigned by Svelte. + + + +```svelte + +``` + +## :wrench: Options + +```json +{ + "svelte/prefer-const": [ + "error", + { + "exclude": ["$props", "$derived", "$derived.by", "$state", "$state.raw"] + } + ] +} +``` + +- `exclude`: The reactive assignments that you want to exclude from being + reported. + +## :mag: Implementation + +- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/rules/prefer-let.ts) +- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/tests/src/rules/prefer-let.ts) diff --git a/packages/eslint-plugin-svelte/src/rule-types.ts b/packages/eslint-plugin-svelte/src/rule-types.ts index b07451a1b..8904adb8e 100644 --- a/packages/eslint-plugin-svelte/src/rule-types.ts +++ b/packages/eslint-plugin-svelte/src/rule-types.ts @@ -285,6 +285,11 @@ export interface RuleOptions { * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-destructured-store-props/ */ 'svelte/prefer-destructured-store-props'?: Linter.RuleEntry<[]> + /** + * Prefer `let` over `const` for Svelte 5 reactive variable declarations. + * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-let/ + */ + 'svelte/prefer-let'?: Linter.RuleEntry /** * require style directives instead of style attribute * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-style-directive/ @@ -513,6 +518,10 @@ type SveltePreferConst = []|[{ destructuring?: ("any" | "all") ignoreReadBeforeAssign?: boolean }] +// ----- svelte/prefer-let ----- +type SveltePreferLet = []|[{ + exclude?: ("$props" | "$derived" | "$derived.by" | "$state" | "$state.raw")[] +}] // ----- svelte/shorthand-attribute ----- type SvelteShorthandAttribute = []|[{ prefer?: ("always" | "never") diff --git a/packages/eslint-plugin-svelte/src/rules/prefer-let.ts b/packages/eslint-plugin-svelte/src/rules/prefer-let.ts new file mode 100644 index 000000000..929b3c1ca --- /dev/null +++ b/packages/eslint-plugin-svelte/src/rules/prefer-let.ts @@ -0,0 +1,90 @@ +import type { TSESTree } from '@typescript-eslint/types'; + +import { createRule } from '../utils/index.js'; + +type ReactiveFunction = '$props' | '$derived' | '$derived.by' | '$state' | '$state.raw'; +const DEFAULT_FUNCTIONS: ReactiveFunction[] = [ + '$props', + '$derived', + '$derived.by', + '$state', + '$state.raw' +]; + +function getReactiveFunction(callExpr: TSESTree.CallExpression, validNames: string[]) { + if (callExpr.callee.type === 'Identifier') { + if (validNames.includes(callExpr.callee.name)) { + return callExpr.callee.name as ReactiveFunction; + } + } else if ( + callExpr.callee.type === 'MemberExpression' && + callExpr.callee.object.type === 'Identifier' && + callExpr.callee.property.type === 'Identifier' + ) { + const fullName = `${callExpr.callee.object.name}.${callExpr.callee.property.name}`; + + if (validNames.includes(fullName)) { + return fullName as ReactiveFunction; + } + } + + return null; +} + +export default createRule('prefer-let', { + meta: { + docs: { + description: 'Prefer `let` over `const` for Svelte 5 reactive variable declarations.', + category: 'Best Practices', + recommended: false + }, + schema: [ + { + type: 'object', + properties: { + exclude: { + type: 'array', + items: { + enum: ['$props', '$derived', '$derived.by', '$state', '$state.raw'] + }, + uniqueItems: true + } + }, + additionalProperties: false + } + ], + messages: { + 'use-let': "'const' is used for a reactive declaration from {{rune}}. Use 'let' instead." + }, + type: 'suggestion', + fixable: 'code' + }, + create(context) { + const exclude = context.options[0]?.exclude ?? []; + const allowedNames = DEFAULT_FUNCTIONS.filter((name) => !exclude.includes(name)); + + return { + VariableDeclaration(node: TSESTree.VariableDeclaration) { + if (node.kind === 'const') { + node.declarations.forEach((declarator) => { + const init = declarator.init; + + if (!init || init.type !== 'CallExpression') { + return; + } + + const rune = getReactiveFunction(init, allowedNames); + if (rune) { + context.report({ + node, + messageId: 'use-let', + data: { rune }, + fix: (fixer) => fixer.replaceTextRange([node.range[0], node.range[0] + 5], 'let') + }); + } + }); + } + } + }; + } +}); diff --git a/packages/eslint-plugin-svelte/src/utils/rules.ts b/packages/eslint-plugin-svelte/src/utils/rules.ts index cb0cbe896..bb32675f8 100644 --- a/packages/eslint-plugin-svelte/src/utils/rules.ts +++ b/packages/eslint-plugin-svelte/src/utils/rules.ts @@ -56,6 +56,7 @@ import noUselessMustaches from '../rules/no-useless-mustaches.js'; import preferClassDirective from '../rules/prefer-class-directive.js'; import preferConst from '../rules/prefer-const.js'; import preferDestructuredStoreProps from '../rules/prefer-destructured-store-props.js'; +import preferLet from '../rules/prefer-let.js'; import preferStyleDirective from '../rules/prefer-style-directive.js'; import requireEachKey from '../rules/require-each-key.js'; import requireEventDispatcherTypes from '../rules/require-event-dispatcher-types.js'; @@ -127,6 +128,7 @@ export const rules = [ preferClassDirective, preferConst, preferDestructuredStoreProps, + preferLet, preferStyleDirective, requireEachKey, requireEventDispatcherTypes, diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/_config.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/_config.json new file mode 100644 index 000000000..60db6d6ea --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/_config.json @@ -0,0 +1 @@ +{ "options": [{ "exclude": ["$derived", "$derived.by"] }] } diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-errors.yaml new file mode 100644 index 000000000..2f5b37747 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-errors.yaml @@ -0,0 +1,4 @@ +- message: "'const' is used for a reactive declaration from $state. Use 'let' instead." + line: 2 + column: 2 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-input.svelte new file mode 100644 index 000000000..602a48115 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-input.svelte @@ -0,0 +1,5 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-output.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-output.svelte new file mode 100644 index 000000000..52b619e99 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-output.svelte @@ -0,0 +1,5 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-errors.yaml new file mode 100644 index 000000000..d4a3b8a5d --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-errors.yaml @@ -0,0 +1,23 @@ +- message: "'const' is used for a reactive declaration from $props. Use 'let' instead." + line: 2 + column: 2 + suggestions: null +- message: "'const' is used for a reactive declaration from $state. Use 'let' instead." + line: 3 + column: 2 + suggestions: null +- message: "'const' is used for a reactive declaration from $state.raw. Use 'let' + instead." + line: 4 + column: 2 + suggestions: null +- message: "'const' is used for a reactive declaration from $derived. Use 'let' + instead." + line: 5 + column: 2 + suggestions: null +- message: "'const' is used for a reactive declaration from $derived.by. Use 'let' + instead." + line: 6 + column: 2 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-input.svelte new file mode 100644 index 000000000..0984af843 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-input.svelte @@ -0,0 +1,7 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-output.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-output.svelte new file mode 100644 index 000000000..ab587a28f --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-output.svelte @@ -0,0 +1,7 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/valid/test01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/valid/test01-input.svelte new file mode 100644 index 000000000..ab587a28f --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/valid/test01-input.svelte @@ -0,0 +1,7 @@ + diff --git a/packages/eslint-plugin-svelte/tests/src/rules/prefer-const.ts b/packages/eslint-plugin-svelte/tests/src/rules/prefer-const.ts index ffe176d0a..2dd42eeaa 100644 --- a/packages/eslint-plugin-svelte/tests/src/rules/prefer-const.ts +++ b/packages/eslint-plugin-svelte/tests/src/rules/prefer-const.ts @@ -1,6 +1,6 @@ -import { RuleTester } from '../../utils/eslint-compat'; -import rule from '../../../src/rules/prefer-const'; -import { loadTestCases } from '../../utils/utils'; +import { RuleTester } from '../../utils/eslint-compat.js'; +import rule from '../../../src/rules/prefer-const.js'; +import { loadTestCases } from '../../utils/utils.js'; const tester = new RuleTester({ languageOptions: { diff --git a/packages/eslint-plugin-svelte/tests/src/rules/prefer-let.ts b/packages/eslint-plugin-svelte/tests/src/rules/prefer-let.ts new file mode 100644 index 000000000..292762556 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/src/rules/prefer-let.ts @@ -0,0 +1,12 @@ +import { RuleTester } from '../../utils/eslint-compat.js'; +import rule from '../../../src/rules/prefer-let.js'; +import { loadTestCases } from '../../utils/utils.js'; + +const tester = new RuleTester({ + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module' + }, +}); + +tester.run('prefer-let', rule as any, loadTestCases('prefer-let'));