diff --git a/packages/eslint-plugin/src/rules/consistent-type-assertions.ts b/packages/eslint-plugin/src/rules/consistent-type-assertions.ts index 9903f15e19e4..d2e610255f17 100644 --- a/packages/eslint-plugin/src/rules/consistent-type-assertions.ts +++ b/packages/eslint-plugin/src/rules/consistent-type-assertions.ts @@ -1,7 +1,9 @@ import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import * as ts from 'typescript'; import * as util from '../util'; +import { getWrappedCode } from '../util/getWrappedCode'; // intentionally mirroring the options export type MessageIds = @@ -82,6 +84,7 @@ export default util.createRule({ ], create(context, [options]) { const sourceCode = context.getSourceCode(); + const parserServices = util.getParserServices(context, true); function isConst(node: TSESTree.TypeNode): boolean { if (node.type !== AST_NODE_TYPES.TSTypeReference) { @@ -125,7 +128,6 @@ export default util.createRule({ if (isConst(node.typeAnnotation) && messageId === 'never') { return; } - context.report({ node, messageId, @@ -135,16 +137,43 @@ export default util.createRule({ : {}, fix: messageId === 'as' - ? (fixer): TSESLint.RuleFix[] => [ - fixer.replaceText( - node, - getTextWithParentheses(node.expression), - ), - fixer.insertTextAfter( + ? (fixer): TSESLint.RuleFix => { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get( + node as TSESTree.TSTypeAssertion, + ); + + /** + * AsExpression has lower precedence than TypeAssertionExpression, + * so we don't need to wrap expression and typeAnnotation in parens. + */ + const expressionCode = sourceCode.getText(node.expression); + const typeAnnotationCode = sourceCode.getText( + node.typeAnnotation, + ); + + const asPrecedence = util.getOperatorPrecedence( + ts.SyntaxKind.AsExpression, + ts.SyntaxKind.Unknown, + ); + const parentPrecedence = util.getOperatorPrecedence( + tsNode.parent.kind, + ts.isBinaryExpression(tsNode.parent) + ? tsNode.parent.operatorToken.kind + : ts.SyntaxKind.Unknown, + ts.isNewExpression(tsNode.parent) + ? tsNode.parent.arguments != null && + tsNode.parent.arguments.length > 0 + : undefined, + ); + + const text = `${expressionCode} as ${typeAnnotationCode}`; + return fixer.replaceText( node, - ` as ${getTextWithParentheses(node.typeAnnotation)}`, - ), - ] + util.isParenthesized(node, sourceCode) + ? text + : getWrappedCode(text, asPrecedence, parentPrecedence), + ); + } : undefined, }); } diff --git a/packages/eslint-plugin/src/util/getOperatorPrecedence.ts b/packages/eslint-plugin/src/util/getOperatorPrecedence.ts index abbcb9191a59..b899e1f8cc99 100644 --- a/packages/eslint-plugin/src/util/getOperatorPrecedence.ts +++ b/packages/eslint-plugin/src/util/getOperatorPrecedence.ts @@ -370,6 +370,7 @@ export function getOperatorPrecedence( return OperatorPrecedence.Member; case SyntaxKind.AsExpression: + case SyntaxKind.SatisfiesExpression: return OperatorPrecedence.Relational; case SyntaxKind.ThisKeyword: diff --git a/packages/eslint-plugin/src/util/getWrappedCode.ts b/packages/eslint-plugin/src/util/getWrappedCode.ts new file mode 100644 index 000000000000..db2999ab9922 --- /dev/null +++ b/packages/eslint-plugin/src/util/getWrappedCode.ts @@ -0,0 +1,9 @@ +import type { OperatorPrecedence } from './getOperatorPrecedence'; + +export function getWrappedCode( + text: string, + nodePrecedence: OperatorPrecedence, + parentPrecedence: OperatorPrecedence, +): string { + return nodePrecedence > parentPrecedence ? text : `(${text})`; +} diff --git a/packages/eslint-plugin/src/util/getWrappingFixer.ts b/packages/eslint-plugin/src/util/getWrappingFixer.ts index e43ff3e142b0..043d144ec5cb 100644 --- a/packages/eslint-plugin/src/util/getWrappingFixer.ts +++ b/packages/eslint-plugin/src/util/getWrappingFixer.ts @@ -35,10 +35,15 @@ export function getWrappingFixer( const innerCodes = innerNodes.map(innerNode => { let code = sourceCode.getText(innerNode); - // check the inner expression's precedence - if (!isStrongPrecedenceNode(innerNode)) { - // the code we are adding might have stronger precedence than our wrapped node - // let's wrap our node in parens in case it has a weaker precedence than the code we are wrapping it in + /** + * Wrap our node in parens to prevent the following cases: + * - It has a weaker precedence than the code we are wrapping it in + * - It's gotten mistaken as block statement instead of object expression + */ + if ( + !isStrongPrecedenceNode(innerNode) || + isObjectExpressionInOneLineReturn(node, innerNode) + ) { code = `(${code})`; } @@ -73,12 +78,15 @@ export function isStrongPrecedenceNode(innerNode: TSESTree.Node): boolean { return ( innerNode.type === AST_NODE_TYPES.Literal || innerNode.type === AST_NODE_TYPES.Identifier || + innerNode.type === AST_NODE_TYPES.TSTypeReference || + innerNode.type === AST_NODE_TYPES.TSTypeOperator || innerNode.type === AST_NODE_TYPES.ArrayExpression || innerNode.type === AST_NODE_TYPES.ObjectExpression || innerNode.type === AST_NODE_TYPES.MemberExpression || innerNode.type === AST_NODE_TYPES.CallExpression || innerNode.type === AST_NODE_TYPES.NewExpression || - innerNode.type === AST_NODE_TYPES.TaggedTemplateExpression + innerNode.type === AST_NODE_TYPES.TaggedTemplateExpression || + innerNode.type === AST_NODE_TYPES.TSInstantiationExpression ); } @@ -205,3 +213,17 @@ function isLeftHandSide(node: TSESTree.Node): boolean { return false; } + +/** + * Checks if a node's parent is arrow function expression and a inner node is object expression + */ +function isObjectExpressionInOneLineReturn( + node: TSESTree.Node, + innerNode: TSESTree.Node, +): boolean { + return ( + node.parent?.type === AST_NODE_TYPES.ArrowFunctionExpression && + node.parent.body === node && + innerNode.type === AST_NODE_TYPES.ObjectExpression + ); +} diff --git a/packages/eslint-plugin/tests/rules/consistent-type-assertions.test.ts b/packages/eslint-plugin/tests/rules/consistent-type-assertions.test.ts index 170aa8b16968..3a6b28650c06 100644 --- a/packages/eslint-plugin/tests/rules/consistent-type-assertions.test.ts +++ b/packages/eslint-plugin/tests/rules/consistent-type-assertions.test.ts @@ -22,7 +22,11 @@ const x = !'string'; const x = a + b; const x = <(A)>a + (b); const x = (new Generic()); -const x = (new (Generic)());`; +const x = new (Generic)(); +const x = new (Generic)('string'); +const x = () => { bar: 5 }; +const x = () => ({ bar: 5 }); +const x = () => bar;`; const ANGLE_BRACKET_TESTS = `${ANGLE_BRACKET_TESTS_EXCEPT_CONST_CASE} const x = { key: 'value' }; @@ -32,12 +36,16 @@ const AS_TESTS_EXCEPT_CONST_CASE = ` const x = new Generic() as Foo; const x = b as A; const x = [1] as readonly number[]; -const x = ('string') as a | b; +const x = 'string' as a | b; const x = !'string' as A; -const x = a as A + b; -const x = a as (A) + (b); -const x = (new Generic()) as Foo; -const x = (new (Generic as Foo)());`; +const x = (a as A) + b; +const x = (a as A) + (b); +const x = new Generic() as Foo; +const x = new (Generic as Foo)(); +const x = new (Generic as Foo)('string'); +const x = () => ({ bar: 5 } as Foo); +const x = () => ({ bar: 5 } as Foo); +const x = () => (bar as Foo);`; const AS_TESTS = `${AS_TESTS_EXCEPT_CONST_CASE} const x = { key: 'value' } as const; @@ -206,6 +214,22 @@ ruleTester.run('consistent-type-assertions', rule, { messageId: 'angle-bracket', line: 11, }, + { + messageId: 'angle-bracket', + line: 12, + }, + { + messageId: 'angle-bracket', + line: 13, + }, + { + messageId: 'angle-bracket', + line: 14, + }, + { + messageId: 'angle-bracket', + line: 15, + }, ], }), ...batchedSingleLineTests({ @@ -256,6 +280,22 @@ ruleTester.run('consistent-type-assertions', rule, { messageId: 'as', line: 11, }, + { + messageId: 'as', + line: 12, + }, + { + messageId: 'as', + line: 13, + }, + { + messageId: 'as', + line: 14, + }, + { + messageId: 'as', + line: 15, + }, ], output: AS_TESTS, }), @@ -303,6 +343,22 @@ ruleTester.run('consistent-type-assertions', rule, { messageId: 'never', line: 10, }, + { + messageId: 'never', + line: 11, + }, + { + messageId: 'never', + line: 12, + }, + { + messageId: 'never', + line: 13, + }, + { + messageId: 'never', + line: 14, + }, ], }), ...batchedSingleLineTests({ @@ -349,6 +405,22 @@ ruleTester.run('consistent-type-assertions', rule, { messageId: 'never', line: 10, }, + { + messageId: 'never', + line: 11, + }, + { + messageId: 'never', + line: 12, + }, + { + messageId: 'never', + line: 13, + }, + { + messageId: 'never', + line: 14, + }, ], }), ...batchedSingleLineTests({ diff --git a/packages/eslint-plugin/tests/util/getWrappedCode.test.ts b/packages/eslint-plugin/tests/util/getWrappedCode.test.ts new file mode 100644 index 000000000000..516642c16b57 --- /dev/null +++ b/packages/eslint-plugin/tests/util/getWrappedCode.test.ts @@ -0,0 +1,91 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import type { TSESTree } from '@typescript-eslint/utils'; +import * as ts from 'typescript'; + +import * as util from '../../src/util'; +import { getWrappedCode } from '../../src/util/getWrappedCode'; +import { getFixturesRootDir } from '../RuleTester'; + +const rootPath = getFixturesRootDir(); +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: rootPath, + project: './tsconfig.json', + }, +}); + +const removeFunctionRule = util.createRule({ + name: 'remove-function', + defaultOptions: [], + meta: { + type: 'suggestion', + fixable: 'code', + docs: { + description: + 'Remove function with first arg remaining in random places for test purposes.', + }, + messages: { + removeFunction: 'Please remove this function', + }, + schema: [], + }, + + create(context) { + const sourceCode = context.getSourceCode(); + const parserServices = util.getParserServices(context, true); + + const report = (node: TSESTree.CallExpression): void => { + context.report({ + node, + messageId: 'removeFunction', + fix: fixer => { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); + const tsArgumentNode = tsNode.arguments[0]; + + const nodePrecedence = util.getOperatorPrecedence( + tsArgumentNode.kind, + ts.isBinaryExpression(tsArgumentNode) + ? tsArgumentNode.operatorToken.kind + : ts.SyntaxKind.Unknown, + ); + const parentPrecedence = util.getOperatorPrecedence( + tsNode.parent.kind, + ts.isBinaryExpression(tsNode.parent) + ? tsNode.parent.operatorToken.kind + : ts.SyntaxKind.Unknown, + ); + + const text = sourceCode.getText(node.arguments[0]); + return fixer.replaceText( + node, + getWrappedCode(text, nodePrecedence, parentPrecedence), + ); + }, + }); + }; + + return { + 'CallExpression[callee.name="fn"]': report, + }; + }, +}); + +ruleTester.run('getWrappedCode - removeFunctionRule', removeFunctionRule, { + valid: [], + invalid: [ + // should add parens when the first argument node has lower precedence than the parent node of the CallExpression + { + code: '() => fn({ x: "wrapObject" })', + errors: [{ messageId: 'removeFunction' }], + output: '() => ({ x: "wrapObject" })', + }, + + // shouldn't add parens when not necessary + { + code: 'const a = fn({ x: "wrapObject" })', + errors: [{ messageId: 'removeFunction' }], + output: 'const a = { x: "wrapObject" }', + }, + ], +}); diff --git a/packages/eslint-plugin/tests/util/getWrappingFixer.test.ts b/packages/eslint-plugin/tests/util/getWrappingFixer.test.ts index 52b0231420c8..ac7c8740b0d3 100644 --- a/packages/eslint-plugin/tests/util/getWrappingFixer.test.ts +++ b/packages/eslint-plugin/tests/util/getWrappingFixer.test.ts @@ -4,7 +4,16 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { createRule, getWrappingFixer } from '../../src/util'; import { getFixturesRootDir } from '../RuleTester'; -const rule = createRule({ +const rootPath = getFixturesRootDir(); +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: rootPath, + project: './tsconfig.json', + }, +}); + +const voidEverythingRule = createRule({ name: 'void-everything', defaultOptions: [], meta: { @@ -45,16 +54,7 @@ const rule = createRule({ }, }); -const rootPath = getFixturesRootDir(); -const ruleTester = new RuleTester({ - parser: '@typescript-eslint/parser', - parserOptions: { - tsconfigRootDir: rootPath, - project: './tsconfig.json', - }, -}); - -ruleTester.run('getWrappingFixer', rule, { +ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { valid: [], invalid: [ // should add parens when inner expression might need them @@ -183,11 +183,6 @@ ruleTester.run('getWrappingFixer', rule, { errors: [{ messageId: 'addVoid' }], output: 'function* fn() { yield void wrapMe }', }, - { - code: '() => wrapMe', - errors: [{ messageId: 'addVoid' }], - output: '() => void wrapMe', - }, { code: 'if (wrapMe) {}', errors: [{ messageId: 'addVoid' }], @@ -309,3 +304,65 @@ ruleTester.run('getWrappingFixer', rule, { }, ], }); + +const removeFunctionRule = createRule({ + name: 'remove-function', + defaultOptions: [], + meta: { + type: 'suggestion', + fixable: 'code', + docs: { + description: + 'Remove function with first arg remaining in random places for test purposes.', + }, + messages: { + removeFunction: 'Please remove this function', + }, + schema: [], + }, + + create(context) { + const sourceCode = context.getSourceCode(); + + const report = (node: TSESTree.CallExpression): void => { + context.report({ + node, + messageId: 'removeFunction', + fix: getWrappingFixer({ + sourceCode, + node, + innerNode: [node.arguments[0]], + wrap: code => code, + }), + }); + }; + + return { + 'CallExpression[callee.name="fn"]': report, + }; + }, +}); + +ruleTester.run('getWrappingFixer - removeFunctionRule', removeFunctionRule, { + valid: [], + invalid: [ + // should add parens when a inner node is a part of return body of node's parent + { + code: '() => fn({ x: "wrapObject" })', + errors: [{ messageId: 'removeFunction' }], + output: '() => ({ x: "wrapObject" })', + }, + + // shouldn't add parens when not necessary + { + code: 'const a = fn({ x: "wrapObject" })', + errors: [{ messageId: 'removeFunction' }], + output: 'const a = { x: "wrapObject" }', + }, + { + code: '() => fn("string")', + errors: [{ messageId: 'removeFunction' }], + output: '() => "string"', + }, + ], +});