diff --git a/packages/eslint-plugin/src/rules/no-misused-spread.ts b/packages/eslint-plugin/src/rules/no-misused-spread.ts index f6fd7610706a..74b2ddb6abf5 100644 --- a/packages/eslint-plugin/src/rules/no-misused-spread.ts +++ b/packages/eslint-plugin/src/rules/no-misused-spread.ts @@ -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'; @@ -9,11 +10,13 @@ import { createRule, getConstrainedTypeAtLocation, getParserServices, + getWrappingFixer, isBuiltinSymbolLike, isPromiseLike, isTypeFlagSet, readonlynessOptionsSchema, typeMatchesSomeSpecifier, + isHigherPrecedenceThanAwait, } from '../util'; type Options = [ @@ -23,6 +26,7 @@ type Options = [ ]; type MessageIds = + | 'addAwait' | 'noArraySpreadInObject' | 'noClassDeclarationSpreadInObject' | 'noClassInstanceSpreadInObject' @@ -30,7 +34,8 @@ type MessageIds = | 'noIterableSpreadInObject' | 'noMapSpreadInObject' | 'noPromiseSpreadInObject' - | 'noStringSpread'; + | 'noStringSpread' + | 'replaceMapSpreadInObject'; export default createRule({ name: 'no-misused-spread', @@ -42,7 +47,9 @@ export default createRule({ 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: @@ -64,6 +71,8 @@ export default createRule({ '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: [ { @@ -104,6 +113,65 @@ export default createRule({ } } + function getMapSpreadSuggestions( + node: TSESTree.JSXSpreadAttribute | TSESTree.SpreadElement, + type: ts.Type, + ): TSESLint.ReportSuggestionArray | 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 { + 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 { @@ -117,6 +185,7 @@ export default createRule({ context.report({ node, messageId: 'noPromiseSpreadInObject', + suggest: getPromiseSpreadSuggestions(node.argument), }); return; @@ -135,6 +204,7 @@ export default createRule({ context.report({ node, messageId: 'noMapSpreadInObject', + suggest: getMapSpreadSuggestions(node, type), }); return; diff --git a/packages/eslint-plugin/src/rules/return-await.ts b/packages/eslint-plugin/src/rules/return-await.ts index a040bbb9463e..0ee20eea92a8 100644 --- a/packages/eslint-plugin/src/rules/return-await.ts +++ b/packages/eslint-plugin/src/rules/return-await.ts @@ -12,8 +12,8 @@ import { isAwaitKeyword, needsToBeAwaited, nullThrows, + isHigherPrecedenceThanAwait, } from '../util'; -import { getOperatorPrecedence } from '../util/getOperatorPrecedence'; type FunctionNode = | TSESTree.ArrowFunctionExpression @@ -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; diff --git a/packages/eslint-plugin/src/util/index.ts b/packages/eslint-plugin/src/util/index.ts index 8d074e03ba5e..6bd8189c5a51 100644 --- a/packages/eslint-plugin/src/util/index.ts +++ b/packages/eslint-plugin/src/util/index.ts @@ -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'; diff --git a/packages/eslint-plugin/src/util/isHigherPrecedenceThanAwait.ts b/packages/eslint-plugin/src/util/isHigherPrecedenceThanAwait.ts new file mode 100644 index 000000000000..20ee6885d269 --- /dev/null +++ b/packages/eslint-plugin/src/util/isHigherPrecedenceThanAwait.ts @@ -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; +} diff --git a/packages/eslint-plugin/tests/rules/no-misused-spread.test.ts b/packages/eslint-plugin/tests/rules/no-misused-spread.test.ts index 8701ea580daa..e187a85a1975 100644 --- a/packages/eslint-plugin/tests/rules/no-misused-spread.test.ts +++ b/packages/eslint-plugin/tests/rules/no-misused-spread.test.ts @@ -741,6 +741,17 @@ ruleTester.run('no-misused-spread', rule, { endLine: 6, line: 3, messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + const o = Object.fromEntries(new Map([ + ['test-1', 1], + ['test-2', 2], + ])); + `, + }, + ], }, ], }, @@ -759,6 +770,19 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 27, line: 7, messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + const map = new Map([ + ['test-1', 1], + ['test-2', 2], + ]); + + const o = Object.fromEntries(map); + `, + }, + ], }, ], }, @@ -773,6 +797,109 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 27, line: 3, messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + declare const map: Map; + const o = Object.fromEntries(map); + `, + }, + ], + }, + ], + }, + { + code: noFormat` + declare const map: Map; + const o = { ...(map) }; + `, + errors: [ + { + column: 21, + endColumn: 29, + line: 3, + messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + declare const map: Map; + const o = Object.fromEntries(map); + `, + }, + ], + }, + ], + }, + { + code: ` + declare const map: Map; + const o = { ...(map, map) }; + `, + errors: [ + { + column: 21, + endColumn: 34, + line: 3, + messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + declare const map: Map; + const o = Object.fromEntries((map, map)); + `, + }, + ], + }, + ], + }, + { + code: ` + declare const map: Map; + const others = { a: 1 }; + const o = { ...map, ...others }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 4, + messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + declare const map: Map; + const others = { a: 1 }; + const o = { ...Object.fromEntries(map), ...others }; + `, + }, + ], + }, + ], + }, + { + code: ` + declare const map: Map; + const o = { other: 1, ...map }; + `, + errors: [ + { + column: 31, + endColumn: 37, + line: 3, + messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + declare const map: Map; + const o = { other: 1, ...Object.fromEntries(map) }; + `, + }, + ], }, ], }, @@ -787,6 +914,15 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 27, line: 3, messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + declare const map: ReadonlyMap; + const o = Object.fromEntries(map); + `, + }, + ], }, ], }, @@ -801,6 +937,15 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 27, line: 3, messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + declare const map: WeakMap<{ a: number }, string>; + const o = Object.fromEntries(map); + `, + }, + ], }, ], }, @@ -829,6 +974,15 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 32, line: 3, messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + declare function getMap(): Map; + const o = Object.fromEntries(getMap()); + `, + }, + ], }, ], }, @@ -843,6 +997,15 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 25, line: 3, messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + declare const a: Map & Set; + const o = Object.fromEntries(a); + `, + }, + ], }, ], }, @@ -871,6 +1034,69 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 31, line: 3, messageId: 'noPromiseSpreadInObject', + suggestions: [ + { + messageId: 'addAwait', + output: ` + const promise = new Promise(() => {}); + const o = { ...await promise }; + `, + }, + ], + }, + ], + }, + { + code: ` + declare const promise: Promise<{ a: 1 }>; + async function foo() { + return { ...(promise || {}) }; + } + `, + errors: [ + { + column: 20, + endColumn: 38, + line: 4, + messageId: 'noPromiseSpreadInObject', + suggestions: [ + { + messageId: 'addAwait', + output: ` + declare const promise: Promise<{ a: 1 }>; + async function foo() { + return { ...(await (promise || {})) }; + } + `, + }, + ], + }, + ], + }, + { + code: ` + declare const promise: Promise; + async function foo() { + return { ...(Math.random() < 0.5 ? promise : {}) }; + } + `, + errors: [ + { + column: 20, + endColumn: 59, + line: 4, + messageId: 'noPromiseSpreadInObject', + suggestions: [ + { + messageId: 'addAwait', + output: ` + declare const promise: Promise; + async function foo() { + return { ...(await (Math.random() < 0.5 ? promise : {})) }; + } + `, + }, + ], }, ], }, @@ -886,6 +1112,16 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 30, line: 3, messageId: 'noPromiseSpreadInObject', + suggestions: [ + { + messageId: 'addAwait', + output: ` + function withPromise

>(promise: P) { + return { ...await promise }; + } + `, + }, + ], }, ], }, @@ -900,6 +1136,15 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 36, line: 3, messageId: 'noPromiseSpreadInObject', + suggestions: [ + { + messageId: 'addAwait', + output: ` + declare const maybePromise: Promise | { a: number }; + const o = { ...await maybePromise }; + `, + }, + ], }, ], }, @@ -914,6 +1159,15 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 31, line: 3, messageId: 'noPromiseSpreadInObject', + suggestions: [ + { + messageId: 'addAwait', + output: ` + declare const promise: Promise & { a: number }; + const o = { ...await promise }; + `, + }, + ], }, ], }, @@ -928,6 +1182,15 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 36, line: 3, messageId: 'noPromiseSpreadInObject', + suggestions: [ + { + messageId: 'addAwait', + output: ` + declare function getPromise(): Promise; + const o = { ...await getPromise() }; + `, + }, + ], }, ], }, @@ -942,6 +1205,15 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 36, line: 3, messageId: 'noPromiseSpreadInObject', + suggestions: [ + { + messageId: 'addAwait', + output: ` + declare function getPromise>(arg: T): T; + const o = { ...await getPromise() }; + `, + }, + ], }, ], }, @@ -1636,6 +1908,16 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 32, line: 4, messageId: 'noMapSpreadInObject', + suggestions: [ + { + messageId: 'replaceMapSpreadInObject', + output: ` + declare const map: Map; + + const o =

; + `, + }, + ], }, ], languageOptions: { @@ -1658,6 +1940,16 @@ ruleTester.run('no-misused-spread', rule, { endColumn: 36, line: 4, messageId: 'noPromiseSpreadInObject', + suggestions: [ + { + messageId: 'addAwait', + output: ` + const promise = new Promise(() => {}); + + const o =
; + `, + }, + ], }, ], languageOptions: {