Skip to content

Commit 478990f

Browse files
authored
feat(eslint-plugin): add suggestion to require-await to remove async keyword (#9718)
* Add suggestion to require-await to remove async keyword Fixes #9621 * Include suggestion when there is a return type annotation. * Restructure suggestion range calculation. * Suggestion will replace return type of `Promise<T>` with `T`. * use noFormat instead of suppressing lint errors. * Remove unnecessary ecmaVersion from tests and correct test that had auto-formatting applied. * Corrected comment. * Updated the copy of ESLint test cases. * Added better support for async generators. * Used nullThrows to avoid unnecessary conditionals. * Added additional changes to end of array instead of inserting at the start. * Use removeRange instead of replaceTextRange with empty replacement. * Change typeArguments null check. * Replaced incorrect type guard.
1 parent 8087d17 commit 478990f

File tree

5 files changed

+850
-12
lines changed

5 files changed

+850
-12
lines changed

packages/eslint-plugin/src/rules/require-await.ts

+132-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { TSESTree } from '@typescript-eslint/utils';
2-
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2+
import { AST_NODE_TYPES, AST_TOKEN_TYPES } from '@typescript-eslint/utils';
3+
import type { AST, RuleFix } from '@typescript-eslint/utils/ts-eslint';
34
import * as tsutils from 'ts-api-utils';
45
import type * as ts from 'typescript';
56

@@ -8,6 +9,9 @@ import {
89
getFunctionHeadLoc,
910
getFunctionNameWithKind,
1011
getParserServices,
12+
isStartOfExpressionStatement,
13+
needsPrecedingSemicolon,
14+
nullThrows,
1115
upperCaseFirst,
1216
} from '../util';
1317

@@ -37,7 +41,9 @@ export default createRule({
3741
schema: [],
3842
messages: {
3943
missingAwait: "{{name}} has no 'await' expression.",
44+
removeAsync: "Remove 'async'.",
4045
},
46+
hasSuggestions: true,
4147
},
4248
defaultOptions: [],
4349
create(context) {
@@ -75,13 +81,128 @@ export default createRule({
7581
!isEmptyFunction(node) &&
7682
!(scopeInfo.isGen && scopeInfo.isAsyncYield)
7783
) {
84+
// If the function belongs to a method definition or
85+
// property, then the function's range may not include the
86+
// `async` keyword and we should look at the parent instead.
87+
const nodeWithAsyncKeyword =
88+
(node.parent.type === AST_NODE_TYPES.MethodDefinition &&
89+
node.parent.value === node) ||
90+
(node.parent.type === AST_NODE_TYPES.Property &&
91+
node.parent.method &&
92+
node.parent.value === node)
93+
? node.parent
94+
: node;
95+
96+
const asyncToken = nullThrows(
97+
context.sourceCode.getFirstToken(
98+
nodeWithAsyncKeyword,
99+
token => token.value === 'async',
100+
),
101+
'The node is an async function, so it must have an "async" token.',
102+
);
103+
104+
const asyncRange: Readonly<AST.Range> = [
105+
asyncToken.range[0],
106+
nullThrows(
107+
context.sourceCode.getTokenAfter(asyncToken, {
108+
includeComments: true,
109+
}),
110+
'There will always be a token after the "async" keyword.',
111+
).range[0],
112+
] as const;
113+
114+
// Removing the `async` keyword can cause parsing errors if the
115+
// current statement is relying on automatic semicolon insertion.
116+
// If ASI is currently being used, then we should replace the
117+
// `async` keyword with a semicolon.
118+
const nextToken = nullThrows(
119+
context.sourceCode.getTokenAfter(asyncToken),
120+
'There will always be a token after the "async" keyword.',
121+
);
122+
const addSemiColon =
123+
nextToken.type === AST_TOKEN_TYPES.Punctuator &&
124+
(nextToken.value === '[' || nextToken.value === '(') &&
125+
(nodeWithAsyncKeyword.type === AST_NODE_TYPES.MethodDefinition ||
126+
isStartOfExpressionStatement(nodeWithAsyncKeyword)) &&
127+
needsPrecedingSemicolon(context.sourceCode, nodeWithAsyncKeyword);
128+
129+
const changes = [
130+
{ range: asyncRange, replacement: addSemiColon ? ';' : undefined },
131+
];
132+
133+
// If there's a return type annotation and it's a
134+
// `Promise<T>`, we can also change the return type
135+
// annotation to just `T` as part of the suggestion.
136+
// Alternatively, if the function is a generator and
137+
// the return type annotation is `AsyncGenerator<T>`,
138+
// then we can change it to `Generator<T>`.
139+
if (
140+
node.returnType?.typeAnnotation.type ===
141+
AST_NODE_TYPES.TSTypeReference
142+
) {
143+
if (scopeInfo.isGen) {
144+
if (hasTypeName(node.returnType.typeAnnotation, 'AsyncGenerator')) {
145+
changes.push({
146+
range: node.returnType.typeAnnotation.typeName.range,
147+
replacement: 'Generator',
148+
});
149+
}
150+
} else if (
151+
hasTypeName(node.returnType.typeAnnotation, 'Promise') &&
152+
node.returnType.typeAnnotation.typeArguments != null
153+
) {
154+
const openAngle = nullThrows(
155+
context.sourceCode.getFirstToken(
156+
node.returnType.typeAnnotation,
157+
token =>
158+
token.type === AST_TOKEN_TYPES.Punctuator &&
159+
token.value === '<',
160+
),
161+
'There are type arguments, so the angle bracket will exist.',
162+
);
163+
const closeAngle = nullThrows(
164+
context.sourceCode.getLastToken(
165+
node.returnType.typeAnnotation,
166+
token =>
167+
token.type === AST_TOKEN_TYPES.Punctuator &&
168+
token.value === '>',
169+
),
170+
'There are type arguments, so the angle bracket will exist.',
171+
);
172+
changes.push(
173+
// Remove the closing angled bracket.
174+
{ range: closeAngle.range, replacement: undefined },
175+
// Remove the "Promise" identifier
176+
// and the opening angled bracket.
177+
{
178+
range: [
179+
node.returnType.typeAnnotation.typeName.range[0],
180+
openAngle.range[1],
181+
],
182+
replacement: undefined,
183+
},
184+
);
185+
}
186+
}
187+
78188
context.report({
79189
node,
80190
loc: getFunctionHeadLoc(node, context.sourceCode),
81191
messageId: 'missingAwait',
82192
data: {
83193
name: upperCaseFirst(getFunctionNameWithKind(node)),
84194
},
195+
suggest: [
196+
{
197+
messageId: 'removeAsync',
198+
fix: (fixer): RuleFix[] =>
199+
changes.map(change =>
200+
change.replacement !== undefined
201+
? fixer.replaceTextRange(change.range, change.replacement)
202+
: fixer.removeRange(change.range),
203+
),
204+
},
205+
],
85206
});
86207
}
87208

@@ -200,3 +321,13 @@ function expandUnionOrIntersectionType(type: ts.Type): ts.Type[] {
200321
}
201322
return [type];
202323
}
324+
325+
function hasTypeName(
326+
typeReference: TSESTree.TSTypeReference,
327+
typeName: string,
328+
): boolean {
329+
return (
330+
typeReference.typeName.type === AST_NODE_TYPES.Identifier &&
331+
typeReference.typeName.name === typeName
332+
);
333+
}

packages/eslint-plugin/src/util/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ export * from './getThisExpression';
1212
export * from './getWrappingFixer';
1313
export * from './isNodeEqual';
1414
export * from './isNullLiteral';
15+
export * from './isStartOfExpressionStatement';
1516
export * from './isUndefinedIdentifier';
1617
export * from './misc';
18+
export * from './needsPrecedingSemiColon';
1719
export * from './objectIterators';
1820
export * from './scopeUtils';
1921
export * from './types';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { TSESTree } from '@typescript-eslint/utils';
2+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3+
4+
// The following is copied from `eslint`'s source code.
5+
// https://github.com/eslint/eslint/blob/3a4eaf921543b1cd5d1df4ea9dec02fab396af2a/lib/rules/utils/ast-utils.js#L1026-L1041
6+
// Could be export { isStartOfExpressionStatement } from 'eslint/lib/rules/utils/ast-utils'
7+
/**
8+
* Tests if a node appears at the beginning of an ancestor ExpressionStatement node.
9+
* @param node The node to check.
10+
* @returns Whether the node appears at the beginning of an ancestor ExpressionStatement node.
11+
*/
12+
export function isStartOfExpressionStatement(node: TSESTree.Node): boolean {
13+
const start = node.range[0];
14+
let ancestor: TSESTree.Node | undefined = node;
15+
16+
while ((ancestor = ancestor.parent) && ancestor.range[0] === start) {
17+
if (ancestor.type === AST_NODE_TYPES.ExpressionStatement) {
18+
return true;
19+
}
20+
}
21+
return false;
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type { TSESTree } from '@typescript-eslint/utils';
2+
import { AST_NODE_TYPES, AST_TOKEN_TYPES } from '@typescript-eslint/utils';
3+
import {
4+
isClosingBraceToken,
5+
isClosingParenToken,
6+
} from '@typescript-eslint/utils/ast-utils';
7+
import type { SourceCode } from '@typescript-eslint/utils/ts-eslint';
8+
9+
// The following is adapted from `eslint`'s source code.
10+
// https://github.com/eslint/eslint/blob/3a4eaf921543b1cd5d1df4ea9dec02fab396af2a/lib/rules/utils/ast-utils.js#L1043-L1132
11+
// Could be export { isStartOfExpressionStatement } from 'eslint/lib/rules/utils/ast-utils'
12+
13+
const BREAK_OR_CONTINUE = new Set([
14+
AST_NODE_TYPES.BreakStatement,
15+
AST_NODE_TYPES.ContinueStatement,
16+
]);
17+
18+
// Declaration types that must contain a string Literal node at the end.
19+
const DECLARATIONS = new Set([
20+
AST_NODE_TYPES.ExportAllDeclaration,
21+
AST_NODE_TYPES.ExportNamedDeclaration,
22+
AST_NODE_TYPES.ImportDeclaration,
23+
]);
24+
25+
const IDENTIFIER_OR_KEYWORD = new Set([
26+
AST_NODE_TYPES.Identifier,
27+
AST_TOKEN_TYPES.Keyword,
28+
]);
29+
30+
// Keywords that can immediately precede an ExpressionStatement node, mapped to the their node types.
31+
const NODE_TYPES_BY_KEYWORD: Record<string, TSESTree.AST_NODE_TYPES | null> = {
32+
__proto__: null,
33+
break: AST_NODE_TYPES.BreakStatement,
34+
continue: AST_NODE_TYPES.ContinueStatement,
35+
debugger: AST_NODE_TYPES.DebuggerStatement,
36+
do: AST_NODE_TYPES.DoWhileStatement,
37+
else: AST_NODE_TYPES.IfStatement,
38+
return: AST_NODE_TYPES.ReturnStatement,
39+
yield: AST_NODE_TYPES.YieldExpression,
40+
};
41+
42+
/*
43+
* Before an opening parenthesis, postfix `++` and `--` always trigger ASI;
44+
* the tokens `:`, `;`, `{` and `=>` don't expect a semicolon, as that would count as an empty statement.
45+
*/
46+
const PUNCTUATORS = new Set([':', ';', '{', '=>', '++', '--']);
47+
48+
/*
49+
* Statements that can contain an `ExpressionStatement` after a closing parenthesis.
50+
* DoWhileStatement is an exception in that it always triggers ASI after the closing parenthesis.
51+
*/
52+
const STATEMENTS = new Set([
53+
AST_NODE_TYPES.DoWhileStatement,
54+
AST_NODE_TYPES.ForInStatement,
55+
AST_NODE_TYPES.ForOfStatement,
56+
AST_NODE_TYPES.ForStatement,
57+
AST_NODE_TYPES.IfStatement,
58+
AST_NODE_TYPES.WhileStatement,
59+
AST_NODE_TYPES.WithStatement,
60+
]);
61+
62+
/**
63+
* Determines whether an opening parenthesis `(`, bracket `[` or backtick ``` ` ``` needs to be preceded by a semicolon.
64+
* This opening parenthesis or bracket should be at the start of an `ExpressionStatement`, a `MethodDefinition` or at
65+
* the start of the body of an `ArrowFunctionExpression`.
66+
* @param sourceCode The source code object.
67+
* @param node A node at the position where an opening parenthesis or bracket will be inserted.
68+
* @returns Whether a semicolon is required before the opening parenthesis or bracket.
69+
*/
70+
export function needsPrecedingSemicolon(
71+
sourceCode: SourceCode,
72+
node: TSESTree.Node,
73+
): boolean {
74+
const prevToken = sourceCode.getTokenBefore(node);
75+
76+
if (
77+
!prevToken ||
78+
(prevToken.type === AST_TOKEN_TYPES.Punctuator &&
79+
PUNCTUATORS.has(prevToken.value))
80+
) {
81+
return false;
82+
}
83+
84+
const prevNode = sourceCode.getNodeByRangeIndex(prevToken.range[0]);
85+
86+
if (!prevNode) {
87+
return false;
88+
}
89+
90+
if (isClosingParenToken(prevToken)) {
91+
return !STATEMENTS.has(prevNode.type);
92+
}
93+
94+
if (isClosingBraceToken(prevToken)) {
95+
return (
96+
(prevNode.type === AST_NODE_TYPES.BlockStatement &&
97+
prevNode.parent.type === AST_NODE_TYPES.FunctionExpression &&
98+
prevNode.parent.parent.type !== AST_NODE_TYPES.MethodDefinition) ||
99+
(prevNode.type === AST_NODE_TYPES.ClassBody &&
100+
prevNode.parent.type === AST_NODE_TYPES.ClassExpression) ||
101+
prevNode.type === AST_NODE_TYPES.ObjectExpression
102+
);
103+
}
104+
105+
if (!prevNode.parent) {
106+
return false;
107+
}
108+
109+
if (IDENTIFIER_OR_KEYWORD.has(prevToken.type)) {
110+
if (BREAK_OR_CONTINUE.has(prevNode.parent.type)) {
111+
return false;
112+
}
113+
114+
const keyword = prevToken.value;
115+
const nodeType = NODE_TYPES_BY_KEYWORD[keyword];
116+
117+
return prevNode.type !== nodeType;
118+
}
119+
120+
if (prevToken.type === AST_TOKEN_TYPES.String) {
121+
return !DECLARATIONS.has(prevNode.parent.type);
122+
}
123+
124+
return true;
125+
}

0 commit comments

Comments
 (0)