Skip to content

Commit 6d0b89f

Browse files
authored
feat: implement derived-has-same-inputs-outputs (#249)
* feat: implement derived-has-same-inputs-outputs rule * chore: add changeset * chore: update according to review comments * chore: add more tests
1 parent 1690371 commit 6d0b89f

File tree

12 files changed

+265
-4
lines changed

12 files changed

+265
-4
lines changed

.changeset/gentle-bugs-stare.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-svelte": minor
3+
---
4+
5+
feat: add `svelte/derived-has-same-inputs-outputs` rule

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ These rules relate to better ways of doing things to help you avoid problems:
289289
| Rule ID | Description | |
290290
|:--------|:------------|:---|
291291
| [svelte/button-has-type](https://ota-meshi.github.io/eslint-plugin-svelte/rules/button-has-type/) | disallow usage of button without an explicit type attribute | |
292+
| [svelte/derived-has-same-inputs-outputs](https://ota-meshi.github.io/eslint-plugin-svelte/rules/derived-has-same-inputs-outputs/) | derived store should use same variable names between values and callback | |
292293
| [svelte/no-at-debug-tags](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-at-debug-tags/) | disallow the use of `{@debug}` | :star: |
293294
| [svelte/no-reactive-functions](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-reactive-functions/) | it's not necessary to define functions in reactive statements | :bulb: |
294295
| [svelte/no-reactive-literals](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-reactive-literals/) | don't assign literal values in reactive statements | :bulb: |

docs/rules.md

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ These rules relate to better ways of doing things to help you avoid problems:
4242
| Rule ID | Description | |
4343
|:--------|:------------|:---|
4444
| [svelte/button-has-type](./rules/button-has-type.md) | disallow usage of button without an explicit type attribute | |
45+
| [svelte/derived-has-same-inputs-outputs](./rules/derived-has-same-inputs-outputs.md) | derived store should use same variable names between values and callback | |
4546
| [svelte/no-at-debug-tags](./rules/no-at-debug-tags.md) | disallow the use of `{@debug}` | :star: |
4647
| [svelte/no-reactive-functions](./rules/no-reactive-functions.md) | it's not necessary to define functions in reactive statements | :bulb: |
4748
| [svelte/no-reactive-literals](./rules/no-reactive-literals.md) | don't assign literal values in reactive statements | :bulb: |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "svelte/derived-has-same-inputs-outputs"
5+
description: "derived store should use same variable names between values and callback"
6+
---
7+
8+
# svelte/derived-has-same-inputs-outputs
9+
10+
> derived store should use same variable names between values and callback
11+
12+
## :book: Rule Details
13+
14+
This rule reports where variable names and callback function's argument names are different.
15+
This is mainly a recommended rule to avoid implementation confusion.
16+
17+
<ESLintCodeBlock language="javascript">
18+
19+
<!--eslint-skip-->
20+
21+
```js
22+
/* eslint svelte/derived-has-same-inputs-outputs: "error" */
23+
24+
import { derived } from "svelte/store"
25+
26+
/* ✓ GOOD */
27+
derived(a, ($a) => {});
28+
derived(a, ($a, set) => {})
29+
derived([ a, b ], ([ $a, $b ]) => {})
30+
31+
/* ✗ BAD */
32+
derived(a, (b) => {});
33+
derived(a, (b, set) => {});
34+
derived([ a, b ], ([ one, two ]) => {})
35+
```
36+
37+
</ESLintCodeBlock>
38+
39+
## :wrench: Options
40+
41+
Nothing.
42+
43+
44+
## :books: Further Reading
45+
46+
- [Svelte - Docs > RUN TIME > svelte/store > derived](https://svelte.dev/docs#run-time-svelte-store-derived)
47+
48+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type * as ESTree from "estree"
2+
import { createRule } from "../utils"
3+
import type { RuleContext } from "../types"
4+
import { extractStoreReferences } from "./reference-helpers/svelte-store"
5+
6+
export default createRule("derived-has-same-inputs-outputs", {
7+
meta: {
8+
docs: {
9+
description:
10+
"derived store should use same variable names between values and callback",
11+
category: "Stylistic Issues",
12+
recommended: false,
13+
conflictWithPrettier: false,
14+
},
15+
schema: [],
16+
messages: {
17+
unexpected: "The argument name should be '{{name}}'.",
18+
},
19+
type: "suggestion",
20+
},
21+
create(context) {
22+
/** check node type */
23+
function isIdentifierOrArrayExpression(
24+
node: ESTree.SpreadElement | ESTree.Expression,
25+
): node is ESTree.Identifier | ESTree.ArrayExpression {
26+
return ["Identifier", "ArrayExpression"].includes(node.type)
27+
}
28+
29+
type ArrowFunctionExpressionOrFunctionExpression =
30+
| ESTree.ArrowFunctionExpression
31+
| ESTree.FunctionExpression
32+
33+
/** check node type */
34+
function isFunctionExpression(
35+
node: ESTree.SpreadElement | ESTree.Expression,
36+
): node is ArrowFunctionExpressionOrFunctionExpression {
37+
return ["ArrowFunctionExpression", "FunctionExpression"].includes(
38+
node.type,
39+
)
40+
}
41+
42+
/**
43+
* Check for identifier type.
44+
* e.g. derived(a, ($a) => {});
45+
*/
46+
function checkIdentifier(
47+
context: RuleContext,
48+
args: ESTree.Identifier,
49+
fn: ArrowFunctionExpressionOrFunctionExpression,
50+
) {
51+
const fnParam = fn.params[0]
52+
if (fnParam.type !== "Identifier") return
53+
const expectedName = `$${args.name}`
54+
if (expectedName !== fnParam.name) {
55+
context.report({
56+
node: fn,
57+
loc: fnParam.loc!,
58+
messageId: "unexpected",
59+
data: { name: expectedName },
60+
})
61+
}
62+
}
63+
64+
/**
65+
* Check for array type.
66+
* e.g. derived([ a, b ], ([ $a, $b ]) => {})
67+
*/
68+
function checkArrayExpression(
69+
context: RuleContext,
70+
args: ESTree.ArrayExpression,
71+
fn: ArrowFunctionExpressionOrFunctionExpression,
72+
) {
73+
const fnParam = fn.params[0]
74+
if (fnParam.type !== "ArrayPattern") return
75+
const argNames = args.elements.map((element) => {
76+
return element && element.type === "Identifier" ? element.name : null
77+
})
78+
fnParam.elements.forEach((element, index) => {
79+
const argName = argNames[index]
80+
if (element && element.type === "Identifier" && argName) {
81+
const expectedName = `$${argName}`
82+
if (expectedName !== element.name) {
83+
context.report({
84+
node: fn,
85+
loc: element.loc!,
86+
messageId: "unexpected",
87+
data: { name: expectedName },
88+
})
89+
}
90+
}
91+
})
92+
}
93+
94+
return {
95+
Program() {
96+
for (const { node } of extractStoreReferences(context, ["derived"])) {
97+
const [args, fn] = node.arguments
98+
if (!args || !isIdentifierOrArrayExpression(args)) continue
99+
if (!fn || !isFunctionExpression(fn)) continue
100+
if (!fn.params || fn.params.length === 0) continue
101+
if (args.type === "Identifier") checkIdentifier(context, args, fn)
102+
else checkArrayExpression(context, args, fn)
103+
}
104+
},
105+
}
106+
},
107+
})

src/rules/no-store-async.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default createRule("no-store-async", {
3232
continue
3333
}
3434

35-
const start = fn.loc?.start ?? { line: 1, column: 0 }
35+
const start = fn.loc!.start
3636
context.report({
3737
node: fn,
3838
loc: {

src/rules/reference-helpers/svelte-store.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,25 @@ import type * as ESTree from "estree"
22
import { ReferenceTracker } from "eslint-utils"
33
import type { RuleContext } from "../../types"
44

5+
type StoreName = "writable" | "readable" | "derived"
6+
57
/** Extract 'svelte/store' references */
68
export function* extractStoreReferences(
79
context: RuleContext,
10+
storeNames: StoreName[] = ["writable", "readable", "derived"],
811
): Generator<{ node: ESTree.CallExpression; name: string }, void> {
912
const referenceTracker = new ReferenceTracker(context.getScope())
1013
for (const { node, path } of referenceTracker.iterateEsmReferences({
1114
"svelte/store": {
1215
[ReferenceTracker.ESM]: true,
1316
writable: {
14-
[ReferenceTracker.CALL]: true,
17+
[ReferenceTracker.CALL]: storeNames.includes("writable"),
1518
},
1619
readable: {
17-
[ReferenceTracker.CALL]: true,
20+
[ReferenceTracker.CALL]: storeNames.includes("readable"),
1821
},
1922
derived: {
20-
[ReferenceTracker.CALL]: true,
23+
[ReferenceTracker.CALL]: storeNames.includes("derived"),
2124
},
2225
},
2326
})) {

src/utils/rules.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { RuleModule } from "../types"
22
import buttonHasType from "../rules/button-has-type"
33
import commentDirective from "../rules/comment-directive"
4+
import derivedHasSameInputsOutputs from "../rules/derived-has-same-inputs-outputs"
45
import firstAttributeLinebreak from "../rules/first-attribute-linebreak"
56
import htmlClosingBracketSpacing from "../rules/html-closing-bracket-spacing"
67
import htmlQuotes from "../rules/html-quotes"
@@ -41,6 +42,7 @@ import validCompile from "../rules/valid-compile"
4142
export const rules = [
4243
buttonHasType,
4344
commentDirective,
45+
derivedHasSameInputsOutputs,
4446
firstAttributeLinebreak,
4547
htmlClosingBracketSpacing,
4648
htmlQuotes,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
- message: The argument name should be '$a'.
2+
line: 3
3+
column: 13
4+
suggestions: null
5+
- message: The argument name should be '$c'.
6+
line: 6
7+
column: 13
8+
suggestions: null
9+
- message: The argument name should be '$e'.
10+
line: 9
11+
column: 19
12+
suggestions: null
13+
- message: The argument name should be '$f'.
14+
line: 9
15+
column: 22
16+
suggestions: null
17+
- message: The argument name should be '$i'.
18+
line: 12
19+
column: 19
20+
suggestions: null
21+
- message: The argument name should be '$j'.
22+
line: 12
23+
column: 22
24+
suggestions: null
25+
- message: The argument name should be '$l'.
26+
line: 15
27+
column: 26
28+
suggestions: null
29+
- message: The argument name should be '$o'.
30+
line: 18
31+
column: 22
32+
suggestions: null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { derived } from "svelte/store"
2+
3+
derived(a, (b) => {
4+
/** do nothing */
5+
})
6+
derived(c, (d, set) => {
7+
/** do nothing */
8+
})
9+
derived([e, f], ([g, h]) => {
10+
/** do nothing */
11+
})
12+
derived([i, j], ([k, l], set) => {
13+
/** do nothing */
14+
})
15+
derived([null, l], ([$m, $n]) => {
16+
/** do nothing */
17+
})
18+
derived([o, null], ([$p, $q]) => {
19+
/** do nothing */
20+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { derived } from "svelte/store"
2+
3+
derived(a, ($a) => {
4+
/** do nothing */
5+
})
6+
derived(c, ($c, set) => {
7+
/** do nothing */
8+
})
9+
derived([e, f], ([$e, $f]) => {
10+
/** do nothing */
11+
})
12+
derived([i, j], ([$i, $j], set) => {
13+
/** do nothing */
14+
})
15+
derived(null, ($null, set) => {
16+
/** do nothing */
17+
})
18+
derived(null, ($k, set) => {
19+
/** do nothing */
20+
})
21+
derived([null, l], ([$m, $l]) => {
22+
/** do nothing */
23+
})
24+
derived([n, null], ([$n, $o]) => {
25+
/** do nothing */
26+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { RuleTester } from "eslint"
2+
import rule from "../../../src/rules/derived-has-same-inputs-outputs"
3+
import { loadTestCases } from "../../utils/utils"
4+
5+
const tester = new RuleTester({
6+
parserOptions: {
7+
ecmaVersion: 2020,
8+
sourceType: "module",
9+
},
10+
})
11+
12+
tester.run(
13+
"derived-has-same-inputs-outputs",
14+
rule as any,
15+
loadTestCases("derived-has-same-inputs-outputs"),
16+
)

0 commit comments

Comments
 (0)