diff --git a/packages/eslint-plugin/docs/rules/prefer-optional-chain.md b/packages/eslint-plugin/docs/rules/prefer-optional-chain.md index 1f42754e0f7..4d0e0871c73 100644 --- a/packages/eslint-plugin/docs/rules/prefer-optional-chain.md +++ b/packages/eslint-plugin/docs/rules/prefer-optional-chain.md @@ -22,6 +22,10 @@ function myFunc(foo: T | null) { function myFunc(foo: T | null) { return foo && foo.a && foo.a.b && foo.a.b.c; } +// or +function myFunc(foo: T | null) { + return (((foo || {}).a || {}).b || {}).c; +} function myFunc(foo: T | null) { return foo?.['a']?.b?.c; @@ -57,6 +61,9 @@ foo && foo.a && foo.a.b && foo.a.b.c; foo && foo['a'] && foo['a'].b && foo['a'].b.c; foo && foo.a && foo.a.b && foo.a.b.method && foo.a.b.method(); +(((foo || {}).a || {}).b {}).c; +(((foo || {})['a'] || {}).b {}).c; + // this rule also supports converting chained strict nullish checks: foo && foo.a != null && diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 109deaa6d60..b3815e09ef9 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -1,5 +1,7 @@ -import { AST_NODE_TYPES, TSESTree, TSESLint } from '@typescript-eslint/utils'; +import * as ts from 'typescript'; import * as util from '../util'; +import { AST_NODE_TYPES, TSESTree, TSESLint } from '@typescript-eslint/utils'; +import { isBinaryExpression } from 'tsutils'; type ValidChainTarget = | TSESTree.BinaryExpression @@ -47,7 +49,68 @@ export default util.createRule({ defaultOptions: [], create(context) { const sourceCode = context.getSourceCode(); + const parserServices = util.getParserServices(context, true); + return { + 'LogicalExpression[operator="||"], LogicalExpression[operator="??"]'( + node: TSESTree.LogicalExpression, + ): void { + const leftNode = node.left; + const rightNode = node.right; + const parentNode = node.parent; + const isRightNodeAnEmptyObjectLiteral = + rightNode.type === AST_NODE_TYPES.ObjectExpression && + rightNode.properties.length === 0; + if ( + !isRightNodeAnEmptyObjectLiteral || + !parentNode || + parentNode.type !== AST_NODE_TYPES.MemberExpression || + parentNode.optional + ) { + return; + } + + function isLeftSideLowerPrecedence(): boolean { + const logicalTsNode = parserServices.esTreeNodeToTSNodeMap.get(node); + + const leftTsNode = parserServices.esTreeNodeToTSNodeMap.get(leftNode); + const operator = isBinaryExpression(logicalTsNode) + ? logicalTsNode.operatorToken.kind + : ts.SyntaxKind.Unknown; + const leftPrecedence = util.getOperatorPrecedence( + leftTsNode.kind, + operator, + ); + + return leftPrecedence < util.OperatorPrecedence.LeftHandSide; + } + context.report({ + node: parentNode, + messageId: 'optionalChainSuggest', + suggest: [ + { + messageId: 'optionalChainSuggest', + fix: (fixer): TSESLint.RuleFix => { + const leftNodeText = sourceCode.getText(leftNode); + // Any node that is made of an operator with higher or equal precedence, + const maybeWrappedLeftNode = isLeftSideLowerPrecedence() + ? `(${leftNodeText})` + : leftNodeText; + const propertyToBeOptionalText = sourceCode.getText( + parentNode.property, + ); + const maybeWrappedProperty = parentNode.computed + ? `[${propertyToBeOptionalText}]` + : propertyToBeOptionalText; + return fixer.replaceTextRange( + parentNode.range, + `${maybeWrappedLeftNode}?.${maybeWrappedProperty}`, + ); + }, + }, + ], + }); + }, [[ 'LogicalExpression[operator="&&"] > Identifier', 'LogicalExpression[operator="&&"] > MemberExpression', diff --git a/packages/eslint-plugin/src/util/index.ts b/packages/eslint-plugin/src/util/index.ts index a83198807a6..b2932466388 100644 --- a/packages/eslint-plugin/src/util/index.ts +++ b/packages/eslint-plugin/src/util/index.ts @@ -4,6 +4,7 @@ export * from './astUtils'; export * from './collectUnusedVariables'; export * from './createRule'; export * from './getFunctionHeadLoc'; +export * from './getOperatorPrecedence'; export * from './getThisExpression'; export * from './getWrappingFixer'; export * from './misc'; diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain.test.ts index d6ac19abb89..2b004e9d7a9 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain.test.ts @@ -146,6 +146,22 @@ const baseCases = [ ruleTester.run('prefer-optional-chain', rule, { valid: [ + 'foo || {};', + 'foo || ({} as any);', + '(foo || {})?.bar;', + '(foo || { bar: 1 }).bar;', + '(undefined && (foo || {})).bar;', + 'foo ||= bar;', + 'foo ||= bar || {};', + 'foo ||= bar?.baz;', + 'foo ||= bar?.baz || {};', + 'foo ||= bar?.baz?.buzz;', + '(foo1 ? foo2 : foo3 || {}).foo4;', + '(foo = 2 || {}).bar;', + 'func(foo || {}).bar;', + 'foo ?? {};', + '(foo ?? {})?.bar;', + 'foo ||= bar ?? {};', 'foo && bar;', 'foo && foo;', 'foo || bar;', @@ -501,5 +517,631 @@ foo?.bar(/* comment */a, }, }, }, + { + code: '(foo || {}).bar;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 16, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar;', + }, + ], + }, + ], + }, + { + code: noFormat`(foo || ({})).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 18, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar;', + }, + ], + }, + ], + }, + { + code: noFormat`(await foo || {}).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 22, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(await foo)?.bar;', + }, + ], + }, + ], + }, + { + code: '(foo1?.foo2 || {}).foo3;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 24, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo1?.foo2?.foo3;', + }, + ], + }, + ], + }, + { + code: '((() => foo())() || {}).bar;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 28, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(() => foo())()?.bar;', + }, + ], + }, + ], + }, + { + code: 'const foo = (bar || {}).baz;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 13, + endColumn: 28, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'const foo = bar?.baz;', + }, + ], + }, + ], + }, + { + code: '(foo.bar || {})[baz];', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 21, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo.bar?.[baz];', + }, + ], + }, + ], + }, + { + code: '((foo1 || {}).foo2 || {}).foo3;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 31, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo1 || {}).foo2?.foo3;', + }, + ], + }, + { + messageId: 'optionalChainSuggest', + column: 2, + endColumn: 19, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo1?.foo2 || {}).foo3;', + }, + ], + }, + ], + }, + { + code: '(foo || undefined || {}).bar;', + errors: [ + { + messageId: 'optionalChainSuggest', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo || undefined)?.bar;', + }, + ], + }, + ], + }, + { + code: '(foo() || bar || {}).baz;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 25, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo() || bar)?.baz;', + }, + ], + }, + ], + }, + { + code: '((foo1 ? foo2 : foo3) || {}).foo4;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 34, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo1 ? foo2 : foo3)?.foo4;', + }, + ], + }, + ], + }, + { + code: noFormat`if (foo) { (foo || {}).bar; }`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 12, + endColumn: 27, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: noFormat`if (foo) { foo?.bar; }`, + }, + ], + }, + ], + }, + { + code: noFormat`if ((foo || {}).bar) { foo.bar; }`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 5, + endColumn: 20, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: noFormat`if (foo?.bar) { foo.bar; }`, + }, + ], + }, + ], + }, + { + code: noFormat`(undefined && foo || {}).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 29, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(undefined && foo)?.bar;', + }, + ], + }, + ], + }, + { + code: '(foo ?? {}).bar;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 16, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar;', + }, + ], + }, + ], + }, + { + code: noFormat`(foo ?? ({})).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 18, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar;', + }, + ], + }, + ], + }, + { + code: noFormat`(await foo ?? {}).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 22, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(await foo)?.bar;', + }, + ], + }, + ], + }, + { + code: '(foo1?.foo2 ?? {}).foo3;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 24, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo1?.foo2?.foo3;', + }, + ], + }, + ], + }, + { + code: '((() => foo())() ?? {}).bar;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 28, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(() => foo())()?.bar;', + }, + ], + }, + ], + }, + { + code: 'const foo = (bar ?? {}).baz;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 13, + endColumn: 28, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'const foo = bar?.baz;', + }, + ], + }, + ], + }, + { + code: '(foo.bar ?? {})[baz];', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 21, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo.bar?.[baz];', + }, + ], + }, + ], + }, + { + code: '((foo1 ?? {}).foo2 ?? {}).foo3;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 31, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo1 ?? {}).foo2?.foo3;', + }, + ], + }, + { + messageId: 'optionalChainSuggest', + column: 2, + endColumn: 19, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo1?.foo2 ?? {}).foo3;', + }, + ], + }, + ], + }, + { + code: '(foo ?? undefined ?? {}).bar;', + errors: [ + { + messageId: 'optionalChainSuggest', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo ?? undefined)?.bar;', + }, + ], + }, + ], + }, + { + code: '(foo() ?? bar ?? {}).baz;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 25, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo() ?? bar)?.baz;', + }, + ], + }, + ], + }, + { + code: '((foo1 ? foo2 : foo3) ?? {}).foo4;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 34, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo1 ? foo2 : foo3)?.foo4;', + }, + ], + }, + ], + }, + { + code: noFormat`if (foo) { (foo ?? {}).bar; }`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 12, + endColumn: 27, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: noFormat`if (foo) { foo?.bar; }`, + }, + ], + }, + ], + }, + { + code: noFormat`if ((foo ?? {}).bar) { foo.bar; }`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 5, + endColumn: 20, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: noFormat`if (foo?.bar) { foo.bar; }`, + }, + ], + }, + ], + }, + { + code: noFormat`(undefined && foo ?? {}).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 29, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(undefined && foo)?.bar;', + }, + ], + }, + ], + }, + { + code: noFormat`(a > b || {}).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 18, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(a > b)?.bar;', + }, + ], + }, + ], + }, + { + code: noFormat`(((typeof x) as string) || {}).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 35, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: noFormat`((typeof x) as string)?.bar;`, + }, + ], + }, + ], + }, + { + code: '(void foo() || {}).bar;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 23, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(void foo())?.bar;', + }, + ], + }, + ], + }, + { + code: '((a ? b : c) || {}).bar;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 24, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(a ? b : c)?.bar;', + }, + ], + }, + ], + }, + { + code: noFormat`((a instanceof Error) || {}).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 33, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(a instanceof Error)?.bar;', + }, + ], + }, + ], + }, + { + code: noFormat`((a << b) || {}).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 21, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(a << b)?.bar;', + }, + ], + }, + ], + }, + { + code: noFormat`((foo ** 2) || {}).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 23, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo ** 2)?.bar;', + }, + ], + }, + ], + }, + { + code: '(foo ** 2 || {}).bar;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 21, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo ** 2)?.bar;', + }, + ], + }, + ], + }, + { + code: '(foo++ || {}).bar;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 18, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(foo++)?.bar;', + }, + ], + }, + ], + }, + { + code: '(+foo || {}).bar;', + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 17, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '(+foo)?.bar;', + }, + ], + }, + ], + }, ], });