Skip to content

Commit 73f23ae

Browse files
authored
feat: added the require-event-prefix rule (#1069)
1 parent a69409e commit 73f23ae

27 files changed

+378
-0
lines changed

.changeset/rich-dogs-design.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-svelte': minor
3+
---
4+
5+
feat: added the `require-event-prefix` rule

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ These rules relate to style guidelines, and are therefore quite subjective:
337337
| [svelte/no-spaces-around-equal-signs-in-attribute](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-spaces-around-equal-signs-in-attribute/) | disallow spaces around equal signs in attribute | :wrench: |
338338
| [svelte/prefer-class-directive](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-class-directive/) | require class directives instead of ternary expressions | :wrench: |
339339
| [svelte/prefer-style-directive](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-style-directive/) | require style directives instead of style attribute | :wrench: |
340+
| [svelte/require-event-prefix](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-event-prefix/) | require component event names to start with "on" | |
340341
| [svelte/shorthand-attribute](https://sveltejs.github.io/eslint-plugin-svelte/rules/shorthand-attribute/) | enforce use of shorthand syntax in attribute | :wrench: |
341342
| [svelte/shorthand-directive](https://sveltejs.github.io/eslint-plugin-svelte/rules/shorthand-directive/) | enforce use of shorthand syntax in directives | :wrench: |
342343
| [svelte/sort-attributes](https://sveltejs.github.io/eslint-plugin-svelte/rules/sort-attributes/) | enforce order of attributes | :wrench: |

docs/rules.md

+1
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ These rules relate to style guidelines, and are therefore quite subjective:
9494
| [svelte/no-spaces-around-equal-signs-in-attribute](./rules/no-spaces-around-equal-signs-in-attribute.md) | disallow spaces around equal signs in attribute | :wrench: |
9595
| [svelte/prefer-class-directive](./rules/prefer-class-directive.md) | require class directives instead of ternary expressions | :wrench: |
9696
| [svelte/prefer-style-directive](./rules/prefer-style-directive.md) | require style directives instead of style attribute | :wrench: |
97+
| [svelte/require-event-prefix](./rules/require-event-prefix.md) | require component event names to start with "on" | |
9798
| [svelte/shorthand-attribute](./rules/shorthand-attribute.md) | enforce use of shorthand syntax in attribute | :wrench: |
9899
| [svelte/shorthand-directive](./rules/shorthand-directive.md) | enforce use of shorthand syntax in directives | :wrench: |
99100
| [svelte/sort-attributes](./rules/sort-attributes.md) | enforce order of attributes | :wrench: |

docs/rules/require-event-prefix.md

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
pageClass: 'rule-details'
3+
sidebarDepth: 0
4+
title: 'svelte/require-event-prefix'
5+
description: 'require component event names to start with "on"'
6+
---
7+
8+
# svelte/require-event-prefix
9+
10+
> require component event names to start with "on"
11+
12+
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> **_This rule has not been released yet._** </badge>
13+
14+
## :book: Rule Details
15+
16+
Starting with Svelte 5, component events are just component props that are functions and so can be called like any function. Events for HTML elements all have their name begin with "on" (e.g. `onclick`). This rule enforces that all component events (i.e. function props) also begin with "on".
17+
18+
<!--eslint-skip-->
19+
20+
```svelte
21+
<script lang="ts">
22+
/* eslint svelte/require-event-prefix: "error" */
23+
24+
/* ✓ GOOD */
25+
26+
interface Props {
27+
regularProp: string;
28+
onclick(): void;
29+
}
30+
31+
let { regularProp, onclick }: Props = $props();
32+
</script>
33+
```
34+
35+
```svelte
36+
<script lang="ts">
37+
/* eslint svelte/require-event-prefix: "error" */
38+
39+
/* ✗ BAD */
40+
41+
interface Props {
42+
click(): void;
43+
}
44+
45+
let { click }: Props = $props();
46+
</script>
47+
```
48+
49+
## :wrench: Options
50+
51+
```json
52+
{
53+
"svelte/require-event-prefix": [
54+
"error",
55+
{
56+
"checkAsyncFunctions": false
57+
}
58+
]
59+
}
60+
```
61+
62+
- `checkAsyncFunctions` ... Whether to also report asychronous function properties. Default `false`.
63+
64+
## :books: Further Reading
65+
66+
- [Svelte docs on events in version 5](https://svelte.dev/docs/svelte/v5-migration-guide#Event-changes)
67+
68+
## :mag: Implementation
69+
70+
- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/rules/require-event-prefix.ts)
71+
- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/tests/src/rules/require-event-prefix.ts)

packages/eslint-plugin-svelte/src/rule-types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ export interface RuleOptions {
316316
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/require-event-dispatcher-types/
317317
*/
318318
'svelte/require-event-dispatcher-types'?: Linter.RuleEntry<[]>
319+
/**
320+
* require component event names to start with "on"
321+
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/require-event-prefix/
322+
*/
323+
'svelte/require-event-prefix'?: Linter.RuleEntry<SvelteRequireEventPrefix>
319324
/**
320325
* require style attributes that can be optimized
321326
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/require-optimized-style-attribute/
@@ -554,6 +559,10 @@ type SveltePreferConst = []|[{
554559
ignoreReadBeforeAssign?: boolean
555560
excludedRunes?: string[]
556561
}]
562+
// ----- svelte/require-event-prefix -----
563+
type SvelteRequireEventPrefix = []|[{
564+
checkAsyncFunctions?: boolean
565+
}]
557566
// ----- svelte/shorthand-attribute -----
558567
type SvelteShorthandAttribute = []|[{
559568
prefer?: ("always" | "never")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { createRule } from '../utils/index.js';
2+
import {
3+
type TSTools,
4+
getTypeScriptTools,
5+
isMethodSymbol,
6+
isPropertySignatureKind,
7+
isFunctionTypeKind,
8+
isMethodSignatureKind,
9+
isTypeReferenceKind,
10+
isIdentifierKind
11+
} from '../utils/ts-utils/index.js';
12+
import type { Symbol, Type } from 'typescript';
13+
import type { CallExpression } from 'estree';
14+
15+
export default createRule('require-event-prefix', {
16+
meta: {
17+
docs: {
18+
description: 'require component event names to start with "on"',
19+
category: 'Stylistic Issues',
20+
conflictWithPrettier: false,
21+
recommended: false
22+
},
23+
schema: [
24+
{
25+
type: 'object',
26+
properties: {
27+
checkAsyncFunctions: {
28+
type: 'boolean'
29+
}
30+
},
31+
additionalProperties: false
32+
}
33+
],
34+
messages: {
35+
nonPrefixedFunction: 'Component event name must start with "on".'
36+
},
37+
type: 'suggestion',
38+
conditions: [
39+
{
40+
svelteVersions: ['5'],
41+
svelteFileTypes: ['.svelte']
42+
}
43+
]
44+
},
45+
create(context) {
46+
const tsTools = getTypeScriptTools(context);
47+
if (!tsTools) {
48+
return {};
49+
}
50+
51+
const checkAsyncFunctions = context.options[0]?.checkAsyncFunctions ?? false;
52+
53+
return {
54+
CallExpression(node) {
55+
const propsType = getPropsType(node, tsTools);
56+
if (propsType === undefined) {
57+
return;
58+
}
59+
for (const property of propsType.getProperties()) {
60+
if (
61+
isFunctionLike(property, tsTools) &&
62+
!property.getName().startsWith('on') &&
63+
(checkAsyncFunctions || !isFunctionAsync(property, tsTools))
64+
) {
65+
const declarationTsNode = property.getDeclarations()?.[0];
66+
const declarationEstreeNode =
67+
declarationTsNode !== undefined
68+
? tsTools.service.tsNodeToESTreeNodeMap.get(declarationTsNode)
69+
: undefined;
70+
context.report({
71+
node: declarationEstreeNode ?? node,
72+
messageId: 'nonPrefixedFunction'
73+
});
74+
}
75+
}
76+
}
77+
};
78+
}
79+
});
80+
81+
function getPropsType(node: CallExpression, tsTools: TSTools): Type | undefined {
82+
if (
83+
node.callee.type !== 'Identifier' ||
84+
node.callee.name !== '$props' ||
85+
node.parent.type !== 'VariableDeclarator'
86+
) {
87+
return undefined;
88+
}
89+
90+
const tsNode = tsTools.service.esTreeNodeToTSNodeMap.get(node.parent.id);
91+
if (tsNode === undefined) {
92+
return undefined;
93+
}
94+
95+
return tsTools.service.program.getTypeChecker().getTypeAtLocation(tsNode);
96+
}
97+
98+
function isFunctionLike(functionSymbol: Symbol, tsTools: TSTools): boolean {
99+
return (
100+
isMethodSymbol(functionSymbol, tsTools.ts) ||
101+
(functionSymbol.valueDeclaration !== undefined &&
102+
isPropertySignatureKind(functionSymbol.valueDeclaration, tsTools.ts) &&
103+
functionSymbol.valueDeclaration.type !== undefined &&
104+
isFunctionTypeKind(functionSymbol.valueDeclaration.type, tsTools.ts))
105+
);
106+
}
107+
108+
function isFunctionAsync(functionSymbol: Symbol, tsTools: TSTools): boolean {
109+
return (
110+
functionSymbol.getDeclarations()?.some((declaration) => {
111+
if (!isMethodSignatureKind(declaration, tsTools.ts)) {
112+
return false;
113+
}
114+
if (declaration.type === undefined || !isTypeReferenceKind(declaration.type, tsTools.ts)) {
115+
return false;
116+
}
117+
return (
118+
isIdentifierKind(declaration.type.typeName, tsTools.ts) &&
119+
declaration.type.typeName.escapedText === 'Promise'
120+
);
121+
}) ?? false
122+
);
123+
}

packages/eslint-plugin-svelte/src/utils/rules.ts

+2
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import preferDestructuredStoreProps from '../rules/prefer-destructured-store-pro
6262
import preferStyleDirective from '../rules/prefer-style-directive.js';
6363
import requireEachKey from '../rules/require-each-key.js';
6464
import requireEventDispatcherTypes from '../rules/require-event-dispatcher-types.js';
65+
import requireEventPrefix from '../rules/require-event-prefix.js';
6566
import requireOptimizedStyleAttribute from '../rules/require-optimized-style-attribute.js';
6667
import requireStoreCallbacksUseSetParam from '../rules/require-store-callbacks-use-set-param.js';
6768
import requireStoreReactiveAccess from '../rules/require-store-reactive-access.js';
@@ -137,6 +138,7 @@ export const rules = [
137138
preferStyleDirective,
138139
requireEachKey,
139140
requireEventDispatcherTypes,
141+
requireEventPrefix,
140142
requireOptimizedStyleAttribute,
141143
requireStoreCallbacksUseSetParam,
142144
requireStoreReactiveAccess,

packages/eslint-plugin-svelte/src/utils/ts-utils/index.ts

+45
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,48 @@ export function getTypeOfPropertyOfType(
306306
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- getTypeOfPropertyOfType is an internal API of TS.
307307
return (checker as any).getTypeOfPropertyOfType(type, name);
308308
}
309+
310+
/**
311+
* Check whether the given symbol is a method type or not.
312+
*/
313+
export function isMethodSymbol(type: TS.Symbol, ts: TypeScript): boolean {
314+
return (type.getFlags() & ts.SymbolFlags.Method) !== 0;
315+
}
316+
317+
/**
318+
* Check whether the given node is a property signature kind or not.
319+
*/
320+
export function isPropertySignatureKind(
321+
node: TS.Node,
322+
ts: TypeScript
323+
): node is TS.PropertySignature {
324+
return node.kind === ts.SyntaxKind.PropertySignature;
325+
}
326+
327+
/**
328+
* Check whether the given node is a function type kind or not.
329+
*/
330+
export function isFunctionTypeKind(node: TS.Node, ts: TypeScript): node is TS.FunctionTypeNode {
331+
return node.kind === ts.SyntaxKind.FunctionType;
332+
}
333+
334+
/**
335+
* Check whether the given node is a method signature kind or not.
336+
*/
337+
export function isMethodSignatureKind(node: TS.Node, ts: TypeScript): node is TS.MethodSignature {
338+
return node.kind === ts.SyntaxKind.MethodSignature;
339+
}
340+
341+
/**
342+
* Check whether the given node is a type reference kind or not.
343+
*/
344+
export function isTypeReferenceKind(node: TS.Node, ts: TypeScript): node is TS.TypeReferenceNode {
345+
return node.kind === ts.SyntaxKind.TypeReference;
346+
}
347+
348+
/**
349+
* Check whether the given node is an identifier kind or not.
350+
*/
351+
export function isIdentifierKind(node: TS.Node, ts: TypeScript): node is TS.Identifier {
352+
return node.kind === ts.SyntaxKind.Identifier;
353+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"svelte": ">=5.0.0"
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"options": [{ "checkAsyncFunctions": true }]
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"svelte": ">=5.0.0"
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- message: Component event name must start with "on".
2+
line: 3
3+
column: 5
4+
suggestions: null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script lang="ts">
2+
interface Props {
3+
custom: () => Promise<void>;
4+
}
5+
6+
let { custom }: Props = $props();
7+
8+
void custom();
9+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- message: Component event name must start with "on".
2+
line: 3
3+
column: 5
4+
suggestions: null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script lang="ts">
2+
interface Props {
3+
custom(): Promise<void>;
4+
}
5+
6+
let { custom }: Props = $props();
7+
8+
void custom();
9+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- message: Component event name must start with "on".
2+
line: 3
3+
column: 5
4+
suggestions: null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script lang="ts">
2+
interface Props {
3+
custom: () => void;
4+
}
5+
6+
let { custom }: Props = $props();
7+
8+
custom();
9+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- message: Component event name must start with "on".
2+
line: 2
3+
column: 21
4+
suggestions: null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script lang="ts">
2+
let { custom }: { custom(): void } = $props();
3+
4+
custom();
5+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- message: Component event name must start with "on".
2+
line: 3
3+
column: 5
4+
suggestions: null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script lang="ts">
2+
interface Props {
3+
custom(): void;
4+
}
5+
6+
let { custom }: Props = $props();
7+
8+
custom();
9+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"svelte": ">=5.0.0"
3+
}

0 commit comments

Comments
 (0)