Skip to content

Commit

Permalink
feat(eslint-plugin): [no-misused-spread] add suggestions (#10719)
Browse files Browse the repository at this point in the history
* temp

* implement

* implement

* fix

* merge

* fix

* add tests

* Update no-misused-spread.test.ts

* appy reviews

---------

Co-authored-by: Josh Goldberg <git@joshuakgoldberg.com>
  • Loading branch information
yeonjuan and JoshuaKGoldberg authored Feb 24, 2025
1 parent e7cb832 commit a43c199
Show file tree
Hide file tree
Showing 5 changed files with 382 additions and 16 deletions.
74 changes: 72 additions & 2 deletions packages/eslint-plugin/src/rules/no-misused-spread.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TSESTree } from '@typescript-eslint/utils';
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';

import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';

Expand All @@ -9,11 +10,13 @@ import {
createRule,
getConstrainedTypeAtLocation,
getParserServices,
getWrappingFixer,
isBuiltinSymbolLike,
isPromiseLike,
isTypeFlagSet,
readonlynessOptionsSchema,
typeMatchesSomeSpecifier,
isHigherPrecedenceThanAwait,
} from '../util';

type Options = [
Expand All @@ -23,14 +26,16 @@ type Options = [
];

type MessageIds =
| 'addAwait'
| 'noArraySpreadInObject'
| 'noClassDeclarationSpreadInObject'
| 'noClassInstanceSpreadInObject'
| 'noFunctionSpreadInObject'
| 'noIterableSpreadInObject'
| 'noMapSpreadInObject'
| 'noPromiseSpreadInObject'
| 'noStringSpread';
| 'noStringSpread'
| 'replaceMapSpreadInObject';

export default createRule<Options, MessageIds>({
name: 'no-misused-spread',
Expand All @@ -42,7 +47,9 @@ export default createRule<Options, MessageIds>({
recommended: 'strict',
requiresTypeChecking: true,
},
hasSuggestions: true,
messages: {
addAwait: 'Add await operator.',
noArraySpreadInObject:
'Using the spread operator on an array in an object will result in a list of indices.',
noClassDeclarationSpreadInObject:
Expand All @@ -64,6 +71,8 @@ export default createRule<Options, MessageIds>({
'Consider using `Intl.Segmenter` for locale-aware string decomposition.',
"Otherwise, if you don't need to preserve emojis or other non-Ascii characters, disable this lint rule on this line or configure the 'allow' rule option.",
].join('\n'),
replaceMapSpreadInObject:
'Replace map spread in object with `Object.fromEntries()`',
},
schema: [
{
Expand Down Expand Up @@ -104,6 +113,65 @@ export default createRule<Options, MessageIds>({
}
}

function getMapSpreadSuggestions(
node: TSESTree.JSXSpreadAttribute | TSESTree.SpreadElement,
type: ts.Type,
): TSESLint.ReportSuggestionArray<MessageIds> | null {
const types = tsutils.unionTypeParts(type);
if (types.some(t => !isMap(services.program, t))) {
return null;
}

if (
node.parent.type === AST_NODE_TYPES.ObjectExpression &&
node.parent.properties.length === 1
) {
return [
{
messageId: 'replaceMapSpreadInObject',
fix: getWrappingFixer({
node: node.parent,
innerNode: node.argument,
sourceCode: context.sourceCode,
wrap: code => `Object.fromEntries(${code})`,
}),
},
];
}

return [
{
messageId: 'replaceMapSpreadInObject',
fix: getWrappingFixer({
node: node.argument,
sourceCode: context.sourceCode,
wrap: code => `Object.fromEntries(${code})`,
}),
},
];
}

function getPromiseSpreadSuggestions(
node: TSESTree.Expression,
): TSESLint.ReportSuggestionArray<MessageIds> {
const isHighPrecendence = isHigherPrecedenceThanAwait(
services.esTreeNodeToTSNodeMap.get(node),
);

return [
{
messageId: 'addAwait',
fix: fixer =>
isHighPrecendence
? fixer.insertTextBefore(node, 'await ')
: [
fixer.insertTextBefore(node, 'await ('),
fixer.insertTextAfter(node, ')'),
],
},
];
}

function checkObjectSpread(
node: TSESTree.JSXSpreadAttribute | TSESTree.SpreadElement,
): void {
Expand All @@ -117,6 +185,7 @@ export default createRule<Options, MessageIds>({
context.report({
node,
messageId: 'noPromiseSpreadInObject',
suggest: getPromiseSpreadSuggestions(node.argument),
});

return;
Expand All @@ -135,6 +204,7 @@ export default createRule<Options, MessageIds>({
context.report({
node,
messageId: 'noMapSpreadInObject',
suggest: getMapSpreadSuggestions(node, type),
});

return;
Expand Down
14 changes: 1 addition & 13 deletions packages/eslint-plugin/src/rules/return-await.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
isAwaitKeyword,
needsToBeAwaited,
nullThrows,
isHigherPrecedenceThanAwait,
} from '../util';
import { getOperatorPrecedence } from '../util/getOperatorPrecedence';

type FunctionNode =
| TSESTree.ArrowFunctionExpression
Expand Down Expand Up @@ -278,18 +278,6 @@ export default createRule({
];
}

function isHigherPrecedenceThanAwait(node: ts.Node): boolean {
const operator = ts.isBinaryExpression(node)
? node.operatorToken.kind
: ts.SyntaxKind.Unknown;
const nodePrecedence = getOperatorPrecedence(node.kind, operator);
const awaitPrecedence = getOperatorPrecedence(
ts.SyntaxKind.AwaitExpression,
ts.SyntaxKind.Unknown,
);
return nodePrecedence > awaitPrecedence;
}

function test(node: TSESTree.Expression, expression: ts.Node): void {
let child: ts.Node;

Expand Down
3 changes: 2 additions & 1 deletion packages/eslint-plugin/src/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ export * from './scopeUtils';
export * from './types';
export * from './getConstraintInfo';
export * from './getValueOfLiteralType';
export * from './truthinessAndNullishUtils';
export * from './isHigherPrecedenceThanAwait';
export * from './skipChainExpression';
export * from './truthinessAndNullishUtils';

// this is done for convenience - saves migrating all of the old rules
export * from '@typescript-eslint/type-utils';
Expand Down
15 changes: 15 additions & 0 deletions packages/eslint-plugin/src/util/isHigherPrecedenceThanAwait.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as ts from 'typescript';

import { getOperatorPrecedence } from './getOperatorPrecedence';

export function isHigherPrecedenceThanAwait(tsNode: ts.Node): boolean {
const operator = ts.isBinaryExpression(tsNode)
? tsNode.operatorToken.kind
: ts.SyntaxKind.Unknown;
const nodePrecedence = getOperatorPrecedence(tsNode.kind, operator);
const awaitPrecedence = getOperatorPrecedence(
ts.SyntaxKind.AwaitExpression,
ts.SyntaxKind.Unknown,
);
return nodePrecedence > awaitPrecedence;
}
Loading

0 comments on commit a43c199

Please sign in to comment.