From a657697c005d648457a3b67d8cf1f85003ea2d0f Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 31 Jan 2023 23:36:00 +1030 Subject: [PATCH 01/26] feat(eslint-plugin): [prefer-optional-chain] support more cases --- .../BinaryExpression/BinaryOperatorToText.ts | 36 +++ .../src/expression/BinaryExpression/spec.ts | 6 +- .../tests/BinaryOperatorToText.type-test.ts | 19 ++ packages/eslint-plugin/src/rules/indent.ts | 4 +- .../src/rules/prefer-optional-chain.ts | 299 +++++++++++++++++- 5 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 packages/ast-spec/src/expression/BinaryExpression/BinaryOperatorToText.ts create mode 100644 packages/ast-spec/tests/BinaryOperatorToText.type-test.ts diff --git a/packages/ast-spec/src/expression/BinaryExpression/BinaryOperatorToText.ts b/packages/ast-spec/src/expression/BinaryExpression/BinaryOperatorToText.ts new file mode 100644 index 000000000000..38ed97401513 --- /dev/null +++ b/packages/ast-spec/src/expression/BinaryExpression/BinaryOperatorToText.ts @@ -0,0 +1,36 @@ +import type { SyntaxKind } from 'typescript'; + +// the members of ts.BinaryOperator +export interface BinaryOperatorToText { + [SyntaxKind.QuestionQuestionToken]: '??'; + [SyntaxKind.InstanceOfKeyword]: 'instanceof'; + [SyntaxKind.InKeyword]: 'in'; + + // math + [SyntaxKind.AsteriskAsteriskToken]: '**'; + [SyntaxKind.AsteriskToken]: '*'; + [SyntaxKind.SlashToken]: '/'; + [SyntaxKind.PercentToken]: '%'; + [SyntaxKind.PlusToken]: '+'; + [SyntaxKind.MinusToken]: '-'; + + // bitwise + [SyntaxKind.AmpersandToken]: '&'; + [SyntaxKind.BarToken]: '|'; + [SyntaxKind.CaretToken]: '^'; + [SyntaxKind.LessThanLessThanToken]: '<<'; + [SyntaxKind.GreaterThanGreaterThanToken]: '>>'; + [SyntaxKind.GreaterThanGreaterThanGreaterThanToken]: '>>>'; + + // logical + [SyntaxKind.AmpersandAmpersandToken]: '&&'; + [SyntaxKind.BarBarToken]: '||'; + [SyntaxKind.LessThanToken]: '<'; + [SyntaxKind.LessThanEqualsToken]: '<='; + [SyntaxKind.GreaterThanToken]: '>'; + [SyntaxKind.GreaterThanEqualsToken]: '>='; + [SyntaxKind.EqualsEqualsToken]: '=='; + [SyntaxKind.EqualsEqualsEqualsToken]: '==='; + [SyntaxKind.ExclamationEqualsEqualsToken]: '!=='; + [SyntaxKind.ExclamationEqualsToken]: '!='; +} diff --git a/packages/ast-spec/src/expression/BinaryExpression/spec.ts b/packages/ast-spec/src/expression/BinaryExpression/spec.ts index fa43c88bcf50..d42f1d4e77f4 100644 --- a/packages/ast-spec/src/expression/BinaryExpression/spec.ts +++ b/packages/ast-spec/src/expression/BinaryExpression/spec.ts @@ -2,10 +2,14 @@ import type { AST_NODE_TYPES } from '../../ast-node-types'; import type { BaseNode } from '../../base/BaseNode'; import type { PrivateIdentifier } from '../../special/PrivateIdentifier/spec'; import type { Expression } from '../../unions/Expression'; +import type { ValueOf } from '../../utils'; +import type { BinaryOperatorToText } from './BinaryOperatorToText'; + +export * from './BinaryOperatorToText'; export interface BinaryExpression extends BaseNode { type: AST_NODE_TYPES.BinaryExpression; - operator: string; + operator: ValueOf; left: Expression | PrivateIdentifier; right: Expression; } diff --git a/packages/ast-spec/tests/BinaryOperatorToText.type-test.ts b/packages/ast-spec/tests/BinaryOperatorToText.type-test.ts new file mode 100644 index 000000000000..22893732e585 --- /dev/null +++ b/packages/ast-spec/tests/BinaryOperatorToText.type-test.ts @@ -0,0 +1,19 @@ +import type { + AssignmentOperator, + BinaryOperator, + SyntaxKind, +} from 'typescript'; + +import type { BinaryOperatorToText } from '../src'; + +type BinaryOperatorWithoutInvalidTypes = Exclude< + BinaryOperator, + | AssignmentOperator // --> AssignmentExpression + | SyntaxKind.CommaToken // -> SequenceExpression +>; +type _Test = { + readonly [T in BinaryOperatorWithoutInvalidTypes]: BinaryOperatorToText[T]; + // If there are any BinaryOperator members that don't have a corresponding + // BinaryOperatorToText, then this line will error with "Type 'T' cannot + // be used to index type 'BinaryOperatorToText'." +}; diff --git a/packages/eslint-plugin/src/rules/indent.ts b/packages/eslint-plugin/src/rules/indent.ts index 19796054a4c6..e67b5df2d648 100644 --- a/packages/eslint-plugin/src/rules/indent.ts +++ b/packages/eslint-plugin/src/rules/indent.ts @@ -193,7 +193,7 @@ export default util.createRule({ // transform it to a BinaryExpression return rules['BinaryExpression, LogicalExpression']({ type: AST_NODE_TYPES.BinaryExpression, - operator: 'as', + operator: 'as' as any, left: node.expression, // the first typeAnnotation includes the as token right: node.typeAnnotation as any, @@ -211,7 +211,7 @@ export default util.createRule({ type: AST_NODE_TYPES.ConditionalExpression, test: { type: AST_NODE_TYPES.BinaryExpression, - operator: 'extends', + operator: 'extends' as any, left: node.checkType as any, right: node.extendsType as any, diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index efccc2ccfd2d..3558f0d45249 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -1,4 +1,4 @@ -import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import { TSESLint, TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import { isBinaryExpression } from 'tsutils'; import * as ts from 'typescript'; @@ -29,7 +29,265 @@ The AST will look like this: } right: foo.bar.baz.buzz } + +So given any logical expression, we can perform a depth-first traversal to get +the operands in order. */ +function gatherLogicalOperands(node: TSESTree.LogicalExpression): { + operands: TSESTree.Expression[]; + seenLogicals: Set; +} { + const operands: TSESTree.Expression[] = []; + const seenLogicals = new Set([node]); + + const stack: TSESTree.Expression[] = [node.right, node.left]; + let current: TSESTree.Expression | undefined; + while ((current = stack.pop())) { + if ( + current.type === AST_NODE_TYPES.LogicalExpression && + current.operator === node.operator + ) { + seenLogicals.add(current); + stack.push(current.right); + stack.push(current.left); + } else { + operands.push(current); + } + } + + return { + operands, + seenLogicals, + }; +} + +const enum ComparisonType { + // x == null + // x == undefined + NotEqNullOrUndefined, + EqNullOrUndefined, + + // x === null + NotEqNull, + EqNull, + + // x === undefined + // typeof x === 'undefined' + NotEqUndefined, + EqUndefined, + + // x + // !x + NotBoolean, + Boolean, +} +type AnalysedOperand = + | { + type: 'valid-comparison'; + comparedName: string[]; + comparisonType: ComparisonType; + node: TSESTree.Expression; + } + | { + type: 'invalid-comparison'; + }; +const enum ComparisonValueType { + Null, + Undefined, + UndefinedStringLiteral, +} +function getComparisonValueType( + node: TSESTree.Expression, +): ComparisonValueType | null { + switch (node.type) { + case AST_NODE_TYPES.Literal: + if (node.value === null && node.raw === 'null') { + return ComparisonValueType.Null; + } + if (node.value === 'undefined') { + return ComparisonValueType.UndefinedStringLiteral; + } + return null; + + case AST_NODE_TYPES.Identifier: + if (node.name === 'undefined') { + return ComparisonValueType.Undefined; + } + return null; + } + + return null; +} + +function getComparedName( + node: TSESTree.Expression | TSESTree.PrivateIdentifier, +): string[] { + // TODO +} + +function analyseLogicalOperands(node: TSESTree.LogicalExpression): { + operands: AnalysedOperand[]; + operator: TSESTree.LogicalExpression['operator']; + seenLogicals: Set; +} { + const result: AnalysedOperand[] = []; + const { operands, seenLogicals } = gatherLogicalOperands(node); + + for (const operand of operands) { + switch (operand.type) { + case AST_NODE_TYPES.BinaryExpression: { + // check for "yoda" style logical: null != x + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- TODO - add ignore IIFE option + const { comparedExpression, comparedValue } = (() => { + const comparedValueLeft = getComparisonValueType(node.left); + if (comparedValueLeft) { + return { + comparedExpression: operand.right, + comparedValue: comparedValueLeft, + }; + } else { + return { + comparedExpression: operand.left, + comparedValue: getComparisonValueType(operand.right), + }; + } + })(); + + if (comparedValue === ComparisonValueType.UndefinedStringLiteral) { + if ( + comparedExpression.type === AST_NODE_TYPES.UnaryExpression && + comparedExpression.operator === 'typeof' + ) { + // typeof x === 'undefined' + result.push({ + type: 'valid-comparison', + comparedName: getComparedName(comparedExpression.argument), + comparisonType: node.operator.startsWith('!') + ? ComparisonType.NotEqUndefined + : ComparisonType.EqUndefined, + node: operand, + }); + continue; + } + + // typeof x === 'string' et al :( + result.push({ type: 'invalid-comparison' }); + continue; + } + + switch (operand.operator) { + case '!=': + case '==': + if ( + comparedValue === ComparisonValueType.Null || + comparedValue === ComparisonValueType.Undefined + ) { + // x == null, x == undefined + result.push({ + type: 'valid-comparison', + comparedName: getComparedName(comparedExpression), + comparisonType: operand.operator.startsWith('!') + ? ComparisonType.NotEqNullOrUndefined + : ComparisonType.EqNullOrUndefined, + node: operand, + }); + continue; + } + // x == something :( + result.push({ type: 'invalid-comparison' }); + continue; + + case '!==': + case '===': { + const comparedName = getComparedName(comparedExpression); + switch (comparedValue) { + case ComparisonValueType.Null: + result.push({ + type: 'valid-comparison', + comparedName, + comparisonType: operand.operator.startsWith('!') + ? ComparisonType.NotEqNull + : ComparisonType.EqNull, + node: operand, + }); + continue; + + case ComparisonValueType.Undefined: + result.push({ + type: 'valid-comparison', + comparedName, + comparisonType: operand.operator.startsWith('!') + ? ComparisonType.NotEqUndefined + : ComparisonType.EqUndefined, + node: operand, + }); + continue; + + default: + // x === something :( + result.push({ type: 'invalid-comparison' }); + continue; + } + } + } + + result.push({ type: 'invalid-comparison' }); + continue; + } + + case AST_NODE_TYPES.UnaryExpression: + if (operand.operator === '!') { + result.push({ + type: 'valid-comparison', + comparedName: getComparedName(operand.argument), + comparisonType: ComparisonType.NotBoolean, + node: operand, + }); + } + result.push({ type: 'invalid-comparison' }); + continue; + + case AST_NODE_TYPES.Identifier: + result.push({ + type: 'valid-comparison', + comparedName: getComparedName(operand), + comparisonType: ComparisonType.Boolean, + node: operand, + }); + continue; + } + } + + return { + operands: result, + seenLogicals, + operator: node.operator, + }; +} + +function analyzeAndChain(chain: AnalysedOperand[]): void { + // TODO - expect "!= null"-like chain +} +function analyzeOrChain(chain: AnalysedOperand[]): void { + // TODO - expect "== null"-like chain +} +function analyzeChain( + operator: TSESTree.LogicalExpression['operator'], + chain: AnalysedOperand[], +): void { + if (chain.length === 0) { + return; + } + + switch (operator) { + case '&&': + analyzeAndChain(chain); + break; + case '||': + analyzeOrChain(chain); + break; + } +} export default util.createRule({ name: 'prefer-optional-chain', @@ -53,10 +311,15 @@ export default util.createRule({ const sourceCode = context.getSourceCode(); const parserServices = util.getParserServices(context, true); + const seenLogicals = new Set(); + return { + // specific handling for `(foo ?? {}).bar` / `(foo || {}).bar` 'LogicalExpression[operator="||"], LogicalExpression[operator="??"]'( node: TSESTree.LogicalExpression, ): void { + seenLogicals.add(node); + const leftNode = node.left; const rightNode = node.right; const parentNode = node.parent; @@ -113,6 +376,40 @@ export default util.createRule({ ], }); }, + + LogicalExpression(node): void { + if (seenLogicals.has(node)) { + return; + } + + const { + operands, + operator, + seenLogicals: newSeenLogicals, + } = analyseLogicalOperands(node); + + for (const logical of newSeenLogicals) { + seenLogicals.add(logical); + } + + if (operator === '??') { + return; + } + + let currentChain: AnalysedOperand[] = []; + for (const operand of operands) { + if (operand.type === 'invalid-comparison') { + analyzeChain(operator, currentChain); + currentChain = []; + } else { + currentChain.push(operand); + } + } + + // make sure to check whatever's left + analyzeChain(operator, currentChain); + }, + [[ 'LogicalExpression[operator="||"] > UnaryExpression[operator="!"] > Identifier', 'LogicalExpression[operator="||"] > UnaryExpression[operator="!"] > MemberExpression', From 0eaad015c85fb2776db3216550d0f8698802c63f Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Wed, 1 Feb 2023 09:54:04 +1030 Subject: [PATCH 02/26] wip --- packages/eslint-plugin/src/rules/prefer-optional-chain.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 3558f0d45249..f60e6a7b920f 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -122,7 +122,7 @@ function getComparisonValueType( function getComparedName( node: TSESTree.Expression | TSESTree.PrivateIdentifier, ): string[] { - // TODO + // TODO - add generic logic for getting the "compared name" text in a standardized format } function analyseLogicalOperands(node: TSESTree.LogicalExpression): { @@ -266,10 +266,12 @@ function analyseLogicalOperands(node: TSESTree.LogicalExpression): { } function analyzeAndChain(chain: AnalysedOperand[]): void { - // TODO - expect "!= null"-like chain + // TODO - "!= null"-like chains + // TODO - "x && x.y" chains } function analyzeOrChain(chain: AnalysedOperand[]): void { - // TODO - expect "== null"-like chain + // TODO - "== null"-like chains + // TODO - "!x || !x.y" chains } function analyzeChain( operator: TSESTree.LogicalExpression['operator'], From bbf5788b93dd2dd92088f604c21e6d92de676219 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 4 Feb 2023 16:51:39 +1030 Subject: [PATCH 03/26] WIP --- .../src/rules/prefer-optional-chain.ts | 1047 ++++++----------- 1 file changed, 331 insertions(+), 716 deletions(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index f60e6a7b920f..1c662d1add7c 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -1,137 +1,61 @@ -import { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import { isBinaryExpression } from 'tsutils'; import * as ts from 'typescript'; import * as util from '../util'; -type ValidChainTarget = - | TSESTree.BinaryExpression - | TSESTree.CallExpression - | TSESTree.ChainExpression - | TSESTree.Identifier - | TSESTree.PrivateIdentifier - | TSESTree.MemberExpression - | TSESTree.ThisExpression - | TSESTree.MetaProperty; - -/* -The AST is always constructed such the first element is always the deepest element. -I.e. for this code: `foo && foo.bar && foo.bar.baz && foo.bar.baz.buzz` -The AST will look like this: -{ - left: { - left: { - left: foo - right: foo.bar - } - right: foo.bar.baz - } - right: foo.bar.baz.buzz -} - -So given any logical expression, we can perform a depth-first traversal to get -the operands in order. -*/ -function gatherLogicalOperands(node: TSESTree.LogicalExpression): { - operands: TSESTree.Expression[]; - seenLogicals: Set; -} { - const operands: TSESTree.Expression[] = []; - const seenLogicals = new Set([node]); - - const stack: TSESTree.Expression[] = [node.right, node.left]; - let current: TSESTree.Expression | undefined; - while ((current = stack.pop())) { - if ( - current.type === AST_NODE_TYPES.LogicalExpression && - current.operator === node.operator - ) { - seenLogicals.add(current); - stack.push(current.right); - stack.push(current.left); - } else { - operands.push(current); - } - } - - return { - operands, - seenLogicals, - }; -} +type TMessageIds = 'preferOptionalChain' | 'optionalChainSuggest'; +type TOptions = []; -const enum ComparisonType { - // x == null - // x == undefined +const enum NullishComparisonType { + /** `x != null`, `x != undefined` */ NotEqNullOrUndefined, + /** `x == null`, `x == undefined` */ EqNullOrUndefined, - // x === null + /** `x !== null` */ NotEqNull, + /** `x === null` */ EqNull, - // x === undefined - // typeof x === 'undefined' + /** `x !== undefined`, `typeof x !== 'undefined'` */ NotEqUndefined, + /** `x === undefined`, `typeof x === 'undefined'` */ EqUndefined, - // x - // !x + /** `x` */ NotBoolean, + /** `!x` */ Boolean, } -type AnalysedOperand = - | { - type: 'valid-comparison'; - comparedName: string[]; - comparisonType: ComparisonType; - node: TSESTree.Expression; - } - | { - type: 'invalid-comparison'; - }; -const enum ComparisonValueType { - Null, - Undefined, - UndefinedStringLiteral, +type ComparisonNode = TSESTree.Expression | TSESTree.PrivateIdentifier; +const enum OperandValidity { + Valid, + Invalid, } -function getComparisonValueType( - node: TSESTree.Expression, -): ComparisonValueType | null { - switch (node.type) { - case AST_NODE_TYPES.Literal: - if (node.value === null && node.raw === 'null') { - return ComparisonValueType.Null; - } - if (node.value === 'undefined') { - return ComparisonValueType.UndefinedStringLiteral; - } - return null; - - case AST_NODE_TYPES.Identifier: - if (node.name === 'undefined') { - return ComparisonValueType.Undefined; - } - return null; - } - - return null; +interface ValidOperand { + type: OperandValidity.Valid; + comparedName: ComparisonNode; + comparisonType: NullishComparisonType; + node: TSESTree.Expression; } - -function getComparedName( - node: TSESTree.Expression | TSESTree.PrivateIdentifier, -): string[] { - // TODO - add generic logic for getting the "compared name" text in a standardized format +interface InvalidOperand { + type: OperandValidity.Invalid; } - -function analyseLogicalOperands(node: TSESTree.LogicalExpression): { - operands: AnalysedOperand[]; - operator: TSESTree.LogicalExpression['operator']; +type Operand = ValidOperand | InvalidOperand; +function gatherLogicalOperands(node: TSESTree.LogicalExpression): { + operands: Operand[]; seenLogicals: Set; } { - const result: AnalysedOperand[] = []; - const { operands, seenLogicals } = gatherLogicalOperands(node); + const enum ComparisonValueType { + Null, + Undefined, + UndefinedStringLiteral, + } + + const result: Operand[] = []; + const { operands, seenLogicals } = flattenLogicalOperands(node); for (const operand of operands) { switch (operand.type) { @@ -160,18 +84,18 @@ function analyseLogicalOperands(node: TSESTree.LogicalExpression): { ) { // typeof x === 'undefined' result.push({ - type: 'valid-comparison', - comparedName: getComparedName(comparedExpression.argument), + type: OperandValidity.Valid, + comparedName: comparedExpression.argument, comparisonType: node.operator.startsWith('!') - ? ComparisonType.NotEqUndefined - : ComparisonType.EqUndefined, + ? NullishComparisonType.NotEqUndefined + : NullishComparisonType.EqUndefined, node: operand, }); continue; } // typeof x === 'string' et al :( - result.push({ type: 'invalid-comparison' }); + result.push({ type: OperandValidity.Invalid }); continue; } @@ -184,74 +108,76 @@ function analyseLogicalOperands(node: TSESTree.LogicalExpression): { ) { // x == null, x == undefined result.push({ - type: 'valid-comparison', - comparedName: getComparedName(comparedExpression), + type: OperandValidity.Valid, + comparedName: comparedExpression, comparisonType: operand.operator.startsWith('!') - ? ComparisonType.NotEqNullOrUndefined - : ComparisonType.EqNullOrUndefined, + ? NullishComparisonType.NotEqNullOrUndefined + : NullishComparisonType.EqNullOrUndefined, node: operand, }); continue; } // x == something :( - result.push({ type: 'invalid-comparison' }); + result.push({ type: OperandValidity.Invalid }); continue; case '!==': case '===': { - const comparedName = getComparedName(comparedExpression); + const comparedName = comparedExpression; switch (comparedValue) { case ComparisonValueType.Null: result.push({ - type: 'valid-comparison', + type: OperandValidity.Valid, comparedName, comparisonType: operand.operator.startsWith('!') - ? ComparisonType.NotEqNull - : ComparisonType.EqNull, + ? NullishComparisonType.NotEqNull + : NullishComparisonType.EqNull, node: operand, }); continue; case ComparisonValueType.Undefined: result.push({ - type: 'valid-comparison', + type: OperandValidity.Valid, comparedName, comparisonType: operand.operator.startsWith('!') - ? ComparisonType.NotEqUndefined - : ComparisonType.EqUndefined, + ? NullishComparisonType.NotEqUndefined + : NullishComparisonType.EqUndefined, node: operand, }); continue; default: // x === something :( - result.push({ type: 'invalid-comparison' }); + result.push({ type: OperandValidity.Invalid }); continue; } } } - result.push({ type: 'invalid-comparison' }); + result.push({ type: OperandValidity.Invalid }); continue; } case AST_NODE_TYPES.UnaryExpression: if (operand.operator === '!') { + // TODO(#4820) - use types here to determine if there is a boolean type that might be refined out result.push({ - type: 'valid-comparison', - comparedName: getComparedName(operand.argument), - comparisonType: ComparisonType.NotBoolean, + type: OperandValidity.Valid, + comparedName: operand.argument, + comparisonType: NullishComparisonType.NotBoolean, node: operand, }); } - result.push({ type: 'invalid-comparison' }); + result.push({ type: OperandValidity.Invalid }); continue; case AST_NODE_TYPES.Identifier: + // TODO(#4820) - use types here to determine if there is a boolean type that might be refined out result.push({ - type: 'valid-comparison', - comparedName: getComparedName(operand), - comparisonType: ComparisonType.Boolean, + type: OperandValidity.Valid, + comparedName: operand, + comparisonType: NullishComparisonType.Boolean, node: operand, }); continue; @@ -261,37 +187,282 @@ function analyseLogicalOperands(node: TSESTree.LogicalExpression): { return { operands: result, seenLogicals, - operator: node.operator, }; + + /* + The AST is always constructed such the first element is always the deepest element. + I.e. for this code: `foo && foo.bar && foo.bar.baz && foo.bar.baz.buzz` + The AST will look like this: + { + left: { + left: { + left: foo + right: foo.bar + } + right: foo.bar.baz + } + right: foo.bar.baz.buzz + } + + So given any logical expression, we can perform a depth-first traversal to get + the operands in order. + + Note that this function purposely does not inspect mixed logical expressions + like `foo || foo.bar && foo.bar.baz` - it will ignore + */ + function flattenLogicalOperands(node: TSESTree.LogicalExpression): { + operands: TSESTree.Expression[]; + seenLogicals: Set; + } { + const operands: TSESTree.Expression[] = []; + const seenLogicals = new Set([node]); + + const stack: TSESTree.Expression[] = [node.right, node.left]; + let current: TSESTree.Expression | undefined; + while ((current = stack.pop())) { + if ( + current.type === AST_NODE_TYPES.LogicalExpression && + current.operator === node.operator + ) { + seenLogicals.add(current); + stack.push(current.right); + stack.push(current.left); + } else { + operands.push(current); + } + } + + return { + operands, + seenLogicals, + }; + } + + function getComparisonValueType( + node: TSESTree.Expression, + ): ComparisonValueType | null { + switch (node.type) { + case AST_NODE_TYPES.Literal: + if (node.value === null && node.raw === 'null') { + return ComparisonValueType.Null; + } + if (node.value === 'undefined') { + return ComparisonValueType.UndefinedStringLiteral; + } + return null; + + case AST_NODE_TYPES.Identifier: + if (node.name === 'undefined') { + return ComparisonValueType.Undefined; + } + return null; + } + + return null; + } } -function analyzeAndChain(chain: AnalysedOperand[]): void { +const enum NodeComparisonResult { + /** the two nodes are comparably the same */ + Equal, + /** the left node is a subset of the right node */ + Subset, + /** the left node is not the same or is a superset of the right node */ + Invalid, +} +function compareNodes( + _nodeA: ComparisonNode, + _nodeB: ComparisonNode, +): NodeComparisonResult { + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + // TODO - recursively compare two nodes + return; +} + +type OperandAnalyzer = ( + operand: ValidOperand, + index: number, + chain: readonly ValidOperand[], +) => readonly [ValidOperand, ...ValidOperand[]] | null; +const analyzeAndChainOperand: OperandAnalyzer = (operand, index, chain) => { // TODO - "!= null"-like chains // TODO - "x && x.y" chains -} -function analyzeOrChain(chain: AnalysedOperand[]): void { + switch (operand.comparisonType) { + case NullishComparisonType.NotBoolean: + case NullishComparisonType.NotEqNullOrUndefined: + return [operand]; + + case NullishComparisonType.NotEqNull: { + // TODO(#4820) - use types here to determine if the value is just `null` (eg `=== null` would be enough on its own) + + // handle `x === null || x === undefined` + const nextOperand = chain[index + 1] as ValidOperand | undefined; + if ( + nextOperand?.comparisonType === NullishComparisonType.NotEqUndefined && + compareNodes(operand.node, nextOperand.node) === + NodeComparisonResult.Equal + ) { + return [operand, nextOperand]; + } else { + return null; + } + } + + case NullishComparisonType.NotEqUndefined: { + // TODO - use types here to determine if the value is just `undefined` (eg `=== undefined` would be enough on its own) + + // handle `x === undefined || x === null` + const nextOperand = chain[index + 1] as ValidOperand | undefined; + if ( + nextOperand?.comparisonType === NullishComparisonType.NotEqNull && + compareNodes(operand.node, nextOperand.node) === + NodeComparisonResult.Equal + ) { + return [operand, nextOperand]; + } else { + return null; + } + } + + default: + return null; + } +}; +const analyzeOrChainOperand: OperandAnalyzer = (operand, index, chain) => { // TODO - "== null"-like chains // TODO - "!x || !x.y" chains -} + switch (operand.comparisonType) { + case NullishComparisonType.Boolean: + case NullishComparisonType.EqNullOrUndefined: + return [operand]; + + case NullishComparisonType.EqNull: { + // TODO(#4820) - use types here to determine if the value is just `null` (eg `=== null` would be enough on its own) + + // handle `x === null || x === undefined` + const nextOperand = chain[index + 1] as ValidOperand | undefined; + if ( + nextOperand?.comparisonType === NullishComparisonType.EqUndefined && + compareNodes(operand.node, nextOperand.node) === + NodeComparisonResult.Equal + ) { + return [operand, nextOperand]; + } else { + return null; + } + } + + case NullishComparisonType.EqUndefined: { + // TODO - use types here to determine if the value is just `undefined` (eg `=== undefined` would be enough on its own) + + // handle `x === undefined || x === null` + const nextOperand = chain[index + 1] as ValidOperand | undefined; + if ( + nextOperand?.comparisonType === NullishComparisonType.EqNull && + compareNodes(operand.node, nextOperand.node) === + NodeComparisonResult.Equal + ) { + return [operand, nextOperand]; + } else { + return null; + } + } + + default: + return null; + } +}; + function analyzeChain( + context: TSESLint.RuleContext, operator: TSESTree.LogicalExpression['operator'], - chain: AnalysedOperand[], + chain: ValidOperand[], ): void { if (chain.length === 0) { return; } - switch (operator) { - case '&&': - analyzeAndChain(chain); - break; - case '||': - analyzeOrChain(chain); - break; + const analyzeOperand = ((): OperandAnalyzer | null => { + switch (operator) { + case '&&': + return analyzeAndChainOperand; + + case '||': + return analyzeOrChainOperand; + + case '??': + return null; + } + })(); + if (!analyzeOperand) { + return; + } + + let subChain: ValidOperand[] = []; + const maybeReportThenReset = ( + newChainSeed?: readonly ValidOperand[], + ): void => { + if (subChain.length > 1) { + context.report({ + messageId: 'preferOptionalChain', + loc: { + start: subChain[0].node.loc.start, + end: subChain[subChain.length - 1].node.loc.end, + }, + }); + } + + // we've reached the end of a chain of logical expressions + // we know the validated + subChain = newChainSeed ? [...newChainSeed] : []; + }; + + for (let i = 0; i < chain.length; i += 1) { + const lastOperand = subChain[subChain.length - 1] as + | ValidOperand + | undefined; + const operand = chain[i]; + + const validatedOperands = analyzeOperand(operand, i, chain); + if (!validatedOperands) { + maybeReportThenReset(); + continue; + } + // in case multiple operands were consumed - make sure to correctly increment the index + i += validatedOperands.length - 1; + + if (lastOperand) { + const currentOperand = validatedOperands[0]; + const comparisonResult = compareNodes( + lastOperand.node, + currentOperand.node, + ); + if ( + comparisonResult === NodeComparisonResult.Subset || + comparisonResult === NodeComparisonResult.Equal + ) { + // the operands are comparable, so we can continue searching + subChain.push(...validatedOperands); + } else { + maybeReportThenReset(validatedOperands); + } + } } } -export default util.createRule({ +export default util.createRule({ name: 'prefer-optional-chain', meta: { type: 'suggestion', @@ -380,28 +551,25 @@ export default util.createRule({ }, LogicalExpression(node): void { + if (node.operator === '??') { + return; + } + if (seenLogicals.has(node)) { return; } - const { - operands, - operator, - seenLogicals: newSeenLogicals, - } = analyseLogicalOperands(node); + const { operands, seenLogicals: newSeenLogicals } = + gatherLogicalOperands(node); for (const logical of newSeenLogicals) { seenLogicals.add(logical); } - if (operator === '??') { - return; - } - - let currentChain: AnalysedOperand[] = []; + let currentChain: ValidOperand[] = []; for (const operand of operands) { - if (operand.type === 'invalid-comparison') { - analyzeChain(operator, currentChain); + if (operand.type === OperandValidity.Invalid) { + analyzeChain(context, node.operator, currentChain); currentChain = []; } else { currentChain.push(operand); @@ -409,561 +577,8 @@ export default util.createRule({ } // make sure to check whatever's left - analyzeChain(operator, currentChain); - }, - - [[ - 'LogicalExpression[operator="||"] > UnaryExpression[operator="!"] > Identifier', - 'LogicalExpression[operator="||"] > UnaryExpression[operator="!"] > MemberExpression', - 'LogicalExpression[operator="||"] > UnaryExpression[operator="!"] > ChainExpression > MemberExpression', - 'LogicalExpression[operator="||"] > UnaryExpression[operator="!"] > MetaProperty', - ].join(',')]( - initialIdentifierOrNotEqualsExpr: - | TSESTree.Identifier - | TSESTree.MemberExpression - | TSESTree.MetaProperty, - ): void { - // selector guarantees this cast - const initialExpression = ( - initialIdentifierOrNotEqualsExpr.parent!.type === - AST_NODE_TYPES.ChainExpression - ? initialIdentifierOrNotEqualsExpr.parent.parent - : initialIdentifierOrNotEqualsExpr.parent - )!.parent as TSESTree.LogicalExpression; - - if ( - initialExpression.left.type !== AST_NODE_TYPES.UnaryExpression || - initialExpression.left.argument !== initialIdentifierOrNotEqualsExpr - ) { - // the node(identifier or member expression) is not the deepest left node - return; - } - - // walk up the tree to figure out how many logical expressions we can include - let previous: TSESTree.LogicalExpression = initialExpression; - let current: TSESTree.Node = initialExpression; - let previousLeftText = getText(initialIdentifierOrNotEqualsExpr); - let optionallyChainedCode = previousLeftText; - let expressionCount = 1; - while (current.type === AST_NODE_TYPES.LogicalExpression) { - if ( - current.right.type !== AST_NODE_TYPES.UnaryExpression || - !isValidChainTarget( - current.right.argument, - // only allow unary '!' with identifiers for the first chain - !foo || !foo() - expressionCount === 1, - ) - ) { - break; - } - const { rightText, shouldBreak } = breakIfInvalid({ - rightNode: current.right.argument, - previousLeftText, - }); - if (shouldBreak) { - break; - } - - let invalidOptionallyChainedPrivateProperty; - ({ - invalidOptionallyChainedPrivateProperty, - expressionCount, - previousLeftText, - optionallyChainedCode, - previous, - current, - } = normalizeRepeatingPatterns( - rightText, - expressionCount, - previousLeftText, - optionallyChainedCode, - previous, - current, - )); - if (invalidOptionallyChainedPrivateProperty) { - return; - } - } - - reportIfMoreThanOne({ - expressionCount, - previous, - optionallyChainedCode, - sourceCode, - context, - shouldHandleChainedAnds: false, - }); - }, - [[ - 'LogicalExpression[operator="&&"] > Identifier', - 'LogicalExpression[operator="&&"] > MemberExpression', - 'LogicalExpression[operator="&&"] > ChainExpression > MemberExpression', - 'LogicalExpression[operator="&&"] > MetaProperty', - 'LogicalExpression[operator="&&"] > BinaryExpression[operator="!=="]', - 'LogicalExpression[operator="&&"] > BinaryExpression[operator="!="]', - ].join(',')]( - initialIdentifierOrNotEqualsExpr: - | TSESTree.BinaryExpression - | TSESTree.Identifier - | TSESTree.MemberExpression - | TSESTree.MetaProperty, - ): void { - // selector guarantees this cast - const initialExpression = ( - initialIdentifierOrNotEqualsExpr.parent?.type === - AST_NODE_TYPES.ChainExpression - ? initialIdentifierOrNotEqualsExpr.parent.parent - : initialIdentifierOrNotEqualsExpr.parent - ) as TSESTree.LogicalExpression; - - if (initialExpression.left !== initialIdentifierOrNotEqualsExpr) { - // the node(identifier or member expression) is not the deepest left node - return; - } - if (!isValidChainTarget(initialIdentifierOrNotEqualsExpr, true)) { - return; - } - - // walk up the tree to figure out how many logical expressions we can include - let previous: TSESTree.LogicalExpression = initialExpression; - let current: TSESTree.Node = initialExpression; - let previousLeftText = getText(initialIdentifierOrNotEqualsExpr); - let optionallyChainedCode = previousLeftText; - let expressionCount = 1; - while (current.type === AST_NODE_TYPES.LogicalExpression) { - if ( - !isValidChainTarget( - current.right, - // only allow identifiers for the first chain - foo && foo() - expressionCount === 1, - ) - ) { - break; - } - const { rightText, shouldBreak } = breakIfInvalid({ - rightNode: current.right, - previousLeftText, - }); - if (shouldBreak) { - break; - } - - let invalidOptionallyChainedPrivateProperty; - ({ - invalidOptionallyChainedPrivateProperty, - expressionCount, - previousLeftText, - optionallyChainedCode, - previous, - current, - } = normalizeRepeatingPatterns( - rightText, - expressionCount, - previousLeftText, - optionallyChainedCode, - previous, - current, - )); - if (invalidOptionallyChainedPrivateProperty) { - return; - } - } - - reportIfMoreThanOne({ - expressionCount, - previous, - optionallyChainedCode, - sourceCode, - context, - shouldHandleChainedAnds: true, - }); + analyzeChain(context, node.operator, currentChain); }, }; - - interface BreakIfInvalidResult { - leftText: string; - rightText: string; - shouldBreak: boolean; - } - - interface BreakIfInvalidOptions { - previousLeftText: string; - rightNode: ValidChainTarget; - } - - function breakIfInvalid({ - previousLeftText, - rightNode, - }: BreakIfInvalidOptions): BreakIfInvalidResult { - let shouldBreak = false; - - const rightText = getText(rightNode); - // can't just use startsWith because of cases like foo && fooBar.baz; - const matchRegex = new RegExp( - `^${ - // escape regex characters - previousLeftText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - }[^a-zA-Z0-9_$]`, - ); - if ( - !matchRegex.test(rightText) && - // handle redundant cases like foo.bar && foo.bar - previousLeftText !== rightText - ) { - shouldBreak = true; - } - return { shouldBreak, leftText: previousLeftText, rightText }; - } - - function getText(node: ValidChainTarget): string { - if (node.type === AST_NODE_TYPES.BinaryExpression) { - return getText( - // isValidChainTarget ensures this is type safe - node.left as ValidChainTarget, - ); - } - - if (node.type === AST_NODE_TYPES.CallExpression) { - const calleeText = getText( - // isValidChainTarget ensures this is type safe - node.callee as ValidChainTarget, - ); - - // ensure that the call arguments are left untouched, or else we can break cases that _need_ whitespace: - // - JSX: - // - Unary Operators: typeof foo, await bar, delete baz - const closingParenToken = util.nullThrows( - sourceCode.getLastToken(node), - util.NullThrowsReasons.MissingToken('closing parenthesis', node.type), - ); - const openingParenToken = util.nullThrows( - sourceCode.getFirstTokenBetween( - node.callee, - closingParenToken, - util.isOpeningParenToken, - ), - util.NullThrowsReasons.MissingToken('opening parenthesis', node.type), - ); - - const argumentsText = sourceCode.text.substring( - openingParenToken.range[0], - closingParenToken.range[1], - ); - - return `${calleeText}${argumentsText}`; - } - - if ( - node.type === AST_NODE_TYPES.Identifier || - node.type === AST_NODE_TYPES.PrivateIdentifier - ) { - return node.name; - } - - if (node.type === AST_NODE_TYPES.MetaProperty) { - return `${node.meta.name}.${node.property.name}`; - } - - if (node.type === AST_NODE_TYPES.ThisExpression) { - return 'this'; - } - - if (node.type === AST_NODE_TYPES.ChainExpression) { - /* istanbul ignore if */ if ( - node.expression.type === AST_NODE_TYPES.TSNonNullExpression - ) { - // this shouldn't happen - return ''; - } - return getText(node.expression); - } - - if (node.object.type === AST_NODE_TYPES.TSNonNullExpression) { - // Not supported mixing with TSNonNullExpression - return ''; - } - - return getMemberExpressionText(node); - } - - /** - * Gets a normalized representation of the given MemberExpression - */ - function getMemberExpressionText(node: TSESTree.MemberExpression): string { - let objectText: string; - - // cases should match the list in ALLOWED_MEMBER_OBJECT_TYPES - switch (node.object.type) { - case AST_NODE_TYPES.MemberExpression: - objectText = getMemberExpressionText(node.object); - break; - - case AST_NODE_TYPES.CallExpression: - case AST_NODE_TYPES.Identifier: - case AST_NODE_TYPES.MetaProperty: - case AST_NODE_TYPES.ThisExpression: - objectText = getText(node.object); - break; - - /* istanbul ignore next */ - default: - return ''; - } - - let propertyText: string; - if (node.computed) { - // cases should match the list in ALLOWED_COMPUTED_PROP_TYPES - switch (node.property.type) { - case AST_NODE_TYPES.Identifier: - propertyText = getText(node.property); - break; - - case AST_NODE_TYPES.Literal: - case AST_NODE_TYPES.TemplateLiteral: - case AST_NODE_TYPES.BinaryExpression: - propertyText = sourceCode.getText(node.property); - break; - - case AST_NODE_TYPES.MemberExpression: - propertyText = getMemberExpressionText(node.property); - break; - - /* istanbul ignore next */ - default: - return ''; - } - - return `${objectText}${node.optional ? '?.' : ''}[${propertyText}]`; - } else { - // cases should match the list in ALLOWED_NON_COMPUTED_PROP_TYPES - switch (node.property.type) { - case AST_NODE_TYPES.Identifier: - propertyText = getText(node.property); - break; - case AST_NODE_TYPES.PrivateIdentifier: - propertyText = '#' + getText(node.property); - break; - - default: - propertyText = sourceCode.getText(node.property); - } - - return `${objectText}${node.optional ? '?.' : '.'}${propertyText}`; - } - } }, }); - -const ALLOWED_MEMBER_OBJECT_TYPES: ReadonlySet = new Set([ - AST_NODE_TYPES.CallExpression, - AST_NODE_TYPES.Identifier, - AST_NODE_TYPES.MemberExpression, - AST_NODE_TYPES.ThisExpression, - AST_NODE_TYPES.MetaProperty, -]); -const ALLOWED_COMPUTED_PROP_TYPES: ReadonlySet = new Set([ - AST_NODE_TYPES.Identifier, - AST_NODE_TYPES.Literal, - AST_NODE_TYPES.MemberExpression, - AST_NODE_TYPES.TemplateLiteral, -]); -const ALLOWED_NON_COMPUTED_PROP_TYPES: ReadonlySet = new Set([ - AST_NODE_TYPES.Identifier, - AST_NODE_TYPES.PrivateIdentifier, -]); - -interface ReportIfMoreThanOneOptions { - expressionCount: number; - previous: TSESTree.LogicalExpression; - optionallyChainedCode: string; - sourceCode: Readonly; - context: Readonly< - TSESLint.RuleContext< - 'preferOptionalChain' | 'optionalChainSuggest', - never[] - > - >; - shouldHandleChainedAnds: boolean; -} - -function reportIfMoreThanOne({ - expressionCount, - previous, - optionallyChainedCode, - sourceCode, - context, - shouldHandleChainedAnds, -}: ReportIfMoreThanOneOptions): void { - if (expressionCount > 1) { - if ( - shouldHandleChainedAnds && - previous.right.type === AST_NODE_TYPES.BinaryExpression - ) { - let operator = previous.right.operator; - if ( - previous.right.operator === '!==' && - // TODO(#4820): Use the type checker to know whether this is `null` - previous.right.right.type === AST_NODE_TYPES.Literal && - previous.right.right.raw === 'null' - ) { - // case like foo !== null && foo.bar !== null - operator = '!='; - } - // case like foo && foo.bar !== someValue - optionallyChainedCode += ` ${operator} ${sourceCode.getText( - previous.right.right, - )}`; - } - - context.report({ - node: previous, - messageId: 'preferOptionalChain', - suggest: [ - { - messageId: 'optionalChainSuggest', - fix: (fixer): TSESLint.RuleFix[] => [ - fixer.replaceText( - previous, - `${shouldHandleChainedAnds ? '' : '!'}${optionallyChainedCode}`, - ), - ], - }, - ], - }); - } -} - -interface NormalizedPattern { - invalidOptionallyChainedPrivateProperty: boolean; - expressionCount: number; - previousLeftText: string; - optionallyChainedCode: string; - previous: TSESTree.LogicalExpression; - current: TSESTree.Node; -} - -function normalizeRepeatingPatterns( - rightText: string, - expressionCount: number, - previousLeftText: string, - optionallyChainedCode: string, - previous: TSESTree.Node, - current: TSESTree.Node, -): NormalizedPattern { - const leftText = previousLeftText; - let invalidOptionallyChainedPrivateProperty = false; - // omit weird doubled up expression that make no sense like foo.bar && foo.bar - if (rightText !== previousLeftText) { - expressionCount += 1; - previousLeftText = rightText; - - /* - Diff the left and right text to construct the fix string - There are the following cases: - - 1) - rightText === 'foo.bar.baz.buzz' - leftText === 'foo.bar.baz' - diff === '.buzz' - - 2) - rightText === 'foo.bar.baz.buzz()' - leftText === 'foo.bar.baz' - diff === '.buzz()' - - 3) - rightText === 'foo.bar.baz.buzz()' - leftText === 'foo.bar.baz.buzz' - diff === '()' - - 4) - rightText === 'foo.bar.baz[buzz]' - leftText === 'foo.bar.baz' - diff === '[buzz]' - - 5) - rightText === 'foo.bar.baz?.buzz' - leftText === 'foo.bar.baz' - diff === '?.buzz' - */ - const diff = rightText.replace(leftText, ''); - if (diff.startsWith('.#')) { - // Do not handle direct optional chaining on private properties because of a typescript bug (https://github.com/microsoft/TypeScript/issues/42734) - // We still allow in computed properties - invalidOptionallyChainedPrivateProperty = true; - } - if (diff.startsWith('?')) { - // item was "pre optional chained" - optionallyChainedCode += diff; - } else { - const needsDot = diff.startsWith('(') || diff.startsWith('['); - optionallyChainedCode += `?${needsDot ? '.' : ''}${diff}`; - } - } - - previous = current as TSESTree.LogicalExpression; - current = util.nullThrows( - current.parent, - util.NullThrowsReasons.MissingParent, - ); - return { - invalidOptionallyChainedPrivateProperty, - expressionCount, - previousLeftText, - optionallyChainedCode, - previous, - current, - }; -} - -function isValidChainTarget( - node: TSESTree.Node, - allowIdentifier: boolean, -): node is ValidChainTarget { - if (node.type === AST_NODE_TYPES.ChainExpression) { - return isValidChainTarget(node.expression, allowIdentifier); - } - - if (node.type === AST_NODE_TYPES.MemberExpression) { - const isObjectValid = - ALLOWED_MEMBER_OBJECT_TYPES.has(node.object.type) && - // make sure to validate the expression is of our expected structure - isValidChainTarget(node.object, true); - const isPropertyValid = node.computed - ? ALLOWED_COMPUTED_PROP_TYPES.has(node.property.type) && - // make sure to validate the member expression is of our expected structure - (node.property.type === AST_NODE_TYPES.MemberExpression - ? isValidChainTarget(node.property, allowIdentifier) - : true) - : ALLOWED_NON_COMPUTED_PROP_TYPES.has(node.property.type); - - return isObjectValid && isPropertyValid; - } - - if (node.type === AST_NODE_TYPES.CallExpression) { - return isValidChainTarget(node.callee, allowIdentifier); - } - - if ( - allowIdentifier && - (node.type === AST_NODE_TYPES.Identifier || - node.type === AST_NODE_TYPES.ThisExpression || - node.type === AST_NODE_TYPES.MetaProperty) - ) { - return true; - } - - /* - special case for the following, where we only want the left - - foo !== null - - foo != null - - foo !== undefined - - foo != undefined - */ - return ( - node.type === AST_NODE_TYPES.BinaryExpression && - ['!==', '!='].includes(node.operator) && - isValidChainTarget(node.left, allowIdentifier) && - (util.isUndefinedIdentifier(node.right) || util.isNullLiteral(node.right)) - ); -} From a519c005e225374f71a48bf1fd855b5f7848f783 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sun, 5 Feb 2023 16:56:52 +1030 Subject: [PATCH 04/26] WIP --- .../BinaryExpression/BinaryOperatorToText.ts | 1 - .../tests/BinaryOperatorToText.type-test.ts | 1 + .../src/rules/prefer-optional-chain.ts | 543 +++++++++++++++++- 3 files changed, 514 insertions(+), 31 deletions(-) diff --git a/packages/ast-spec/src/expression/BinaryExpression/BinaryOperatorToText.ts b/packages/ast-spec/src/expression/BinaryExpression/BinaryOperatorToText.ts index 38ed97401513..b19373f54d22 100644 --- a/packages/ast-spec/src/expression/BinaryExpression/BinaryOperatorToText.ts +++ b/packages/ast-spec/src/expression/BinaryExpression/BinaryOperatorToText.ts @@ -2,7 +2,6 @@ import type { SyntaxKind } from 'typescript'; // the members of ts.BinaryOperator export interface BinaryOperatorToText { - [SyntaxKind.QuestionQuestionToken]: '??'; [SyntaxKind.InstanceOfKeyword]: 'instanceof'; [SyntaxKind.InKeyword]: 'in'; diff --git a/packages/ast-spec/tests/BinaryOperatorToText.type-test.ts b/packages/ast-spec/tests/BinaryOperatorToText.type-test.ts index 22893732e585..95f993b9e4df 100644 --- a/packages/ast-spec/tests/BinaryOperatorToText.type-test.ts +++ b/packages/ast-spec/tests/BinaryOperatorToText.type-test.ts @@ -10,6 +10,7 @@ type BinaryOperatorWithoutInvalidTypes = Exclude< BinaryOperator, | AssignmentOperator // --> AssignmentExpression | SyntaxKind.CommaToken // -> SequenceExpression + | SyntaxKind.QuestionQuestionToken // -> LogicalExpression >; type _Test = { readonly [T in BinaryOperatorWithoutInvalidTypes]: BinaryOperatorToText[T]; diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 1c662d1add7c..d687658a1e63 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -29,14 +29,20 @@ const enum NullishComparisonType { /** `!x` */ Boolean, } -type ComparisonNode = TSESTree.Expression | TSESTree.PrivateIdentifier; +type ValueComparisonNode = + | TSESTree.Expression + | TSESTree.PrivateIdentifier + | TSESTree.SpreadElement; +type TypeComparisonNode = + | TSESTree.TSTypeParameterInstantiation + | TSESTree.TypeNode; const enum OperandValidity { Valid, Invalid, } interface ValidOperand { type: OperandValidity.Valid; - comparedName: ComparisonNode; + comparedName: ValueComparisonNode; comparisonType: NullishComparisonType; node: TSESTree.Expression; } @@ -262,6 +268,33 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { } } +function compareArray( + compare: (a: TNode, b: TNode) => NodeComparisonResult, +): >( + arrayA: TArr, + arrayB: TArr, +) => NodeComparisonResult.Equal | NodeComparisonResult.Invalid { + return (arrayA, arrayB) => { + if (arrayA.length !== arrayB.length) { + return NodeComparisonResult.Invalid; + } + + const result = arrayA.every((elA, idx) => { + const elB = arrayB[idx]; + if (elA == null || elB == null) { + return elA === elB; + } + return compare(elA, elB); + }); + if (result) { + return NodeComparisonResult.Equal; + } + return NodeComparisonResult.Invalid; + }; +} +const compareNodeArray = compareArray(compareNodes); +const compareTypeNodeArray = compareArray(compareTypes); + const enum NodeComparisonResult { /** the two nodes are comparably the same */ Equal, @@ -270,25 +303,481 @@ const enum NodeComparisonResult { /** the left node is not the same or is a superset of the right node */ Invalid, } +function compareTypes( + nodeA: TypeComparisonNode | null | undefined, + nodeB: TypeComparisonNode | null | undefined, +): NodeComparisonResult { + if (nodeA == null || nodeB == null) { + if (nodeA !== nodeB) { + return NodeComparisonResult.Invalid; + } + return NodeComparisonResult.Equal; + } + + switch (nodeA.type) { + case AST_NODE_TYPES.TSAbstractKeyword: + case AST_NODE_TYPES.TSAnyKeyword: + case AST_NODE_TYPES.TSAsyncKeyword: + case AST_NODE_TYPES.TSBigIntKeyword: + case AST_NODE_TYPES.TSBooleanKeyword: + case AST_NODE_TYPES.TSDeclareKeyword: + case AST_NODE_TYPES.TSExportKeyword: + case AST_NODE_TYPES.TSIntrinsicKeyword: + case AST_NODE_TYPES.TSNeverKeyword: + case AST_NODE_TYPES.TSNullKeyword: + case AST_NODE_TYPES.TSNumberKeyword: + case AST_NODE_TYPES.TSObjectKeyword: + case AST_NODE_TYPES.TSPrivateKeyword: + case AST_NODE_TYPES.TSProtectedKeyword: + case AST_NODE_TYPES.TSPublicKeyword: + case AST_NODE_TYPES.TSReadonlyKeyword: + case AST_NODE_TYPES.TSStaticKeyword: + case AST_NODE_TYPES.TSStringKeyword: + case AST_NODE_TYPES.TSSymbolKeyword: + case AST_NODE_TYPES.TSUndefinedKeyword: + case AST_NODE_TYPES.TSUnknownKeyword: + case AST_NODE_TYPES.TSVoidKeyword: + return NodeComparisonResult.Equal; + + case AST_NODE_TYPES.TSArrayType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSConditionalType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSConstructorType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSFunctionType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSImportType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSIndexedAccessType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSInferType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSIntersectionType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSLiteralType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSMappedType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSNamedTupleMember: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSOptionalType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSQualifiedName: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSRestType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSTemplateLiteralType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSThisType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSTupleType: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSTypeLiteral: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSTypeOperator: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSTypeParameterInstantiation: + return compareTypeNodeArray(nodeA.params, (nodeB as typeof nodeA).params); + + case AST_NODE_TYPES.TSTypePredicate: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSTypeQuery: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSTypeReference: + // TODO + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSUnionType: + // TODO + return NodeComparisonResult.Invalid; + + default: + nodeA satisfies never; + return NodeComparisonResult.Invalid; + } +} function compareNodes( - _nodeA: ComparisonNode, - _nodeB: ComparisonNode, + nodeA: ValueComparisonNode | null | undefined, + nodeB: ValueComparisonNode | null | undefined, ): NodeComparisonResult { - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - // TODO - recursively compare two nodes - return; + if (nodeA == null || nodeB == null) { + if (nodeA !== nodeB) { + return NodeComparisonResult.Invalid; + } + return NodeComparisonResult.Equal; + } + + if (nodeA.type !== nodeB.type) { + // special case where nodes are allowed to be non-equal + // a?.b && a.b.c + // ^^^^ ChainExpression, MemberExpression + // ^^^^^ MemberExpression + // Can be fixed to + // a?.b?.c + if (nodeA.type === AST_NODE_TYPES.ChainExpression) { + return compareNodes(nodeA.expression, nodeB); + } + if (nodeB.type === AST_NODE_TYPES.ChainExpression) { + return compareNodes(nodeA, nodeB.expression); + } + + // special case for optional member expressions + // a && a.b + // ^ Identifier + // ^^^ MemberExpression + if ( + nodeA.type === AST_NODE_TYPES.Identifier && + nodeB.type === AST_NODE_TYPES.MemberExpression + ) { + if (compareNodes(nodeA, nodeB.object) !== NodeComparisonResult.Invalid) { + return NodeComparisonResult.Subset; + } + return NodeComparisonResult.Invalid; + } + + // special case for optional call expressions + // a.b && a.b() + // ^^^ MemberExpression + // ^^^^^ CallExpression + // Can be fixed to + // a.b?.() + if ( + (nodeA.type === AST_NODE_TYPES.MemberExpression || + nodeA.type === AST_NODE_TYPES.Identifier) && + nodeB.type === AST_NODE_TYPES.CallExpression + ) { + if (compareNodes(nodeA, nodeB.callee) !== NodeComparisonResult.Invalid) { + return NodeComparisonResult.Subset; + } + return NodeComparisonResult.Invalid; + } + + // special case for chaining off of a meta property + if (nodeA.type === AST_NODE_TYPES.MetaProperty) { + switch (nodeB.type) { + // import.meta && import.meta.foo + case AST_NODE_TYPES.MemberExpression: { + const objectCompare = compareNodes(nodeA, nodeB.object); + if (objectCompare !== NodeComparisonResult.Invalid) { + return NodeComparisonResult.Subset; + } + return NodeComparisonResult.Invalid; + } + + // import.meta && import.meta.bar() + case AST_NODE_TYPES.CallExpression: { + const calleeCompare = compareNodes(nodeA, nodeB.callee); + if (calleeCompare !== NodeComparisonResult.Invalid) { + return NodeComparisonResult.Subset; + } + return NodeComparisonResult.Invalid; + } + + default: + return NodeComparisonResult.Invalid; + } + } + + return NodeComparisonResult.Invalid; + } + + switch (nodeA.type) { + // these expressions create a new instance each time - so it makes no sense to compare the chain + case AST_NODE_TYPES.ArrayExpression: + case AST_NODE_TYPES.ArrowFunctionExpression: + case AST_NODE_TYPES.ClassExpression: + case AST_NODE_TYPES.FunctionExpression: + case AST_NODE_TYPES.JSXElement: + case AST_NODE_TYPES.JSXFragment: + case AST_NODE_TYPES.NewExpression: + case AST_NODE_TYPES.ObjectExpression: + return NodeComparisonResult.Invalid; + + // chaining from assignments could change the value irrevocably - so it makes no sense to compare the chain + case AST_NODE_TYPES.AssignmentExpression: + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.AwaitExpression: + return compareNodes(nodeA.argument, (nodeB as typeof nodeA).argument); + + // binary expressions always return either boolean or number - so it makes no sense to compare the chain + case AST_NODE_TYPES.BinaryExpression: + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.CallExpression: { + const nodeBCall = nodeB as typeof nodeA; + + // check for cases like + // foo() && foo()(bar) + // ^^^^^ nodeA + // ^^^^^^^^^^ nodeB + // we don't want to check the arguments in this case + const aSubsetOfB = compareNodes(nodeA, nodeBCall.callee); + if (aSubsetOfB !== NodeComparisonResult.Invalid) { + return aSubsetOfB; + } + + const calleeCompare = compareNodes(nodeA.callee, nodeBCall.callee); + if (calleeCompare !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + + // NOTE - we purposely ignore optional flag because for our purposes + // foo?.bar() && foo.bar?.()?.baz + // or + // foo.bar() && foo?.bar?.()?.baz + // are going to be exactly the same + + const argumentCompare = compareNodeArray( + nodeA.arguments, + nodeBCall.arguments, + ); + if (argumentCompare !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + + const typeParamCompare = compareTypes( + nodeA.typeParameters, + nodeBCall.typeParameters, + ); + if (typeParamCompare === NodeComparisonResult.Equal) { + return NodeComparisonResult.Equal; + } + + return NodeComparisonResult.Invalid; + } + + case AST_NODE_TYPES.ChainExpression: + return compareNodes(nodeA.expression, (nodeB as typeof nodeA).expression); + + // TODO - we could handle this, but it's pretty nonsensical and needlessly complicated to do something like + // (x ? y : z) && (x ? y : z).prop + // Let's put a pin in this unless someone asks for it + case AST_NODE_TYPES.ConditionalExpression: + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.Identifier: + case AST_NODE_TYPES.PrivateIdentifier: + if (nodeA.name === (nodeB as typeof nodeA).name) { + return NodeComparisonResult.Equal; + } + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.ImportExpression: { + const nodeBImport = nodeB as typeof nodeA; + const sourceCompare = compareNodes(nodeA.source, nodeBImport.source); + if (sourceCompare !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + + if (nodeA.attributes == null || nodeBImport.attributes == null) { + if (nodeA.attributes !== nodeBImport.attributes) { + return NodeComparisonResult.Invalid; + } + return NodeComparisonResult.Equal; + } + + if ( + compareNodes(nodeA.attributes, nodeBImport.attributes) === + NodeComparisonResult.Equal + ) { + return NodeComparisonResult.Equal; + } + + return NodeComparisonResult.Invalid; + } + + case AST_NODE_TYPES.Literal: { + const nodeBLiteral = nodeB as typeof nodeA; + if ( + nodeA.raw === nodeBLiteral.raw && + nodeA.value === nodeBLiteral.value + ) { + return NodeComparisonResult.Equal; + } + return NodeComparisonResult.Invalid; + } + + // TODO - we could handle this, but it's pretty nonsensical and needlessly complicated to do something like + // (x || y) && (x || y).z + // Let's put a pin in this unless someone asks for it + case AST_NODE_TYPES.LogicalExpression: + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.MemberExpression: { + const nodeBMember = nodeB as typeof nodeA; + if (nodeA.computed !== nodeBMember.computed) { + return NodeComparisonResult.Invalid; + } + + // check for cases like + // foo.bar && foo.bar.baz + // ^^^^^^^ nodeA + // ^^^^^^^^^^^ nodeB + // we don't want to check the property in this case + const aSubsetOfB = compareNodes(nodeA, nodeBMember.object); + if (aSubsetOfB !== NodeComparisonResult.Invalid) { + return aSubsetOfB; + } + + // NOTE - we purposely ignore optional flag because for our purposes + // foo?.bar && foo.bar?.baz + // or + // foo.bar && foo?.bar?.baz + // are going to be exactly the same + + const objectCompare = compareNodes(nodeA.object, nodeBMember.object); + if (objectCompare !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + + return compareNodes(nodeA.property, nodeBMember.property); + } + + case AST_NODE_TYPES.MetaProperty: { + const nodeBMeta = nodeB as typeof nodeA; + const metaCompare = compareNodes(nodeA.meta, nodeBMeta.meta); + if (metaCompare !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + const propCompare = compareNodes(nodeA.property, nodeBMeta.property); + if (propCompare === NodeComparisonResult.Equal) { + return NodeComparisonResult.Equal; + } + return NodeComparisonResult.Invalid; + } + + case AST_NODE_TYPES.SequenceExpression: + return compareNodeArray( + nodeA.expressions, + (nodeB as typeof nodeA).expressions, + ); + + case AST_NODE_TYPES.SpreadElement: + return compareNodes(nodeA.argument, (nodeB as typeof nodeA).argument); + + // constant expressions are TIGHT + case AST_NODE_TYPES.Super: + case AST_NODE_TYPES.ThisExpression: + return NodeComparisonResult.Equal; + + // TODO - we could handle this, but it's pretty nonsensical and needlessly complicated to do something like + // tag`foo${bar}` && tag`foo${bar}`.prop + // Additionally the template tag might return a new value each time. + // Let's put a pin in this unless someone asks for it. + case AST_NODE_TYPES.TaggedTemplateExpression: + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TemplateLiteral: { + const nodeBTemplate = nodeB as typeof nodeA; + const areQuasisEqual = + nodeA.quasis.length === nodeBTemplate.quasis.length && + nodeA.quasis.every((elA, idx) => { + const elB = nodeBTemplate.quasis[idx]; + return elA === elB; + }); + if (!areQuasisEqual) { + return NodeComparisonResult.Invalid; + } + + return compareNodeArray(nodeA.expressions, nodeBTemplate.expressions); + } + + case AST_NODE_TYPES.TSAsExpression: + case AST_NODE_TYPES.TSSatisfiesExpression: + case AST_NODE_TYPES.TSTypeAssertion: { + const nodeBAssertion = nodeB as typeof nodeA; + const expressionCompare = compareNodes( + nodeA.expression, + nodeBAssertion.expression, + ); + if (expressionCompare === NodeComparisonResult.Invalid) { + return NodeComparisonResult.Invalid; + } + + const typeCompare = compareTypes( + nodeA.typeAnnotation, + nodeBAssertion.typeAnnotation, + ); + if (typeCompare === NodeComparisonResult.Equal) { + return expressionCompare; + } + + return NodeComparisonResult.Invalid; + } + + // it's syntactically invalid for an instantiation expression to be part of a chain + case AST_NODE_TYPES.TSInstantiationExpression: + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.TSNonNullExpression: + return compareNodes(nodeA.expression, (nodeB as typeof nodeA).expression); + + // unary expressions alway return one of number, boolean, string or undefined - so it makes no sense to compare the chain + case AST_NODE_TYPES.UnaryExpression: + return NodeComparisonResult.Invalid; + + // update expression returns a number and also changes the value each time - so it makes no sense to compare the chain + case AST_NODE_TYPES.UpdateExpression: + return NodeComparisonResult.Invalid; + + // yield returns the value passed to the `next` function, so it may not be the same each time - so it makes no sense to compare the chain + case AST_NODE_TYPES.YieldExpression: + return NodeComparisonResult.Invalid; + + // these aren't actually valid expressions. + // https://github.com/typescript-eslint/typescript-eslint/blob/20d7caee35ab84ae6381fdf04338c9e2b9e2bc48/packages/ast-spec/src/unions/Expression.ts#L37-L43 + case AST_NODE_TYPES.ArrayPattern: + case AST_NODE_TYPES.ObjectPattern: + return NodeComparisonResult.Invalid; + + default: + nodeA satisfies never; + return NodeComparisonResult.Invalid; + } } type OperandAnalyzer = ( @@ -297,8 +786,6 @@ type OperandAnalyzer = ( chain: readonly ValidOperand[], ) => readonly [ValidOperand, ...ValidOperand[]] | null; const analyzeAndChainOperand: OperandAnalyzer = (operand, index, chain) => { - // TODO - "!= null"-like chains - // TODO - "x && x.y" chains switch (operand.comparisonType) { case NullishComparisonType.NotBoolean: case NullishComparisonType.NotEqNullOrUndefined: @@ -321,7 +808,7 @@ const analyzeAndChainOperand: OperandAnalyzer = (operand, index, chain) => { } case NullishComparisonType.NotEqUndefined: { - // TODO - use types here to determine if the value is just `undefined` (eg `=== undefined` would be enough on its own) + // TODO(#4820) - use types here to determine if the value is just `undefined` (eg `=== undefined` would be enough on its own) // handle `x === undefined || x === null` const nextOperand = chain[index + 1] as ValidOperand | undefined; @@ -341,8 +828,6 @@ const analyzeAndChainOperand: OperandAnalyzer = (operand, index, chain) => { } }; const analyzeOrChainOperand: OperandAnalyzer = (operand, index, chain) => { - // TODO - "== null"-like chains - // TODO - "!x || !x.y" chains switch (operand.comparisonType) { case NullishComparisonType.Boolean: case NullishComparisonType.EqNullOrUndefined: @@ -365,7 +850,7 @@ const analyzeOrChainOperand: OperandAnalyzer = (operand, index, chain) => { } case NullishComparisonType.EqUndefined: { - // TODO - use types here to determine if the value is just `undefined` (eg `=== undefined` would be enough on its own) + // TODO(#4820) - use types here to determine if the value is just `undefined` (eg `=== undefined` would be enough on its own) // handle `x === undefined || x === null` const nextOperand = chain[index + 1] as ValidOperand | undefined; @@ -550,11 +1035,9 @@ export default util.createRule({ }); }, - LogicalExpression(node): void { - if (node.operator === '??') { - return; - } - + 'LogicalExpression[operator!="??"]'( + node: TSESTree.LogicalExpression, + ): void { if (seenLogicals.has(node)) { return; } From a354d0aa3f328c0794595584bca47f940e4ee14c Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 7 Feb 2023 11:07:08 +1030 Subject: [PATCH 05/26] temp --- .../eslint-plugin/src/rules/prefer-optional-chain.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index d687658a1e63..cb16bb942de5 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -1,3 +1,4 @@ +import { visitorKeys } from '@typescript-eslint/visitor-keys'; import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import { isBinaryExpression } from 'tsutils'; @@ -451,7 +452,8 @@ function compareNodes( } if (nodeA.type !== nodeB.type) { - // special case where nodes are allowed to be non-equal + // special cases where nodes are allowed to be non-equal + // a?.b && a.b.c // ^^^^ ChainExpression, MemberExpression // ^^^^^ MemberExpression @@ -524,6 +526,13 @@ function compareNodes( return NodeComparisonResult.Invalid; } + // switch (nodeA.type) { + // default: { + // const currentVisitorKeys = + // } + + // } + switch (nodeA.type) { // these expressions create a new instance each time - so it makes no sense to compare the chain case AST_NODE_TYPES.ArrayExpression: From b6fcab36588bcde7bc2fd239796e90ac84587c54 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 7 Feb 2023 18:04:00 +1030 Subject: [PATCH 06/26] tests are passing without fixers --- packages/eslint-plugin/package.json | 1 + .../src/rules/prefer-optional-chain.ts | 714 ++-- .../rules/prefer-optional-chain/base-cases.ts | 20 - .../prefer-optional-chain.test.ts | 2872 ++++++++++------- packages/typescript-estree/src/convert.ts | 9 +- packages/typescript-estree/src/node-utils.ts | 152 +- .../typescript-estree/src/simple-traverse.ts | 11 +- packages/visitor-keys/src/visitor-keys.ts | 26 +- 8 files changed, 2103 insertions(+), 1702 deletions(-) diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 0a4fa810a7e4..e0d5f4733c7f 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -47,6 +47,7 @@ "@typescript-eslint/scope-manager": "5.51.0", "@typescript-eslint/type-utils": "5.51.0", "@typescript-eslint/utils": "5.51.0", + "@typescript-eslint/visitor-keys": "5.51.0", "debug": "^4.3.4", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index cb16bb942de5..0446e92a2712 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -1,6 +1,6 @@ -import { visitorKeys } from '@typescript-eslint/visitor-keys'; import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import { visitorKeys } from '@typescript-eslint/visitor-keys'; import { isBinaryExpression } from 'tsutils'; import * as ts from 'typescript'; @@ -9,41 +9,48 @@ import * as util from '../util'; type TMessageIds = 'preferOptionalChain' | 'optionalChainSuggest'; type TOptions = []; +const enum ComparisonValueType { + Null = 'Null', // eslint-disable-line @typescript-eslint/internal/prefer-ast-types-enum + Undefined = 'Undefined', + UndefinedStringLiteral = 'UndefinedStringLiteral', +} +const enum OperandValidity { + Valid = 'Valid', + Invalid = 'Invalid', +} +const enum NodeComparisonResult { + /** the two nodes are comparably the same */ + Equal = 'Equal', + /** the left node is a subset of the right node */ + Subset = 'Subset', + /** the left node is not the same or is a superset of the right node */ + Invalid = 'Invalid', +} const enum NullishComparisonType { /** `x != null`, `x != undefined` */ - NotEqNullOrUndefined, + NotEqualNullOrUndefined = 'NotEqualNullOrUndefined', /** `x == null`, `x == undefined` */ - EqNullOrUndefined, + EqualNullOrUndefined = 'EqualNullOrUndefined', /** `x !== null` */ - NotEqNull, + NotStrictEqualNull = 'NotStrictEqualNull', /** `x === null` */ - EqNull, + StrictEqualNull = 'StrictEqualNull', /** `x !== undefined`, `typeof x !== 'undefined'` */ - NotEqUndefined, + NotStrictEqualUndefined = 'NotStrictEqualUndefined', /** `x === undefined`, `typeof x === 'undefined'` */ - EqUndefined, + StrictEqualUndefined = 'StrictEqualUndefined', - /** `x` */ - NotBoolean, /** `!x` */ - Boolean, -} -type ValueComparisonNode = - | TSESTree.Expression - | TSESTree.PrivateIdentifier - | TSESTree.SpreadElement; -type TypeComparisonNode = - | TSESTree.TSTypeParameterInstantiation - | TSESTree.TypeNode; -const enum OperandValidity { - Valid, - Invalid, + NotBoolean = 'NotBoolean', + /** `x` */ + Boolean = 'Boolean', // eslint-disable-line @typescript-eslint/internal/prefer-ast-types-enum } + interface ValidOperand { type: OperandValidity.Valid; - comparedName: ValueComparisonNode; + comparedName: TSESTree.Node; comparisonType: NullishComparisonType; node: TSESTree.Expression; } @@ -51,16 +58,11 @@ interface InvalidOperand { type: OperandValidity.Invalid; } type Operand = ValidOperand | InvalidOperand; + function gatherLogicalOperands(node: TSESTree.LogicalExpression): { operands: Operand[]; seenLogicals: Set; } { - const enum ComparisonValueType { - Null, - Undefined, - UndefinedStringLiteral, - } - const result: Operand[] = []; const { operands, seenLogicals } = flattenLogicalOperands(node); @@ -94,8 +96,8 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { type: OperandValidity.Valid, comparedName: comparedExpression.argument, comparisonType: node.operator.startsWith('!') - ? NullishComparisonType.NotEqUndefined - : NullishComparisonType.EqUndefined, + ? NullishComparisonType.NotStrictEqualUndefined + : NullishComparisonType.StrictEqualUndefined, node: operand, }); continue; @@ -118,8 +120,8 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { type: OperandValidity.Valid, comparedName: comparedExpression, comparisonType: operand.operator.startsWith('!') - ? NullishComparisonType.NotEqNullOrUndefined - : NullishComparisonType.EqNullOrUndefined, + ? NullishComparisonType.NotEqualNullOrUndefined + : NullishComparisonType.EqualNullOrUndefined, node: operand, }); continue; @@ -137,8 +139,8 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { type: OperandValidity.Valid, comparedName, comparisonType: operand.operator.startsWith('!') - ? NullishComparisonType.NotEqNull - : NullishComparisonType.EqNull, + ? NullishComparisonType.NotStrictEqualNull + : NullishComparisonType.StrictEqualNull, node: operand, }); continue; @@ -148,8 +150,8 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { type: OperandValidity.Valid, comparedName, comparisonType: operand.operator.startsWith('!') - ? NullishComparisonType.NotEqUndefined - : NullishComparisonType.EqUndefined, + ? NullishComparisonType.NotStrictEqualUndefined + : NullishComparisonType.StrictEqualUndefined, node: operand, }); continue; @@ -175,11 +177,17 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { comparisonType: NullishComparisonType.NotBoolean, node: operand, }); + continue; } result.push({ type: OperandValidity.Invalid }); continue; - case AST_NODE_TYPES.Identifier: + case AST_NODE_TYPES.LogicalExpression: + // explicitly ignore the mixed logical expression cases + result.push({ type: OperandValidity.Invalid }); + continue; + + default: // TODO(#4820) - use types here to determine if there is a boolean type that might be refined out result.push({ type: OperandValidity.Valid, @@ -215,7 +223,7 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { the operands in order. Note that this function purposely does not inspect mixed logical expressions - like `foo || foo.bar && foo.bar.baz` - it will ignore + like `foo || foo.bar && foo.bar.baz` - separate selector */ function flattenLogicalOperands(node: TSESTree.LogicalExpression): { operands: TSESTree.Expression[]; @@ -250,6 +258,7 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { ): ComparisonValueType | null { switch (node.type) { case AST_NODE_TYPES.Literal: + // eslint-disable-next-line eqeqeq -- intentional exact comparison against null if (node.value === null && node.raw === 'null') { return ComparisonValueType.Null; } @@ -269,254 +278,188 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { } } -function compareArray( - compare: (a: TNode, b: TNode) => NodeComparisonResult, -): >( - arrayA: TArr, - arrayB: TArr, -) => NodeComparisonResult.Equal | NodeComparisonResult.Invalid { - return (arrayA, arrayB) => { - if (arrayA.length !== arrayB.length) { - return NodeComparisonResult.Invalid; - } +function compareArrays( + arrayA: unknown[], + arrayB: unknown[], +): NodeComparisonResult.Equal | NodeComparisonResult.Invalid { + if (arrayA.length !== arrayB.length) { + return NodeComparisonResult.Invalid; + } - const result = arrayA.every((elA, idx) => { - const elB = arrayB[idx]; - if (elA == null || elB == null) { - return elA === elB; - } - return compare(elA, elB); - }); - if (result) { - return NodeComparisonResult.Equal; + const result = arrayA.every((elA, idx) => { + const elB = arrayB[idx]; + if (elA == null || elB == null) { + return elA === elB; } - return NodeComparisonResult.Invalid; - }; + return compareUnknownValues(elA, elB) === NodeComparisonResult.Equal; + }); + if (result) { + return NodeComparisonResult.Equal; + } + return NodeComparisonResult.Invalid; } -const compareNodeArray = compareArray(compareNodes); -const compareTypeNodeArray = compareArray(compareTypes); -const enum NodeComparisonResult { - /** the two nodes are comparably the same */ - Equal, - /** the left node is a subset of the right node */ - Subset, - /** the left node is not the same or is a superset of the right node */ - Invalid, +function isValidNode(x: unknown): x is TSESTree.Node { + return ( + typeof x === 'object' && + x != null && + 'type' in x && + typeof x.type === 'string' + ); } -function compareTypes( - nodeA: TypeComparisonNode | null | undefined, - nodeB: TypeComparisonNode | null | undefined, +function isValidChainExpressionToLookThrough( + node: TSESTree.Node, +): node is TSESTree.ChainExpression { + return ( + !( + node.parent?.type === AST_NODE_TYPES.MemberExpression && + node.parent.object === node + ) && + !( + node.parent?.type === AST_NODE_TYPES.CallExpression && + node.parent.callee === node + ) && + node.type === AST_NODE_TYPES.ChainExpression + ); +} +function compareUnknownValues( + valueA: unknown, + valueB: unknown, ): NodeComparisonResult { - if (nodeA == null || nodeB == null) { - if (nodeA !== nodeB) { + if (valueA == null || valueB == null) { + if (valueA !== valueB) { return NodeComparisonResult.Invalid; } return NodeComparisonResult.Equal; } - switch (nodeA.type) { - case AST_NODE_TYPES.TSAbstractKeyword: - case AST_NODE_TYPES.TSAnyKeyword: - case AST_NODE_TYPES.TSAsyncKeyword: - case AST_NODE_TYPES.TSBigIntKeyword: - case AST_NODE_TYPES.TSBooleanKeyword: - case AST_NODE_TYPES.TSDeclareKeyword: - case AST_NODE_TYPES.TSExportKeyword: - case AST_NODE_TYPES.TSIntrinsicKeyword: - case AST_NODE_TYPES.TSNeverKeyword: - case AST_NODE_TYPES.TSNullKeyword: - case AST_NODE_TYPES.TSNumberKeyword: - case AST_NODE_TYPES.TSObjectKeyword: - case AST_NODE_TYPES.TSPrivateKeyword: - case AST_NODE_TYPES.TSProtectedKeyword: - case AST_NODE_TYPES.TSPublicKeyword: - case AST_NODE_TYPES.TSReadonlyKeyword: - case AST_NODE_TYPES.TSStaticKeyword: - case AST_NODE_TYPES.TSStringKeyword: - case AST_NODE_TYPES.TSSymbolKeyword: - case AST_NODE_TYPES.TSUndefinedKeyword: - case AST_NODE_TYPES.TSUnknownKeyword: - case AST_NODE_TYPES.TSVoidKeyword: - return NodeComparisonResult.Equal; - - case AST_NODE_TYPES.TSArrayType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSConditionalType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSConstructorType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSFunctionType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSImportType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSIndexedAccessType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSInferType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSIntersectionType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSLiteralType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSMappedType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSNamedTupleMember: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSOptionalType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSQualifiedName: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSRestType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSTemplateLiteralType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSThisType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSTupleType: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSTypeLiteral: - // TODO - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.TSTypeOperator: - // TODO - return NodeComparisonResult.Invalid; + if (!isValidNode(valueA) || !isValidNode(valueB)) { + return NodeComparisonResult.Invalid; + } - case AST_NODE_TYPES.TSTypeParameterInstantiation: - return compareTypeNodeArray(nodeA.params, (nodeB as typeof nodeA).params); + return compareNodes(valueA, valueB); +} +function compareByVisiting( + nodeA: TSESTree.Node, + nodeB: TSESTree.Node, +): NodeComparisonResult.Equal | NodeComparisonResult.Invalid { + const currentVisitorKeys = visitorKeys[nodeA.type]; + if (currentVisitorKeys == null) { + // we don't know how to visit this node, so assume it's invalid to avoid false-positives / broken fixers + return NodeComparisonResult.Invalid; + } - case AST_NODE_TYPES.TSTypePredicate: - // TODO - return NodeComparisonResult.Invalid; + if (currentVisitorKeys.length === 0) { + // assume nodes with no keys are constant things like keywords + return NodeComparisonResult.Equal; + } - case AST_NODE_TYPES.TSTypeQuery: - // TODO - return NodeComparisonResult.Invalid; + for (const key of currentVisitorKeys) { + // @ts-expect-error - dynamic access but it's safe + const nodeAChildOrChildren = nodeA[key] as unknown; + // @ts-expect-error - dynamic access but it's safe + const nodeBChildOrChildren = nodeB[key] as unknown; - case AST_NODE_TYPES.TSTypeReference: - // TODO - return NodeComparisonResult.Invalid; + if (Array.isArray(nodeAChildOrChildren)) { + const arrayA = nodeAChildOrChildren as unknown[]; + const arrayB = nodeBChildOrChildren as unknown[]; - case AST_NODE_TYPES.TSUnionType: - // TODO - return NodeComparisonResult.Invalid; - - default: - nodeA satisfies never; - return NodeComparisonResult.Invalid; - } -} -function compareNodes( - nodeA: ValueComparisonNode | null | undefined, - nodeB: ValueComparisonNode | null | undefined, -): NodeComparisonResult { - if (nodeA == null || nodeB == null) { - if (nodeA !== nodeB) { - return NodeComparisonResult.Invalid; + const result = compareArrays(arrayA, arrayB); + if (!result) { + return NodeComparisonResult.Invalid; + } + // fallthrough to the next key as the key was "equal" + } else { + const result = compareUnknownValues( + nodeAChildOrChildren, + nodeBChildOrChildren, + ); + if (result !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + // fallthrough to the next key as the key was "equal" } - return NodeComparisonResult.Equal; } + return NodeComparisonResult.Equal; +} +type CompareNodesArgument = TSESTree.Node | null | undefined; +function compareNodesUncached( + nodeA: TSESTree.Node, + nodeB: TSESTree.Node, +): NodeComparisonResult { if (nodeA.type !== nodeB.type) { // special cases where nodes are allowed to be non-equal + // look through a chain expression node at the top-level because it only + // exists to delimit the end of an optional chain + // // a?.b && a.b.c // ^^^^ ChainExpression, MemberExpression // ^^^^^ MemberExpression - // Can be fixed to - // a?.b?.c - if (nodeA.type === AST_NODE_TYPES.ChainExpression) { + // + // except for in this class of cases + // (a?.b).c && a.b.c + // because the parentheses have runtime meaning (sad face) + if (isValidChainExpressionToLookThrough(nodeA)) { return compareNodes(nodeA.expression, nodeB); } - if (nodeB.type === AST_NODE_TYPES.ChainExpression) { + if (isValidChainExpressionToLookThrough(nodeB)) { return compareNodes(nodeA, nodeB.expression); } - // special case for optional member expressions - // a && a.b - // ^ Identifier - // ^^^ MemberExpression - if ( - nodeA.type === AST_NODE_TYPES.Identifier && - nodeB.type === AST_NODE_TYPES.MemberExpression - ) { - if (compareNodes(nodeA, nodeB.object) !== NodeComparisonResult.Invalid) { - return NodeComparisonResult.Subset; - } - return NodeComparisonResult.Invalid; + // look through the type-only non-null assertion because its existence could + // possibly be replaced by an optional chain instead + // + // a.b! && a.b.c + // ^^^^ TSNonNullExpression + if (nodeA.type === AST_NODE_TYPES.TSNonNullExpression) { + return compareNodes(nodeA.expression, nodeB); + } + if (nodeB.type === AST_NODE_TYPES.TSNonNullExpression) { + return compareNodes(nodeA, nodeB.expression); } - // special case for optional call expressions + // special case for subset optional chains where the node types don't match, + // but we want to try comparing by discarding the "extra" code + // + // a && a.b + // ^ compare this + // a && a() + // ^ compare this // a.b && a.b() - // ^^^ MemberExpression - // ^^^^^ CallExpression - // Can be fixed to - // a.b?.() + // ^^^ compare this + // a() && a().b + // ^^^ compare this + // import.meta && import.meta.b + // ^^^^^^^^^^^ compare this if ( - (nodeA.type === AST_NODE_TYPES.MemberExpression || - nodeA.type === AST_NODE_TYPES.Identifier) && - nodeB.type === AST_NODE_TYPES.CallExpression + nodeA.type === AST_NODE_TYPES.CallExpression || + nodeA.type === AST_NODE_TYPES.Identifier || + nodeA.type === AST_NODE_TYPES.MemberExpression || + nodeA.type === AST_NODE_TYPES.MetaProperty ) { - if (compareNodes(nodeA, nodeB.callee) !== NodeComparisonResult.Invalid) { - return NodeComparisonResult.Subset; - } - return NodeComparisonResult.Invalid; - } - - // special case for chaining off of a meta property - if (nodeA.type === AST_NODE_TYPES.MetaProperty) { switch (nodeB.type) { - // import.meta && import.meta.foo - case AST_NODE_TYPES.MemberExpression: { - const objectCompare = compareNodes(nodeA, nodeB.object); - if (objectCompare !== NodeComparisonResult.Invalid) { + case AST_NODE_TYPES.MemberExpression: + if (nodeB.property.type === AST_NODE_TYPES.PrivateIdentifier) { + // Private identifiers in optional chaining is not currently allowed + // TODO - handle this once TS supports it (https://github.com/microsoft/TypeScript/issues/42734) + return NodeComparisonResult.Invalid; + } + if ( + compareNodes(nodeA, nodeB.object) !== NodeComparisonResult.Invalid + ) { return NodeComparisonResult.Subset; } return NodeComparisonResult.Invalid; - } - // import.meta && import.meta.bar() - case AST_NODE_TYPES.CallExpression: { - const calleeCompare = compareNodes(nodeA, nodeB.callee); - if (calleeCompare !== NodeComparisonResult.Invalid) { + case AST_NODE_TYPES.CallExpression: + if ( + compareNodes(nodeA, nodeB.callee) !== NodeComparisonResult.Invalid + ) { return NodeComparisonResult.Subset; } return NodeComparisonResult.Invalid; - } default: return NodeComparisonResult.Invalid; @@ -526,13 +469,6 @@ function compareNodes( return NodeComparisonResult.Invalid; } - // switch (nodeA.type) { - // default: { - // const currentVisitorKeys = - // } - - // } - switch (nodeA.type) { // these expressions create a new instance each time - so it makes no sense to compare the chain case AST_NODE_TYPES.ArrayExpression: @@ -549,13 +485,6 @@ function compareNodes( case AST_NODE_TYPES.AssignmentExpression: return NodeComparisonResult.Invalid; - case AST_NODE_TYPES.AwaitExpression: - return compareNodes(nodeA.argument, (nodeB as typeof nodeA).argument); - - // binary expressions always return either boolean or number - so it makes no sense to compare the chain - case AST_NODE_TYPES.BinaryExpression: - return NodeComparisonResult.Invalid; - case AST_NODE_TYPES.CallExpression: { const nodeBCall = nodeB as typeof nodeA; @@ -580,7 +509,7 @@ function compareNodes( // foo.bar() && foo?.bar?.()?.baz // are going to be exactly the same - const argumentCompare = compareNodeArray( + const argumentCompare = compareArrays( nodeA.arguments, nodeBCall.arguments, ); @@ -588,7 +517,7 @@ function compareNodes( return NodeComparisonResult.Invalid; } - const typeParamCompare = compareTypes( + const typeParamCompare = compareNodes( nodeA.typeParameters, nodeBCall.typeParameters, ); @@ -600,13 +529,8 @@ function compareNodes( } case AST_NODE_TYPES.ChainExpression: - return compareNodes(nodeA.expression, (nodeB as typeof nodeA).expression); - - // TODO - we could handle this, but it's pretty nonsensical and needlessly complicated to do something like - // (x ? y : z) && (x ? y : z).prop - // Let's put a pin in this unless someone asks for it - case AST_NODE_TYPES.ConditionalExpression: - return NodeComparisonResult.Invalid; + // special case handling for ChainExpression because it's allowed to be a subset + return compareNodes(nodeA, (nodeB as typeof nodeA).expression); case AST_NODE_TYPES.Identifier: case AST_NODE_TYPES.PrivateIdentifier: @@ -615,50 +539,48 @@ function compareNodes( } return NodeComparisonResult.Invalid; - case AST_NODE_TYPES.ImportExpression: { - const nodeBImport = nodeB as typeof nodeA; - const sourceCompare = compareNodes(nodeA.source, nodeBImport.source); - if (sourceCompare !== NodeComparisonResult.Equal) { - return NodeComparisonResult.Invalid; - } - - if (nodeA.attributes == null || nodeBImport.attributes == null) { - if (nodeA.attributes !== nodeBImport.attributes) { - return NodeComparisonResult.Invalid; - } - return NodeComparisonResult.Equal; - } - + case AST_NODE_TYPES.Literal: { + const nodeBLiteral = nodeB as typeof nodeA; if ( - compareNodes(nodeA.attributes, nodeBImport.attributes) === - NodeComparisonResult.Equal + nodeA.raw === nodeBLiteral.raw && + nodeA.value === nodeBLiteral.value ) { return NodeComparisonResult.Equal; } - return NodeComparisonResult.Invalid; } - case AST_NODE_TYPES.Literal: { - const nodeBLiteral = nodeB as typeof nodeA; + case AST_NODE_TYPES.LogicalExpression: { + const nodeBLogical = nodeB as typeof nodeA; + + // TODO - we could handle this, but it's pretty nonsensical and needlessly complicated to do something like + // (x || y) && (x || y).z + // Let's put a pin in this unless someone asks for it if ( - nodeA.raw === nodeBLiteral.raw && - nodeA.value === nodeBLiteral.value + (nodeA.parent?.type === AST_NODE_TYPES.MemberExpression && + nodeA.parent.object === nodeA) || + nodeA.parent?.type === AST_NODE_TYPES.LogicalExpression ) { + return NodeComparisonResult.Invalid; + } + + const leftCompare = compareNodes(nodeA.left, nodeBLogical.left); + if (leftCompare !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + const rightCompare = compareNodes(nodeA.right, nodeBLogical.right); + if (rightCompare === NodeComparisonResult.Equal) { return NodeComparisonResult.Equal; } return NodeComparisonResult.Invalid; } - // TODO - we could handle this, but it's pretty nonsensical and needlessly complicated to do something like - // (x || y) && (x || y).z - // Let's put a pin in this unless someone asks for it - case AST_NODE_TYPES.LogicalExpression: - return NodeComparisonResult.Invalid; - case AST_NODE_TYPES.MemberExpression: { const nodeBMember = nodeB as typeof nodeA; - if (nodeA.computed !== nodeBMember.computed) { + + if (nodeBMember.property.type === AST_NODE_TYPES.PrivateIdentifier) { + // Private identifiers in optional chaining is not currently allowed + // TODO - handle this once TS supports it (https://github.com/microsoft/TypeScript/issues/42734) return NodeComparisonResult.Invalid; } @@ -666,10 +588,21 @@ function compareNodes( // foo.bar && foo.bar.baz // ^^^^^^^ nodeA // ^^^^^^^^^^^ nodeB + // result === Equal + // + // foo.bar && foo.bar.baz.bam + // ^^^^^^^ nodeA + // ^^^^^^^^^^^^^^^ nodeB + // result === Subset + // // we don't want to check the property in this case const aSubsetOfB = compareNodes(nodeA, nodeBMember.object); if (aSubsetOfB !== NodeComparisonResult.Invalid) { - return aSubsetOfB; + return NodeComparisonResult.Subset; + } + + if (nodeA.computed !== nodeBMember.computed) { + return NodeComparisonResult.Invalid; } // NOTE - we purposely ignore optional flag because for our purposes @@ -686,33 +619,6 @@ function compareNodes( return compareNodes(nodeA.property, nodeBMember.property); } - case AST_NODE_TYPES.MetaProperty: { - const nodeBMeta = nodeB as typeof nodeA; - const metaCompare = compareNodes(nodeA.meta, nodeBMeta.meta); - if (metaCompare !== NodeComparisonResult.Equal) { - return NodeComparisonResult.Invalid; - } - const propCompare = compareNodes(nodeA.property, nodeBMeta.property); - if (propCompare === NodeComparisonResult.Equal) { - return NodeComparisonResult.Equal; - } - return NodeComparisonResult.Invalid; - } - - case AST_NODE_TYPES.SequenceExpression: - return compareNodeArray( - nodeA.expressions, - (nodeB as typeof nodeA).expressions, - ); - - case AST_NODE_TYPES.SpreadElement: - return compareNodes(nodeA.argument, (nodeB as typeof nodeA).argument); - - // constant expressions are TIGHT - case AST_NODE_TYPES.Super: - case AST_NODE_TYPES.ThisExpression: - return NodeComparisonResult.Equal; - // TODO - we could handle this, but it's pretty nonsensical and needlessly complicated to do something like // tag`foo${bar}` && tag`foo${bar}`.prop // Additionally the template tag might return a new value each time. @@ -720,6 +626,7 @@ function compareNodes( case AST_NODE_TYPES.TaggedTemplateExpression: return NodeComparisonResult.Invalid; + case AST_NODE_TYPES.TSTemplateLiteralType: case AST_NODE_TYPES.TemplateLiteral: { const nodeBTemplate = nodeB as typeof nodeA; const areQuasisEqual = @@ -732,29 +639,14 @@ function compareNodes( return NodeComparisonResult.Invalid; } - return compareNodeArray(nodeA.expressions, nodeBTemplate.expressions); + return compareNodes(nodeA, nodeBTemplate); } - case AST_NODE_TYPES.TSAsExpression: - case AST_NODE_TYPES.TSSatisfiesExpression: - case AST_NODE_TYPES.TSTypeAssertion: { - const nodeBAssertion = nodeB as typeof nodeA; - const expressionCompare = compareNodes( - nodeA.expression, - nodeBAssertion.expression, - ); - if (expressionCompare === NodeComparisonResult.Invalid) { - return NodeComparisonResult.Invalid; - } - - const typeCompare = compareTypes( - nodeA.typeAnnotation, - nodeBAssertion.typeAnnotation, - ); - if (typeCompare === NodeComparisonResult.Equal) { - return expressionCompare; + case AST_NODE_TYPES.TemplateElement: { + const nodeBElement = nodeB as typeof nodeA; + if (nodeA.value.cooked === nodeBElement.value.cooked) { + return NodeComparisonResult.Equal; } - return NodeComparisonResult.Invalid; } @@ -762,11 +654,10 @@ function compareNodes( case AST_NODE_TYPES.TSInstantiationExpression: return NodeComparisonResult.Invalid; - case AST_NODE_TYPES.TSNonNullExpression: - return compareNodes(nodeA.expression, (nodeB as typeof nodeA).expression); - - // unary expressions alway return one of number, boolean, string or undefined - so it makes no sense to compare the chain - case AST_NODE_TYPES.UnaryExpression: + // these aren't actually valid expressions. + // https://github.com/typescript-eslint/typescript-eslint/blob/20d7caee35ab84ae6381fdf04338c9e2b9e2bc48/packages/ast-spec/src/unions/Expression.ts#L37-L43 + case AST_NODE_TYPES.ArrayPattern: + case AST_NODE_TYPES.ObjectPattern: return NodeComparisonResult.Invalid; // update expression returns a number and also changes the value each time - so it makes no sense to compare the chain @@ -777,58 +668,88 @@ function compareNodes( case AST_NODE_TYPES.YieldExpression: return NodeComparisonResult.Invalid; - // these aren't actually valid expressions. - // https://github.com/typescript-eslint/typescript-eslint/blob/20d7caee35ab84ae6381fdf04338c9e2b9e2bc48/packages/ast-spec/src/unions/Expression.ts#L37-L43 - case AST_NODE_TYPES.ArrayPattern: - case AST_NODE_TYPES.ObjectPattern: - return NodeComparisonResult.Invalid; - + // general-case automatic handling of nodes to save us implementing every + // single case by hand. This just iterates the visitor keys to recursively + // check the children. + // + // Any specific logic cases or short-circuits should be listed as separate + // cases so that they don't fall into this generic handling default: - nodeA satisfies never; + return compareByVisiting(nodeA, nodeB); + } +} +const COMPARE_NODES_CACHE = new WeakMap< + TSESTree.Node, + WeakMap +>(); +function compareNodes( + nodeA: CompareNodesArgument, + nodeB: CompareNodesArgument, +): NodeComparisonResult { + if (nodeA == null || nodeB == null) { + if (nodeA !== nodeB) { return NodeComparisonResult.Invalid; + } + return NodeComparisonResult.Equal; + } + + const cached = COMPARE_NODES_CACHE.get(nodeA)?.get(nodeB); + if (cached) { + return cached; + } + + const result = compareNodesUncached(nodeA, nodeB); + let mapA = COMPARE_NODES_CACHE.get(nodeA); + if (mapA == null) { + mapA = new WeakMap(); + COMPARE_NODES_CACHE.set(nodeA, mapA); } + mapA.set(nodeB, result); + return result; } +// I hate that these functions are identical aside from the enum values used +// I can't think of a good way to reuse the code here in a way that will preserve +// the type safety and simplicity. + type OperandAnalyzer = ( operand: ValidOperand, index: number, chain: readonly ValidOperand[], -) => readonly [ValidOperand, ...ValidOperand[]] | null; +) => readonly [ValidOperand] | readonly [ValidOperand, ValidOperand] | null; const analyzeAndChainOperand: OperandAnalyzer = (operand, index, chain) => { switch (operand.comparisonType) { - case NullishComparisonType.NotBoolean: - case NullishComparisonType.NotEqNullOrUndefined: + case NullishComparisonType.Boolean: + case NullishComparisonType.NotEqualNullOrUndefined: return [operand]; - case NullishComparisonType.NotEqNull: { - // TODO(#4820) - use types here to determine if the value is just `null` (eg `=== null` would be enough on its own) - - // handle `x === null || x === undefined` + case NullishComparisonType.NotStrictEqualNull: { + // handle `x !== null && x !== undefined` const nextOperand = chain[index + 1] as ValidOperand | undefined; if ( - nextOperand?.comparisonType === NullishComparisonType.NotEqUndefined && - compareNodes(operand.node, nextOperand.node) === + nextOperand?.comparisonType === + NullishComparisonType.NotStrictEqualUndefined && + compareNodes(operand.comparedName, nextOperand.comparedName) === NodeComparisonResult.Equal ) { return [operand, nextOperand]; } else { - return null; + return [operand]; } } - case NullishComparisonType.NotEqUndefined: { - // TODO(#4820) - use types here to determine if the value is just `undefined` (eg `=== undefined` would be enough on its own) - - // handle `x === undefined || x === null` + case NullishComparisonType.NotStrictEqualUndefined: { + // handle `x !== undefined && x !== null` const nextOperand = chain[index + 1] as ValidOperand | undefined; if ( - nextOperand?.comparisonType === NullishComparisonType.NotEqNull && - compareNodes(operand.node, nextOperand.node) === + nextOperand?.comparisonType === + NullishComparisonType.NotStrictEqualNull && + compareNodes(operand.comparedName, nextOperand.comparedName) === NodeComparisonResult.Equal ) { return [operand, nextOperand]; } else { - return null; + return [operand]; } } @@ -838,18 +759,19 @@ const analyzeAndChainOperand: OperandAnalyzer = (operand, index, chain) => { }; const analyzeOrChainOperand: OperandAnalyzer = (operand, index, chain) => { switch (operand.comparisonType) { - case NullishComparisonType.Boolean: - case NullishComparisonType.EqNullOrUndefined: + case NullishComparisonType.NotBoolean: + case NullishComparisonType.EqualNullOrUndefined: return [operand]; - case NullishComparisonType.EqNull: { + case NullishComparisonType.StrictEqualNull: { // TODO(#4820) - use types here to determine if the value is just `null` (eg `=== null` would be enough on its own) // handle `x === null || x === undefined` const nextOperand = chain[index + 1] as ValidOperand | undefined; if ( - nextOperand?.comparisonType === NullishComparisonType.EqUndefined && - compareNodes(operand.node, nextOperand.node) === + nextOperand?.comparisonType === + NullishComparisonType.StrictEqualUndefined && + compareNodes(operand.comparedName, nextOperand.comparedName) === NodeComparisonResult.Equal ) { return [operand, nextOperand]; @@ -858,14 +780,14 @@ const analyzeOrChainOperand: OperandAnalyzer = (operand, index, chain) => { } } - case NullishComparisonType.EqUndefined: { + case NullishComparisonType.StrictEqualUndefined: { // TODO(#4820) - use types here to determine if the value is just `undefined` (eg `=== undefined` would be enough on its own) // handle `x === undefined || x === null` const nextOperand = chain[index + 1] as ValidOperand | undefined; if ( - nextOperand?.comparisonType === NullishComparisonType.EqNull && - compareNodes(operand.node, nextOperand.node) === + nextOperand?.comparisonType === NullishComparisonType.StrictEqualNull && + compareNodes(operand.comparedName, nextOperand.comparedName) === NodeComparisonResult.Equal ) { return [operand, nextOperand]; @@ -884,7 +806,8 @@ function analyzeChain( operator: TSESTree.LogicalExpression['operator'], chain: ValidOperand[], ): void { - if (chain.length === 0) { + // need at least 2 operands in a chain for it to be a chain + if (chain.length <= 1) { return; } @@ -937,23 +860,32 @@ function analyzeChain( // in case multiple operands were consumed - make sure to correctly increment the index i += validatedOperands.length - 1; + // purposely inspect and push the last operand because the prior operands don't matter + // this also means we won't false-positive in cases like + // foo !== null && foo !== undefined + const currentOperand = validatedOperands[validatedOperands.length - 1]; if (lastOperand) { - const currentOperand = validatedOperands[0]; const comparisonResult = compareNodes( - lastOperand.node, - currentOperand.node, + lastOperand.comparedName, + currentOperand.comparedName, ); - if ( - comparisonResult === NodeComparisonResult.Subset || - comparisonResult === NodeComparisonResult.Equal - ) { + if (comparisonResult === NodeComparisonResult.Subset) { // the operands are comparable, so we can continue searching - subChain.push(...validatedOperands); - } else { + subChain.push(currentOperand); + } else if (comparisonResult === NodeComparisonResult.Invalid) { maybeReportThenReset(validatedOperands); + } else if (comparisonResult === NodeComparisonResult.Equal) { + // purposely don't push this case because the node is a no-op and if + // we consider it then we might report on things like + // foo && foo } + } else { + subChain.push(currentOperand); } } + + // check the leftovers + maybeReportThenReset(); } export default util.createRule({ @@ -985,8 +917,6 @@ export default util.createRule({ 'LogicalExpression[operator="||"], LogicalExpression[operator="??"]'( node: TSESTree.LogicalExpression, ): void { - seenLogicals.add(node); - const leftNode = node.left; const rightNode = node.right; const parentNode = node.parent; @@ -1002,6 +932,8 @@ export default util.createRule({ return; } + seenLogicals.add(node); + function isLeftSideLowerPrecedence(): boolean { const logicalTsNode = parserServices.esTreeNodeToTSNodeMap.get(node); diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts index 99cfe6b0ff9b..5e2a39590791 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts @@ -182,26 +182,6 @@ const baseCases: Array = [ output: 'foo.bar?.()?.baz', canReplaceAndWithOr: true, }, - { - code: 'foo !== null && foo.bar !== null', - output: 'foo?.bar != null', - canReplaceAndWithOr: false, - }, - { - code: 'foo != null && foo.bar != null', - output: 'foo?.bar != null', - canReplaceAndWithOr: false, - }, - { - code: 'foo != null && foo.bar !== null', - output: 'foo?.bar != null', - canReplaceAndWithOr: false, - }, - { - code: 'foo !== null && foo.bar != null', - output: 'foo?.bar != null', - canReplaceAndWithOr: false, - }, ]; interface Selector { diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index a18de12bf7b1..5493c1eb5f16 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -1,4 +1,9 @@ import rule from '../../../src/rules/prefer-optional-chain'; +import type { + InferMessageIdsTypeFromRule, + InferOptionsTypeFromRule, +} from '../../../src/util'; +import type { InvalidTestCase } from '../../RuleTester'; import { noFormat, RuleTester } from '../../RuleTester'; import * as BaseCases from './base-cases'; @@ -6,1268 +11,1657 @@ const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', }); -ruleTester.run('prefer-optional-chain', rule, { - valid: [ - '!a || !b;', - '!a || a.b;', - '!a && a.b;', - '!a && !a.b;', - '!a.b || a.b?.();', - '!a.b || a.b();', - '!foo() || !foo().bar;', +function tempRemoveFixerTODO({ + output: _, + ...invalid +}: InvalidTestCase): InvalidTestCase< + InferMessageIdsTypeFromRule, + InferOptionsTypeFromRule +> { + return { + ...invalid, + output: null, + // @ts-expect-error -- TODO + errors: invalid.errors.map(({ suggestions: _, ...err }) => err), + }; +} - '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;', - 'foo ?? bar;', - 'foo || foo.bar;', - 'foo ?? foo.bar;', - "file !== 'index.ts' && file.endsWith('.ts');", - 'nextToken && sourceCode.isSpaceBetweenTokens(prevToken, nextToken);', - 'result && this.options.shouldPreserveNodeMaps;', - 'foo && fooBar.baz;', - 'match && match$1 !== undefined;', - 'foo !== null && foo !== undefined;', - "x['y'] !== undefined && x['y'] !== null;", - // private properties - 'this.#a && this.#b;', - '!this.#a || !this.#b;', - 'a.#foo?.bar;', - '!a.#foo?.bar;', - '!foo().#a || a;', - '!a.b.#a || a;', - '!new A().#b || a;', - '!(await a).#b || a;', - "!(foo as any).bar || 'anything';", - // currently do not handle complex computed properties - 'foo && foo[bar as string] && foo[bar as string].baz;', - 'foo && foo[1 + 2] && foo[1 + 2].baz;', - 'foo && foo[typeof bar] && foo[typeof bar].baz;', - '!foo[1 + 1] || !foo[1 + 2];', - '!foo[1 + 1] || !foo[1 + 1].foo;', - '!foo || !foo[bar as string] || !foo[bar as string].baz;', - '!foo || !foo[1 + 2] || !foo[1 + 2].baz;', - '!foo || !foo[typeof bar] || !foo[typeof bar].baz;', - // currently do not handle 'this' as the first part of a chain - 'this && this.foo;', - '!this || !this.foo;', - // intentionally do not handle mixed TSNonNullExpression in properties - '!entity.__helper!.__initialized || options.refresh;', - '!foo!.bar || !foo!.bar.baz;', - '!foo!.bar!.baz || !foo!.bar!.baz!.paz;', - '!foo.bar!.baz || !foo.bar!.baz!.paz;', - 'import.meta || true;', - 'import.meta || import.meta.foo;', - '!import.meta && false;', - '!import.meta && !import.meta.foo;', - 'new.target || new.target.length;', - '!new.target || true;', - // Do not handle direct optional chaining on private properties because of a typescript bug (https://github.com/microsoft/TypeScript/issues/42734) - // We still allow in computed properties - 'foo && foo.#bar;', - '!foo || !foo.#bar;', - ], - invalid: [ - ...BaseCases.all(), - // it should ignore whitespace in the expressions - ...BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/\./g, '. '), - })), - ...BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/\./g, '.\n'), - })), - // it should ignore parts of the expression that aren't part of the expression chain - ...BaseCases.all().map(c => ({ - ...c, - code: `${c.code} && bing`, - errors: [ - { - ...c.errors[0], - suggestions: [ - { - ...c.errors[0].suggestions![0], - output: `${c.errors[0].suggestions![0].output} && bing`, - }, - ], - }, - ], - })), - ...BaseCases.all().map(c => ({ - ...c, - code: `${c.code} && bing.bong`, - errors: [ - { - ...c.errors[0], - suggestions: [ - { - ...c.errors[0].suggestions![0], - output: `${c.errors[0].suggestions![0].output} && bing.bong`, - }, - ], - }, - ], - })), - // strict nullish equality checks x !== null && x.y !== null - ...BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!== null &&'), - })), - ...BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!= null &&'), - })), - ...BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!== undefined &&'), - })), - ...BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!= undefined &&'), - })), - - // replace && with ||: foo && foo.bar -> !foo || !foo.bar - ...BaseCases.select('canReplaceAndWithOr', true) - .all() - .map(c => ({ - ...c, - code: c.code.replace(/(^|\s)foo/g, '$1!foo').replace(/&&/g, '||'), +describe('|| {}', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [ + 'foo || {};', + 'foo || ({} as any);', + '(foo || {})?.bar;', + '(foo || { bar: 1 }).bar;', + '(undefined && (foo || {})).bar;', + 'foo ||= bar || {};', + 'foo ||= bar?.baz || {};', + '(foo1 ? foo2 : foo3 || {}).foo4;', + '(foo = 2 || {}).bar;', + 'func(foo || {}).bar;', + 'foo ?? {};', + '(foo ?? {})?.bar;', + 'foo ||= bar ?? {};', + ], + invalid: [ + { + code: '(foo || {}).bar;', errors: [ { - ...c.errors[0], + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 16, suggestions: [ { - ...c.errors[0].suggestions![0], - output: `!${c.errors[0].suggestions![0].output}`, + messageId: 'optionalChainSuggest', + output: 'foo?.bar;', }, ], }, ], - })), - - // two errors - { - code: noFormat`foo && foo.bar && foo.bar.baz || baz && baz.bar && baz.bar.foo`, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: `foo?.bar?.baz || baz && baz.bar && baz.bar.foo`, - }, - ], - }, - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: `foo && foo.bar && foo.bar.baz || baz?.bar?.foo`, - }, - ], - }, - ], - }, - // case with inconsistent checks - { - code: 'foo && foo.bar != null && foo.bar.baz !== undefined && foo.bar.baz.buzz;', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar?.baz?.buzz;', - }, - ], - }, - ], - }, - { - code: noFormat`foo.bar && foo.bar.baz != null && foo.bar.baz.qux !== undefined && foo.bar.baz.qux.buzz;`, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo.bar?.baz?.qux?.buzz;', - }, - ], - }, - ], - }, - // ensure essential whitespace isn't removed - { - code: 'foo && foo.bar(baz => );', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar(baz => );', - }, - ], - }, - ], - parserOptions: { - ecmaFeatures: { - jsx: true, - }, }, - }, - { - code: 'foo && foo.bar(baz => typeof baz);', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar(baz => typeof baz);', - }, - ], - }, - ], - }, - { - code: noFormat`foo && foo["some long string"] && foo["some long string"].baz`, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: `foo?.["some long string"]?.baz`, - }, - ], - }, - ], - }, - { - code: noFormat`foo && foo[\`some long string\`] && foo[\`some long string\`].baz`, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: `foo?.[\`some long string\`]?.baz`, - }, - ], - }, - ], - }, - { - code: "foo && foo['some long string'] && foo['some long string'].baz;", - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: "foo?.['some long string']?.baz;", - }, - ], - }, - ], - }, - // should preserve comments in a call expression - { - code: noFormat` -foo && foo.bar(/* comment */a, - // comment2 - b, ); - `, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: ` -foo?.bar(/* comment */a, - // comment2 - b, ); - `, - }, - ], - }, - ], - }, - // ensure binary expressions that are the last expression do not get removed - { - code: 'foo && foo.bar != null;', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar != null;', - }, - ], - }, - ], - }, - { - code: 'foo && foo.bar != undefined;', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar != undefined;', - }, - ], - }, - ], - }, - { - code: 'foo && foo.bar != null && baz;', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar != null && baz;', - }, - ], - }, - ], - }, - // case with this keyword at the start of expression - { - code: 'this.bar && this.bar.baz;', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'this.bar?.baz;', - }, - ], - }, - ], - }, - // other weird cases - { - code: 'foo && foo?.();', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.();', - }, - ], - }, - ], - }, - { - code: 'foo.bar && foo.bar?.();', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo.bar?.();', - }, - ], - }, - ], - }, - // using suggestion instead of autofix - { - code: 'foo && foo.bar != null && foo.bar.baz !== undefined && foo.bar.baz.buzz;', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - line: 1, - column: 1, - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar?.baz?.buzz;', - }, - ], - }, - ], - }, - { - code: 'foo && foo.bar(baz => );', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - line: 1, - column: 1, - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar(baz => );', - }, - ], - }, - ], - parserOptions: { - ecmaFeatures: { - jsx: true, - }, + { + code: noFormat`(foo || ({})).bar;`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 1, + endColumn: 18, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '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: `if (foo) { foo?.bar; }`, - }, - ], - }, - ], - }, - { - code: noFormat`if ((foo || {}).bar) { foo.bar; }`, - errors: [ - { - messageId: 'optionalChainSuggest', - column: 5, - endColumn: 20, - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: `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: `if (foo) { foo?.bar; }`, - }, - ], - }, - ], - }, - { - code: noFormat`if ((foo ?? {}).bar) { foo.bar; }`, - errors: [ - { - messageId: 'optionalChainSuggest', - column: 5, - endColumn: 20, - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: `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: `((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;', - }, - ], - }, - ], - }, - { - code: '(this || {}).foo;', - errors: [ - { - messageId: 'optionalChainSuggest', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'this?.foo;', - }, - ], - }, - ], - }, - // case with this keyword at the start of expression - { - code: '!this.bar || !this.bar.baz;', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: '!this.bar?.baz;', - }, - ], - }, - ], - }, - { - code: '!a.b || !a.b();', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: '!a.b?.();', - }, - ], - }, - ], - }, - { - code: '!foo.bar || !foo.bar.baz;', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: '!foo.bar?.baz;', - }, - ], - }, - ], - }, - { - code: '!foo[bar] || !foo[bar]?.[baz];', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: '!foo[bar]?.[baz];', - }, - ], - }, - ], - }, - { - code: '!foo || !foo?.bar.baz;', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: '!foo?.bar.baz;', - }, - ], - }, - ], - }, - // two errors - { - code: noFormat`(!foo || !foo.bar || !foo.bar.baz) && (!baz || !baz.bar || !baz.bar.foo);`, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: noFormat`(!foo?.bar?.baz) && (!baz || !baz.bar || !baz.bar.foo);`, - }, - ], + { + 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: `if (foo) { foo?.bar; }`, + }, + ], + }, + ], + }, + { + code: noFormat`if ((foo || {}).bar) { foo.bar; }`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 5, + endColumn: 20, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: `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: `if (foo) { foo?.bar; }`, + }, + ], + }, + ], + }, + { + code: noFormat`if ((foo ?? {}).bar) { foo.bar; }`, + errors: [ + { + messageId: 'optionalChainSuggest', + column: 5, + endColumn: 20, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: `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: `((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;', + }, + ], + }, + ], + }, + { + code: '(this || {}).foo;', + errors: [ + { + messageId: 'optionalChainSuggest', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'this?.foo;', + }, + ], + }, + ], + }, + ], + }); +}); + +describe('hand-crafted cases', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [ + '!a || !b;', + '!a || a.b;', + '!a && a.b;', + '!a && !a.b;', + '!a.b || a.b?.();', + '!a.b || a.b();', + 'foo ||= bar;', + 'foo ||= bar?.baz;', + 'foo ||= bar?.baz?.buzz;', + 'foo && bar;', + 'foo && foo;', + 'foo || bar;', + 'foo ?? bar;', + 'foo || foo.bar;', + 'foo ?? foo.bar;', + "file !== 'index.ts' && file.endsWith('.ts');", + 'nextToken && sourceCode.isSpaceBetweenTokens(prevToken, nextToken);', + 'result && this.options.shouldPreserveNodeMaps;', + 'foo && fooBar.baz;', + 'match && match$1 !== undefined;', + // looks like a chain, but isn't actually a chain - just a pair of strict nullish checks + 'foo !== null && foo !== undefined;', + "x['y'] !== undefined && x['y'] !== null;", + // private properties + 'this.#a && this.#b;', + '!this.#a || !this.#b;', + 'a.#foo?.bar;', + '!a.#foo?.bar;', + '!foo().#a || a;', + '!a.b.#a || a;', + '!new A().#b || a;', + '!(await a).#b || a;', + "!(foo as any).bar || 'anything';", + // computed properties should be interrogated and correctly ignored + '!foo[1 + 1] || !foo[1 + 2];', + '!foo[1 + 1] || !foo[1 + 2].foo;', + // currently do not handle 'this' as the first part of a chain + 'this && this.foo;', + '!this || !this.foo;', + '!entity.__helper!.__initialized || options.refresh;', + 'import.meta || true;', + 'import.meta || import.meta.foo;', + '!import.meta && false;', + '!import.meta && !import.meta.foo;', + 'new.target || new.target.length;', + '!new.target || true;', + // Do not handle direct optional chaining on private properties because this TS limitation (https://github.com/microsoft/TypeScript/issues/42734) + 'foo && foo.#bar;', + '!foo || !foo.#bar;', + ], + invalid: [ + // two errors + { + code: noFormat`foo && foo.bar && foo.bar.baz || baz && baz.bar && baz.bar.foo`, + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: `foo?.bar?.baz || baz && baz.bar && baz.bar.foo`, + }, + ], + }, + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: `foo && foo.bar && foo.bar.baz || baz?.bar?.foo`, + }, + ], + }, + ], + }, + // case with inconsistent checks + { + code: 'foo && foo.bar != null && foo.bar.baz !== undefined && foo.bar.baz.buzz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar?.baz?.buzz;', + }, + ], + }, + ], + }, + { + code: noFormat`foo.bar && foo.bar.baz != null && foo.bar.baz.qux !== undefined && foo.bar.baz.qux.buzz;`, + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo.bar?.baz?.qux?.buzz;', + }, + ], + }, + ], + }, + // ensure essential whitespace isn't removed + { + code: 'foo && foo.bar(baz => );', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar(baz => );', + }, + ], + }, + ], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, }, - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: noFormat`(!foo || !foo.bar || !foo.bar.baz) && (!baz?.bar?.foo);`, - }, - ], + }, + { + code: 'foo && foo.bar(baz => typeof baz);', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar(baz => typeof baz);', + }, + ], + }, + ], + }, + { + code: noFormat`foo && foo["some long string"] && foo["some long string"].baz`, + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: `foo?.["some long string"]?.baz`, + }, + ], + }, + ], + }, + { + code: noFormat`foo && foo[\`some long string\`] && foo[\`some long string\`].baz`, + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: `foo?.[\`some long string\`]?.baz`, + }, + ], + }, + ], + }, + { + code: "foo && foo['some long string'] && foo['some long string'].baz;", + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: "foo?.['some long string']?.baz;", + }, + ], + }, + ], + }, + // complex computed properties should be handled correctly + { + code: 'foo && foo[bar as string] && foo[bar as string].baz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.[bar as string]?.baz;', + }, + ], + }, + ], + }, + { + code: 'foo && foo[1 + 2] && foo[1 + 2].baz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.[1 + 2]?.baz;', + }, + ], + }, + ], + }, + { + code: 'foo && foo[typeof bar] && foo[typeof bar].baz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.[typeof bar]?.baz;', + }, + ], + }, + ], + }, + // should preserve comments in a call expression + { + code: noFormat` +foo && foo.bar(/* comment */a, + // comment2 + b, ); + `, + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: ` +foo?.bar(/* comment */a, + // comment2 + b, ); + `, + }, + ], + }, + ], + }, + // ensure binary expressions that are the last expression do not get removed + { + code: 'foo && foo.bar != null;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar != null;', + }, + ], + }, + ], + }, + { + code: 'foo && foo.bar != undefined;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar != undefined;', + }, + ], + }, + ], + }, + { + code: 'foo && foo.bar != null && baz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar != null && baz;', + }, + ], + }, + ], + }, + // case with this keyword at the start of expression + { + code: 'this.bar && this.bar.baz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'this.bar?.baz;', + }, + ], + }, + ], + }, + // other weird cases + { + code: 'foo && foo?.();', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.();', + }, + ], + }, + ], + }, + { + code: 'foo.bar && foo.bar?.();', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo.bar?.();', + }, + ], + }, + ], + }, + // using suggestion instead of autofix + { + code: 'foo && foo.bar != null && foo.bar.baz !== undefined && foo.bar.baz.buzz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + line: 1, + column: 1, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar?.baz?.buzz;', + }, + ], + }, + ], + }, + { + code: 'foo && foo.bar(baz => );', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + line: 1, + column: 1, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar(baz => );', + }, + ], + }, + ], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, }, - ], - }, - { - code: ` + }, + // case with this keyword at the start of expression + { + code: '!this.bar || !this.bar.baz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '!this.bar?.baz;', + }, + ], + }, + ], + }, + { + code: '!a.b || !a.b();', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '!a.b?.();', + }, + ], + }, + ], + }, + { + code: '!foo.bar || !foo.bar.baz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '!foo.bar?.baz;', + }, + ], + }, + ], + }, + { + code: '!foo[bar] || !foo[bar]?.[baz];', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '!foo[bar]?.[baz];', + }, + ], + }, + ], + }, + { + code: '!foo || !foo?.bar.baz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: '!foo?.bar.baz;', + }, + ], + }, + ], + }, + // two errors + { + code: noFormat`(!foo || !foo.bar || !foo.bar.baz) && (!baz || !baz.bar || !baz.bar.foo);`, + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: noFormat`(!foo?.bar?.baz) && (!baz || !baz.bar || !baz.bar.foo);`, + }, + ], + }, + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: noFormat`(!foo || !foo.bar || !foo.bar.baz) && (!baz?.bar?.foo);`, + }, + ], + }, + ], + }, + { + code: ` class Foo { constructor() { new.target && new.target.length; } } `, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: ` + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: ` class Foo { constructor() { new.target?.length; } } `, - }, - ], - }, - ], - }, - { - code: noFormat`import.meta && import.meta?.baz;`, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: noFormat`import.meta?.baz;`, - }, - ], - }, - ], - }, - { - code: noFormat`!import.meta || !import.meta?.baz;`, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: noFormat`!import.meta?.baz;`, - }, - ], - }, - ], - }, - { - code: noFormat`import.meta && import.meta?.() && import.meta?.().baz;`, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: noFormat`import.meta?.()?.baz;`, - }, - ], - }, - ], - }, - ], + }, + ], + }, + ], + }, + { + code: noFormat`import.meta && import.meta?.baz;`, + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: noFormat`import.meta?.baz;`, + }, + ], + }, + ], + }, + { + code: noFormat`!import.meta || !import.meta?.baz;`, + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: noFormat`!import.meta?.baz;`, + }, + ], + }, + ], + }, + { + code: noFormat`import.meta && import.meta?.() && import.meta?.().baz;`, + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: noFormat`import.meta?.()?.baz;`, + }, + ], + }, + ], + }, + // non-null expressions + { + code: '!foo() || !foo().bar;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: '!foo!.bar || !foo!.bar.baz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: '!foo!.bar!.baz || !foo!.bar!.baz!.paz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: '!foo.bar!.baz || !foo.bar!.baz!.paz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: 'foo !== null && foo.bar !== null', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar != null', + }, + ], + }, + ], + }, + { + code: 'foo != null && foo.bar != null', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar != null', + }, + ], + }, + ], + }, + { + code: 'foo != null && foo.bar !== null', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar != null', + }, + ], + }, + ], + }, + { + code: 'foo !== null && foo.bar != null', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.bar != null', + }, + ], + }, + ], + }, + // https://github.com/typescript-eslint/typescript-eslint/issues/6332 + { + code: 'unrelated != null && foo != null && foo.bar != null', + output: 'unrelated != null && foo?.bar != null', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: 'unrelated1 != null && unrelated2 != null && foo != null && foo.bar != null', + output: 'unrelated1 != null && unrelated2 != null && foo?.bar != null', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + // https://github.com/typescript-eslint/typescript-eslint/issues/1461 + { + code: 'foo1 != null && foo1.bar != null && foo2 != null && foo2.bar != null', + output: 'foo1?.bar != null && foo2?.bar != null', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: 'foo && foo.a && bar && bar.a', + output: 'foo?.a && bar?.a', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + // randomly placed optional chain tokens are ignored + { + code: 'foo.bar.baz != null && foo?.bar?.baz.bam != null', + output: 'foo.bar.baz?.bam != null', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: 'foo?.bar.baz != null && foo.bar?.baz.bam != null', + output: 'foo?.bar.baz?.bam != null', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: 'foo?.bar?.baz != null && foo.bar.baz.bam != null', + output: 'foo?.bar?.baz?.bam != null', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + // randomly placed non-null assertions are ignored + { + code: 'foo.bar.baz != null && foo!.bar!.baz.bam != null', + output: 'foo.bar.baz?.bam != null', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: 'foo!.bar.baz != null && foo.bar!.baz.bam != null', + output: 'foo?.bar.baz?.bam != null', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: 'foo!.bar!.baz != null && foo.bar.baz.bam != null', + output: 'foo?.bar?.baz?.bam != null', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + ] + // TODO - remove this once fixer is reimplemented + .map(tempRemoveFixerTODO), + }); +}); + +describe('base cases', () => { + describe('and', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: [ + ...BaseCases.all(), + // it should ignore whitespace in the expressions + ...BaseCases.all().map(c => ({ + ...c, + code: c.code.replace(/\./g, '. '), + })), + ...BaseCases.all().map(c => ({ + ...c, + code: c.code.replace(/\./g, '.\n'), + })), + // it should ignore parts of the expression that aren't part of the expression chain + ...BaseCases.all().map(c => ({ + ...c, + code: `${c.code} && bing`, + errors: [ + { + ...c.errors[0], + suggestions: [ + { + ...c.errors[0].suggestions![0], + output: `${c.errors[0].suggestions![0].output} && bing`, + }, + ], + }, + ], + })), + ...BaseCases.all().map(c => ({ + ...c, + code: `${c.code} && bing.bong`, + errors: [ + { + ...c.errors[0], + suggestions: [ + { + ...c.errors[0].suggestions![0], + output: `${c.errors[0].suggestions![0].output} && bing.bong`, + }, + ], + }, + ], + })), + ] + // TODO - remove this once fixer is reimplemented + .map(tempRemoveFixerTODO), + }); + }); + + describe('strict nullish equality checks', () => { + describe('!== null', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases.all() + .map(c => ({ + ...c, + code: c.code.replace(/&&/g, '!== null &&'), + })) + // TODO - remove this once fixer is reimplemented + .map(tempRemoveFixerTODO), + }); + }); + + describe('!= null', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases.all() + .map(c => ({ + ...c, + code: c.code.replace(/&&/g, '!= null &&'), + })) + // TODO - remove this once fixer is reimplemented + .map(tempRemoveFixerTODO), + }); + }); + + describe('!== undefined', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases.all() + .map(c => ({ + ...c, + code: c.code.replace(/&&/g, '!== undefined &&'), + })) + // TODO - remove this once fixer is reimplemented + .map(tempRemoveFixerTODO), + }); + }); + + describe('!= undefined', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases.all() + .map(c => ({ + ...c, + code: c.code.replace(/&&/g, '!= undefined &&'), + })) + // TODO - remove this once fixer is reimplemented + .map(tempRemoveFixerTODO), + }); + }); + }); + + describe('or', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases.select('canReplaceAndWithOr', true) + .all() + .map(c => ({ + ...c, + code: c.code.replace(/(^|\s)foo/g, '$1!foo').replace(/&&/g, '||'), + errors: [ + { + ...c.errors[0], + suggestions: [ + { + ...c.errors[0].suggestions![0], + output: `!${c.errors[0].suggestions![0].output}`, + }, + ], + }, + ], + })) + // TODO - remove this once fixer is reimplemented + .map(tempRemoveFixerTODO), + }); + }); + + describe('should ignore spacing sanity checks', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: [ + // it should ignore whitespace in the expressions + ...BaseCases.all().map(c => ({ + ...c, + code: c.code.replace(/\./g, '. '), + })), + ...BaseCases.all().map(c => ({ + ...c, + code: c.code.replace(/\./g, '.\n'), + })), + ] + // TODO - remove this once fixer is reimplemented + .map(tempRemoveFixerTODO), + }); + }); + + describe('should skip trailing irrelevant operands sanity checks', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: [ + // it should ignore parts of the expression that aren't part of the expression chain + ...BaseCases.all().map(c => ({ + ...c, + code: `${c.code} && bing`, + errors: [ + { + ...c.errors[0], + suggestions: [ + { + ...c.errors[0].suggestions![0], + output: `${c.errors[0].suggestions![0].output} && bing`, + }, + ], + }, + ], + })), + ...BaseCases.all().map(c => ({ + ...c, + code: `${c.code} && bing.bong`, + errors: [ + { + ...c.errors[0], + suggestions: [ + { + ...c.errors[0].suggestions![0], + output: `${c.errors[0].suggestions![0].output} && bing.bong`, + }, + ], + }, + ], + })), + ] + // TODO - remove this once fixer is reimplemented + .map(tempRemoveFixerTODO), + }); + }); }); diff --git a/packages/typescript-estree/src/convert.ts b/packages/typescript-estree/src/convert.ts index 70ee9da4e0ce..3afa527a62b7 100644 --- a/packages/typescript-estree/src/convert.ts +++ b/packages/typescript-estree/src/convert.ts @@ -2001,10 +2001,10 @@ export class Converter { ); return result; } else { - const type = getBinaryExpressionType(node.operatorToken); + const expressionType = getBinaryExpressionType(node.operatorToken); if ( this.allowPattern && - type === AST_NODE_TYPES.AssignmentExpression + expressionType.type === AST_NODE_TYPES.AssignmentExpression ) { return this.createNode(node, { type: AST_NODE_TYPES.AssignmentPattern, @@ -2017,13 +2017,12 @@ export class Converter { | TSESTree.LogicalExpression | TSESTree.BinaryExpression >(node, { - type, - operator: getTextForTokenKind(node.operatorToken.kind), + ...expressionType, left: this.converter( node.left, node, this.inTypeMode, - type === AST_NODE_TYPES.AssignmentExpression, + expressionType.type === AST_NODE_TYPES.AssignmentExpression, ), right: this.convertChild(node.right), }); diff --git a/packages/typescript-estree/src/node-utils.ts b/packages/typescript-estree/src/node-utils.ts index 2b7351b0408f..bc1a612b6970 100644 --- a/packages/typescript-estree/src/node-utils.ts +++ b/packages/typescript-estree/src/node-utils.ts @@ -7,36 +7,84 @@ import { AST_NODE_TYPES, AST_TOKEN_TYPES } from './ts-estree'; const SyntaxKind = ts.SyntaxKind; -const LOGICAL_OPERATORS: ( - | ts.LogicalOperator - | ts.SyntaxKind.QuestionQuestionToken -)[] = [ +type LogicalOperatorKind = + | ts.SyntaxKind.AmpersandAmpersandToken + | ts.SyntaxKind.BarBarToken + | ts.SyntaxKind.QuestionQuestionToken; +const LOGICAL_OPERATORS: ReadonlySet = new Set([ SyntaxKind.BarBarToken, SyntaxKind.AmpersandAmpersandToken, SyntaxKind.QuestionQuestionToken, -]; +]); -interface TokenToText extends TSESTree.PunctuatorTokenToText { +interface TokenToText + extends TSESTree.PunctuatorTokenToText, + TSESTree.BinaryOperatorToText { [SyntaxKind.ImportKeyword]: 'import'; - [SyntaxKind.InKeyword]: 'in'; - [SyntaxKind.InstanceOfKeyword]: 'instanceof'; [SyntaxKind.NewKeyword]: 'new'; [SyntaxKind.KeyOfKeyword]: 'keyof'; [SyntaxKind.ReadonlyKeyword]: 'readonly'; [SyntaxKind.UniqueKeyword]: 'unique'; } +type AssignmentOperatorKind = keyof TSESTree.AssignmentOperatorToText; +const ASSIGNMENT_OPERATORS: ReadonlySet = new Set([ + ts.SyntaxKind.EqualsToken, + ts.SyntaxKind.PlusEqualsToken, + ts.SyntaxKind.MinusEqualsToken, + ts.SyntaxKind.AsteriskEqualsToken, + ts.SyntaxKind.AsteriskAsteriskEqualsToken, + ts.SyntaxKind.SlashEqualsToken, + ts.SyntaxKind.PercentEqualsToken, + ts.SyntaxKind.LessThanLessThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.AmpersandEqualsToken, + ts.SyntaxKind.BarEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.AmpersandAmpersandEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, + ts.SyntaxKind.CaretEqualsToken, +]); + +type BinaryOperatorKind = keyof TSESTree.BinaryOperatorToText; +const BINARY_OPERATORS: ReadonlySet = new Set([ + SyntaxKind.InstanceOfKeyword, + SyntaxKind.InKeyword, + SyntaxKind.AsteriskAsteriskToken, + SyntaxKind.AsteriskToken, + SyntaxKind.SlashToken, + SyntaxKind.PercentToken, + SyntaxKind.PlusToken, + SyntaxKind.MinusToken, + SyntaxKind.AmpersandToken, + SyntaxKind.BarToken, + SyntaxKind.CaretToken, + SyntaxKind.LessThanLessThanToken, + SyntaxKind.GreaterThanGreaterThanToken, + SyntaxKind.GreaterThanGreaterThanGreaterThanToken, + SyntaxKind.AmpersandAmpersandToken, + SyntaxKind.BarBarToken, + SyntaxKind.LessThanToken, + SyntaxKind.LessThanEqualsToken, + SyntaxKind.GreaterThanToken, + SyntaxKind.GreaterThanEqualsToken, + SyntaxKind.EqualsEqualsToken, + SyntaxKind.EqualsEqualsEqualsToken, + SyntaxKind.ExclamationEqualsEqualsToken, + SyntaxKind.ExclamationEqualsToken, +]); + /** * Returns true if the given ts.Token is the assignment operator * @param operator the operator token * @returns is assignment */ -export function isAssignmentOperator( - operator: ts.Token, -): boolean { - return ( - operator.kind >= SyntaxKind.FirstAssignment && - operator.kind <= SyntaxKind.LastAssignment +function isAssignmentOperator( + operator: ts.BinaryOperatorToken, +): operator is ts.Token { + return (ASSIGNMENT_OPERATORS as ReadonlySet).has( + operator.kind, ); } @@ -45,12 +93,21 @@ export function isAssignmentOperator( * @param operator the operator token * @returns is a logical operator */ -export function isLogicalOperator( - operator: ts.Token, -): boolean { - return (LOGICAL_OPERATORS as ts.SyntaxKind[]).includes(operator.kind); +function isLogicalOperator( + operator: ts.BinaryOperatorToken, +): operator is ts.Token { + return (LOGICAL_OPERATORS as ReadonlySet).has(operator.kind); +} + +function isESTreeBinaryOperator( + operator: ts.BinaryOperatorToken, +): operator is ts.Token { + return (BINARY_OPERATORS as ReadonlySet).has(operator.kind); } +type TokenForTokenKind = T extends keyof TokenToText + ? TokenToText[T] + : string | undefined; /** * Returns the string form of the given TSToken SyntaxKind * @param kind the token's SyntaxKind @@ -58,7 +115,7 @@ export function isLogicalOperator( */ export function getTextForTokenKind( kind: T, -): T extends keyof TokenToText ? TokenToText[T] : string | undefined { +): TokenForTokenKind { return ts.tokenToString(kind) as T extends keyof TokenToText ? TokenToText[T] : string | undefined; @@ -116,7 +173,7 @@ export function isComma( * @param node the TypeScript node * @returns is comment */ -export function isComment(node: ts.Node): boolean { +function isComment(node: ts.Node): boolean { return ( node.kind === SyntaxKind.SingleLineCommentTrivia || node.kind === SyntaxKind.MultiLineCommentTrivia @@ -128,7 +185,7 @@ export function isComment(node: ts.Node): boolean { * @param node the TypeScript node * @returns is JSDoc comment */ -export function isJSDocComment(node: ts.Node): node is ts.JSDoc { +function isJSDocComment(node: ts.Node): node is ts.JSDoc { return node.kind === SyntaxKind.JSDocComment; } @@ -137,18 +194,39 @@ export function isJSDocComment(node: ts.Node): node is ts.JSDoc { * @param operator the operator token * @returns the binary expression type */ -export function getBinaryExpressionType( - operator: ts.Token, -): - | AST_NODE_TYPES.AssignmentExpression - | AST_NODE_TYPES.LogicalExpression - | AST_NODE_TYPES.BinaryExpression { +export function getBinaryExpressionType(operator: ts.BinaryOperatorToken): + | { + type: AST_NODE_TYPES.AssignmentExpression; + operator: TokenForTokenKind; + } + | { + type: AST_NODE_TYPES.LogicalExpression; + operator: TokenForTokenKind; + } + | { + type: AST_NODE_TYPES.BinaryExpression; + operator: TokenForTokenKind; + } { if (isAssignmentOperator(operator)) { - return AST_NODE_TYPES.AssignmentExpression; + return { + type: AST_NODE_TYPES.AssignmentExpression, + operator: getTextForTokenKind(operator.kind), + }; } else if (isLogicalOperator(operator)) { - return AST_NODE_TYPES.LogicalExpression; + return { + type: AST_NODE_TYPES.LogicalExpression, + operator: getTextForTokenKind(operator.kind), + }; + } else if (isESTreeBinaryOperator(operator)) { + return { + type: AST_NODE_TYPES.BinaryExpression, + operator: getTextForTokenKind(operator.kind), + }; } - return AST_NODE_TYPES.BinaryExpression; + + throw new Error( + `Unexpected binary operator ${ts.tokenToString(operator.kind)}`, + ); } /** @@ -231,7 +309,7 @@ export function getRange(node: ts.Node, ast: ts.SourceFile): [number, number] { * @param node the ts.Node * @returns is a token */ -export function isToken(node: ts.Node): node is ts.Token { +function isToken(node: ts.Node): node is ts.Token { return ( node.kind >= SyntaxKind.FirstToken && node.kind <= SyntaxKind.LastToken ); @@ -242,7 +320,7 @@ export function isToken(node: ts.Node): node is ts.Token { * @param node ts.Node to be checked * @returns is a JSX token */ -export function isJSXToken(node: ts.Node): boolean { +function isJSXToken(node: ts.Node): boolean { return ( node.kind >= SyntaxKind.JsxElement && node.kind <= SyntaxKind.JsxAttribute ); @@ -430,7 +508,7 @@ export function isChildUnwrappableOptionalChain( * @param token the ts.Token * @returns the token type */ -export function getTokenType( +function getTokenType( token: ts.Identifier | ts.Token, ): Exclude { if ('originalKeywordKind' in token && token.originalKeywordKind) { @@ -630,7 +708,7 @@ export function createError( * @param n the TSNode * @param ast the TS AST */ -export function nodeHasTokens(n: ts.Node, ast: ts.SourceFile): boolean { +function nodeHasTokens(n: ts.Node, ast: ts.SourceFile): boolean { // If we have a token or node that has a non-zero width, it must have tokens. // Note: getWidth() does not take trivia into account. return n.kind === SyntaxKind.EndOfFileToken @@ -662,13 +740,11 @@ export function firstDefined( return undefined; } -export function identifierIsThisKeyword(id: ts.Identifier): boolean { +function identifierIsThisKeyword(id: ts.Identifier): boolean { return id.originalKeywordKind === SyntaxKind.ThisKeyword; } -export function isThisIdentifier( - node: ts.Node | undefined, -): node is ts.Identifier { +function isThisIdentifier(node: ts.Node | undefined): node is ts.Identifier { return ( !!node && node.kind === SyntaxKind.Identifier && diff --git a/packages/typescript-estree/src/simple-traverse.ts b/packages/typescript-estree/src/simple-traverse.ts index 2d51cdbe4fa1..5955a09b758d 100644 --- a/packages/typescript-estree/src/simple-traverse.ts +++ b/packages/typescript-estree/src/simple-traverse.ts @@ -2,10 +2,13 @@ import { visitorKeys } from '@typescript-eslint/visitor-keys'; import type { TSESTree } from './ts-estree'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function isValidNode(x: any): x is TSESTree.Node { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return x != null && typeof x === 'object' && typeof x.type === 'string'; +function isValidNode(x: unknown): x is TSESTree.Node { + return ( + typeof x === 'object' && + x != null && + 'type' in x && + typeof x.type === 'string' + ); } function getVisitorKeysForNode( diff --git a/packages/visitor-keys/src/visitor-keys.ts b/packages/visitor-keys/src/visitor-keys.ts index 80a8b8c9c782..194713224144 100644 --- a/packages/visitor-keys/src/visitor-keys.ts +++ b/packages/visitor-keys/src/visitor-keys.ts @@ -111,11 +111,27 @@ type AdditionalKeys = { [T in KeysDefinedInESLintVisitorKeysCore]?: readonly GetNodeTypeKeys[]; }; -/** - * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! IMPORTANT NOTE !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - * - * The key arrays should be sorted in the order in which you would want to visit - * the child keys - don't just sort them alphabetically. +/* + ********************************** IMPORTANT NOTE ******************************** + * * + * The key arrays should be sorted in the order in which you would want to visit * + * the child keys. * + * * + * DO NOT SORT THEM ALPHABETICALLY! * + * * + * They should be sorted in the order that they appear in the source code. * + * For example: * + * * + * class Foo extends Bar { prop: 1 } * + * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ClassDeclaration * + * ^^^ id ^^^ superClass * + * ^^^^^^^^^^^ body * + * * + * It would be incorrect to provide the visitor keys ['body', 'id', 'superClass'] * + * because the body comes AFTER everything else in the source code. * + * Instead the correct ordering would be ['id', 'superClass', 'body']. * + * * + ********************************************************************************** */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- TODO - add ignore IIFE option From e51128a85e411826f8e566f9430d11f3c489e8ef Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 7 Feb 2023 18:12:00 +1030 Subject: [PATCH 07/26] add more test --- .../prefer-optional-chain.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index 5493c1eb5f16..0c1992265adc 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -1468,6 +1468,47 @@ foo?.bar(/* comment */a, }, ], }, + // mixed logical checks are followed and flagged + { + code: ` + a && + a.b != null && + a.b.c !== undefined && + a.b.c !== null && + a.b.c.d != null && + a.b.c.d.e !== null && + a.b.c.d.e.f !== undefined && + typeof a.b.c.d.e.f.g !== 'undefined' && + a.b.c.d.e.f.g.h + `, + output: 'a?.b?.c?.d?.e?.f?.g?.h', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: ` + !a || + a.b == null || + a.b.c === undefined || + a.b.c === null || + a.b.c.d == null || + a.b.c.d.e === null || + a.b.c.d.e.f === undefined || + typeof a.b.c.d.e.f.g === 'undefined' || + a.b.c.d.e.f.g.h + `, + output: 'a?.b?.c?.d?.e?.f?.g?.h', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, ] // TODO - remove this once fixer is reimplemented .map(tempRemoveFixerTODO), From 34671ecb01f477b33c8f2dd570431ed3d4ea10b6 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 7 Feb 2023 18:19:20 +1030 Subject: [PATCH 08/26] fix lint errors across codebase revealed by changes --- .../eslint-plugin/src/rules/class-literal-property-style.ts | 2 +- .../src/rules/explicit-module-boundary-types.ts | 2 +- packages/eslint-plugin/src/rules/no-extraneous-class.ts | 5 ++--- packages/eslint-plugin/src/rules/prefer-includes.ts | 3 +-- .../prefer-optional-chain/prefer-optional-chain.test.ts | 2 +- 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/eslint-plugin/src/rules/class-literal-property-style.ts b/packages/eslint-plugin/src/rules/class-literal-property-style.ts index ed49b144e478..f52bf1c3b11a 100644 --- a/packages/eslint-plugin/src/rules/class-literal-property-style.ts +++ b/packages/eslint-plugin/src/rules/class-literal-property-style.ts @@ -60,7 +60,7 @@ export default util.createRule({ if ( node.kind !== 'get' || !node.value.body || - !node.value.body.body.length + node.value.body.body.length === 0 ) { return; } diff --git a/packages/eslint-plugin/src/rules/explicit-module-boundary-types.ts b/packages/eslint-plugin/src/rules/explicit-module-boundary-types.ts index e7552e0cbd8f..4c802b1eb449 100644 --- a/packages/eslint-plugin/src/rules/explicit-module-boundary-types.ts +++ b/packages/eslint-plugin/src/rules/explicit-module-boundary-types.ts @@ -235,7 +235,7 @@ export default util.createRule({ * Checks if a function name is allowed and should not be checked. */ function isAllowedName(node: TSESTree.Node | undefined): boolean { - if (!node || !options.allowedNames || !options.allowedNames.length) { + if (!node || !options.allowedNames || options.allowedNames.length === 0) { return false; } diff --git a/packages/eslint-plugin/src/rules/no-extraneous-class.ts b/packages/eslint-plugin/src/rules/no-extraneous-class.ts index b7b93c8c77c3..d5b76afefb90 100644 --- a/packages/eslint-plugin/src/rules/no-extraneous-class.ts +++ b/packages/eslint-plugin/src/rules/no-extraneous-class.ts @@ -72,9 +72,8 @@ export default util.createRule({ ): boolean => { return !!( allowWithDecorator && - node && - node.decorators && - node.decorators.length + node?.decorators && + node.decorators.length !== 0 ); }; diff --git a/packages/eslint-plugin/src/rules/prefer-includes.ts b/packages/eslint-plugin/src/rules/prefer-includes.ts index 720d5fbe8092..940e75913556 100644 --- a/packages/eslint-plugin/src/rules/prefer-includes.ts +++ b/packages/eslint-plugin/src/rules/prefer-includes.ts @@ -161,8 +161,7 @@ export default createRule({ .getProperty('includes') ?.getDeclarations(); if ( - includesMethodDecl == null || - !includesMethodDecl.some(includesMethodDecl => + !includesMethodDecl?.some(includesMethodDecl => hasSameParameters(includesMethodDecl, instanceofMethodDecl), ) ) { diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index 0c1992265adc..184ade076e71 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -1468,7 +1468,7 @@ foo?.bar(/* comment */a, }, ], }, - // mixed logical checks are followed and flagged + // mixed binary checks are followed and flagged { code: ` a && From e1c5eee7e343235cfa2982072890928fe305d76b Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 7 Feb 2023 19:21:10 +1030 Subject: [PATCH 09/26] more test cases --- packages/eslint-plugin/package.json | 1 + .../src/rules/prefer-optional-chain.ts | 61 +++------- .../prefer-optional-chain.test.ts | 115 ++++++++++++++++++ 3 files changed, 130 insertions(+), 47 deletions(-) diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index e0d5f4733c7f..a3aab79cc986 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -41,6 +41,7 @@ "generate:configs": "yarn tsx tools/generate-configs.ts", "lint": "nx lint", "test": "jest --coverage", + "test-single": "jest --no-coverage", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 0446e92a2712..6fb7528104f7 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -72,16 +72,17 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { // check for "yoda" style logical: null != x // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- TODO - add ignore IIFE option const { comparedExpression, comparedValue } = (() => { - const comparedValueLeft = getComparisonValueType(node.left); - if (comparedValueLeft) { + // non-yoda checks are by far the most common, so check for them first + const comparedValueRight = getComparisonValueType(operand.right); + if (comparedValueRight) { return { - comparedExpression: operand.right, - comparedValue: comparedValueLeft, + comparedExpression: operand.left, + comparedValue: comparedValueRight, }; } else { return { - comparedExpression: operand.left, - comparedValue: getComparisonValueType(operand.right), + comparedExpression: operand.right, + comparedValue: getComparisonValueType(operand.left), }; } })(); @@ -95,7 +96,7 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { result.push({ type: OperandValidity.Valid, comparedName: comparedExpression.argument, - comparisonType: node.operator.startsWith('!') + comparisonType: operand.operator.startsWith('!') ? NullishComparisonType.NotStrictEqualUndefined : NullishComparisonType.StrictEqualUndefined, node: operand, @@ -103,7 +104,7 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { continue; } - // typeof x === 'string' et al :( + // y === 'undefined' result.push({ type: OperandValidity.Invalid }); continue; } @@ -254,7 +255,7 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { } function getComparisonValueType( - node: TSESTree.Expression, + node: TSESTree.Node, ): ComparisonValueType | null { switch (node.type) { case AST_NODE_TYPES.Literal: @@ -326,6 +327,7 @@ function compareUnknownValues( valueA: unknown, valueB: unknown, ): NodeComparisonResult { + /* istanbul ignore if -- not possible for us to test this - it's just a sanity safeguard */ if (valueA == null || valueB == null) { if (valueA !== valueB) { return NodeComparisonResult.Invalid; @@ -333,6 +335,7 @@ function compareUnknownValues( return NodeComparisonResult.Equal; } + /* istanbul ignore if -- not possible for us to test this - it's just a sanity safeguard */ if (!isValidNode(valueA) || !isValidNode(valueB)) { return NodeComparisonResult.Invalid; } @@ -344,6 +347,7 @@ function compareByVisiting( nodeB: TSESTree.Node, ): NodeComparisonResult.Equal | NodeComparisonResult.Invalid { const currentVisitorKeys = visitorKeys[nodeA.type]; + /* istanbul ignore if -- not possible for us to test this - it's just a sanity safeguard */ if (currentVisitorKeys == null) { // we don't know how to visit this node, so assume it's invalid to avoid false-positives / broken fixers return NodeComparisonResult.Invalid; @@ -365,7 +369,7 @@ function compareByVisiting( const arrayB = nodeBChildOrChildren as unknown[]; const result = compareArrays(arrayA, arrayB); - if (!result) { + if (result !== NodeComparisonResult.Equal) { return NodeComparisonResult.Invalid; } // fallthrough to the next key as the key was "equal" @@ -550,31 +554,6 @@ function compareNodesUncached( return NodeComparisonResult.Invalid; } - case AST_NODE_TYPES.LogicalExpression: { - const nodeBLogical = nodeB as typeof nodeA; - - // TODO - we could handle this, but it's pretty nonsensical and needlessly complicated to do something like - // (x || y) && (x || y).z - // Let's put a pin in this unless someone asks for it - if ( - (nodeA.parent?.type === AST_NODE_TYPES.MemberExpression && - nodeA.parent.object === nodeA) || - nodeA.parent?.type === AST_NODE_TYPES.LogicalExpression - ) { - return NodeComparisonResult.Invalid; - } - - const leftCompare = compareNodes(nodeA.left, nodeBLogical.left); - if (leftCompare !== NodeComparisonResult.Equal) { - return NodeComparisonResult.Invalid; - } - const rightCompare = compareNodes(nodeA.right, nodeBLogical.right); - if (rightCompare === NodeComparisonResult.Equal) { - return NodeComparisonResult.Equal; - } - return NodeComparisonResult.Invalid; - } - case AST_NODE_TYPES.MemberExpression: { const nodeBMember = nodeB as typeof nodeA; @@ -618,14 +597,6 @@ function compareNodesUncached( return compareNodes(nodeA.property, nodeBMember.property); } - - // TODO - we could handle this, but it's pretty nonsensical and needlessly complicated to do something like - // tag`foo${bar}` && tag`foo${bar}`.prop - // Additionally the template tag might return a new value each time. - // Let's put a pin in this unless someone asks for it. - case AST_NODE_TYPES.TaggedTemplateExpression: - return NodeComparisonResult.Invalid; - case AST_NODE_TYPES.TSTemplateLiteralType: case AST_NODE_TYPES.TemplateLiteral: { const nodeBTemplate = nodeB as typeof nodeA; @@ -650,10 +621,6 @@ function compareNodesUncached( return NodeComparisonResult.Invalid; } - // it's syntactically invalid for an instantiation expression to be part of a chain - case AST_NODE_TYPES.TSInstantiationExpression: - return NodeComparisonResult.Invalid; - // these aren't actually valid expressions. // https://github.com/typescript-eslint/typescript-eslint/blob/20d7caee35ab84ae6381fdf04338c9e2b9e2bc48/packages/ast-spec/src/unions/Expression.ts#L37-L43 case AST_NODE_TYPES.ArrayPattern: diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index 184ade076e71..43c5b5eceaaf 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -711,6 +711,21 @@ describe('hand-crafted cases', () => { 'result && this.options.shouldPreserveNodeMaps;', 'foo && fooBar.baz;', 'match && match$1 !== undefined;', + "typeof foo === 'number' && foo.toFixed();", + "foo === 'undefined' && foo.length;", + 'foo == bar && foo.bar == null;', + 'foo === 1 && foo.toFixed();', + // call arguments are considered + 'foo.bar(a) && foo.bar(a, b).baz;', + // type parameters are considered + 'foo.bar() && foo.bar().baz;', + // array elements are considered + '[1, 2].length && [1, 2, 3].length.toFixed();', + noFormat`[1,].length && [1, 2].length.toFixed();`, + // short-circuiting chains are considered + '(foo?.a).b && foo.a.b.c;', + '(foo?.a)() && foo.a().b;', + '(foo?.a)() && foo.a()();', // looks like a chain, but isn't actually a chain - just a pair of strict nullish checks 'foo !== null && foo !== undefined;', "x['y'] !== undefined && x['y'] !== null;", @@ -740,6 +755,28 @@ describe('hand-crafted cases', () => { // Do not handle direct optional chaining on private properties because this TS limitation (https://github.com/microsoft/TypeScript/issues/42734) 'foo && foo.#bar;', '!foo || !foo.#bar;', + // weird non-constant cases are ignored + '({} && {}.toString());', + '[] && [].length;', + '(() => {}) && (() => {}).name;', + '(function () {} && function () {}.name);', + '(class Foo {} && class Foo {}.constructor);', + "new Map().get('a') && new Map().get('a').what;", + { + code: '
&& (
).wtf;', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + { + code: '<> && (<>).wtf;', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + 'foo[x++] && foo[x++].bar;', + 'foo[yield x] && foo[yield x].bar;', + 'a = b && (a = b).wtf;', + // TODO - should we handle this? + '(x || y) != null && (x || y).foo;', + // TODO - should we handle this? + '(await foo) && (await foo).bar;', ], invalid: [ // two errors @@ -879,6 +916,21 @@ describe('hand-crafted cases', () => { }, ], }, + { + code: 'foo && foo[`some long string`] && foo[`some long string`].baz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.[`some long string`]?.baz;', + }, + ], + }, + ], + }, // complex computed properties should be handled correctly { code: 'foo && foo[bar as string] && foo[bar as string].baz;', @@ -925,6 +977,37 @@ describe('hand-crafted cases', () => { }, ], }, + { + code: 'foo && foo.bar(a) && foo.bar(a, b).baz', + output: 'foo?.bar(a) && foo.bar(a, b).baz', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + // type parameters are considered + { + code: 'foo && foo() && foo().bar', + output: 'foo?.()?.bar', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: 'foo && foo() && foo().bar', + output: 'foo?.() && foo().bar', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, // should preserve comments in a call expression { code: noFormat` @@ -1509,6 +1592,38 @@ foo?.bar(/* comment */a, }, ], }, + // yoda checks are flagged + { + code: 'undefined !== foo && null !== foo.bar && foo.bar.baz', + output: 'foo?.bar?.baz', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: "null != foo && 'undefined' !== typeof foo.bar && foo.bar.baz", + output: 'foo?.bar?.baz', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + // await + { + code: '(await foo).bar && (await foo).bar.baz', + output: '(await foo).bar?.baz', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, ] // TODO - remove this once fixer is reimplemented .map(tempRemoveFixerTODO), From 3c387a93717582d8250a902f45316261bdb3cfdd Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 7 Feb 2023 19:59:09 +1030 Subject: [PATCH 10/26] more test cases --- .../src/rules/prefer-optional-chain.ts | 4 ++- .../prefer-optional-chain.test.ts | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 6fb7528104f7..b34ec7e4f206 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -499,7 +499,7 @@ function compareNodesUncached( // we don't want to check the arguments in this case const aSubsetOfB = compareNodes(nodeA, nodeBCall.callee); if (aSubsetOfB !== NodeComparisonResult.Invalid) { - return aSubsetOfB; + return NodeComparisonResult.Subset; } const calleeCompare = compareNodes(nodeA.callee, nodeBCall.callee); @@ -625,6 +625,7 @@ function compareNodesUncached( // https://github.com/typescript-eslint/typescript-eslint/blob/20d7caee35ab84ae6381fdf04338c9e2b9e2bc48/packages/ast-spec/src/unions/Expression.ts#L37-L43 case AST_NODE_TYPES.ArrayPattern: case AST_NODE_TYPES.ObjectPattern: + /* istanbul ignore next */ return NodeComparisonResult.Invalid; // update expression returns a number and also changes the value each time - so it makes no sense to compare the chain @@ -787,6 +788,7 @@ function analyzeChain( return analyzeOrChainOperand; case '??': + /* istanbul ignore next -- previous checks make this unreachable, but keep it for exhaustiveness check */ return null; } })(); diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index 43c5b5eceaaf..cc84819cb78e 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -931,6 +931,21 @@ describe('hand-crafted cases', () => { }, ], }, + { + code: 'foo && foo[`some ${long} string`] && foo[`some ${long} string`].baz;', + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: 'foo?.[`some ${long} string`]?.baz;', + }, + ], + }, + ], + }, // complex computed properties should be handled correctly { code: 'foo && foo[bar as string] && foo[bar as string].baz;', @@ -987,6 +1002,16 @@ describe('hand-crafted cases', () => { }, ], }, + { + code: 'foo() && foo()(bar)', + output: 'foo()?.(bar)', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, // type parameters are considered { code: 'foo && foo() && foo().bar', From 74d0d0b683825afda7f937ec57c1167fa9015d27 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Mon, 17 Apr 2023 14:17:00 +0930 Subject: [PATCH 11/26] lint after rebase --- packages/eslint-plugin/src/rules/prefer-optional-chain.ts | 6 +++--- .../prefer-optional-chain/prefer-optional-chain.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index af46627c034b..e62a6d4e4854 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -69,7 +69,7 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { switch (operand.type) { case AST_NODE_TYPES.BinaryExpression: { // check for "yoda" style logical: null != x - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- TODO - add ignore IIFE option + const { comparedExpression, comparedValue } = (() => { // non-yoda checks are by far the most common, so check for them first const comparedValueRight = getComparisonValueType(operand.right); @@ -521,8 +521,8 @@ function compareNodesUncached( } const typeParamCompare = compareNodes( - nodeA.typeParameters, - nodeBCall.typeParameters, + nodeA.typeArguments, + nodeBCall.typeArguments, ); if (typeParamCompare === NodeComparisonResult.Equal) { return NodeComparisonResult.Equal; diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index cc84819cb78e..50db24afd664 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -756,11 +756,11 @@ describe('hand-crafted cases', () => { 'foo && foo.#bar;', '!foo || !foo.#bar;', // weird non-constant cases are ignored - '({} && {}.toString());', + '({}) && {}.toString();', '[] && [].length;', '(() => {}) && (() => {}).name;', - '(function () {} && function () {}.name);', - '(class Foo {} && class Foo {}.constructor);', + '(function () {}) && function () {}.name;', + '(class Foo {}) && class Foo {}.constructor;', "new Map().get('a') && new Map().get('a').what;", { code: '
&& (
).wtf;', From 74ff6d0fc36fea5c4259e28db218f254d27ea171 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Mon, 17 Apr 2023 16:57:34 +0930 Subject: [PATCH 12/26] make it type-aware --- packages/eslint-plugin/package.json | 2 +- .../src/configs/disable-type-checked.ts | 1 + .../src/rules/prefer-optional-chain.ts | 185 ++++++++++++++++-- .../prefer-optional-chain.test.ts | 144 ++++++++++++-- .../src/referencer/VisitorBase.ts | 2 +- packages/type-utils/package.json | 2 +- packages/typescript-estree/package.json | 2 +- .../src/eslint-utils/getParserServices.ts | 54 +++-- yarn.lock | 13 +- 9 files changed, 352 insertions(+), 53 deletions(-) diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 4498ba190fa6..689a5572cbd7 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -65,7 +65,7 @@ "ignore": "^5.2.0", "natural-compare-lite": "^1.4.0", "semver": "^7.3.7", - "ts-api-utils": "^0.0.44" + "ts-api-utils": "^0.0.46" }, "devDependencies": { "@types/debug": "*", diff --git a/packages/eslint-plugin/src/configs/disable-type-checked.ts b/packages/eslint-plugin/src/configs/disable-type-checked.ts index c03bdbfd9a9c..8518e1cb2620 100644 --- a/packages/eslint-plugin/src/configs/disable-type-checked.ts +++ b/packages/eslint-plugin/src/configs/disable-type-checked.ts @@ -36,6 +36,7 @@ export = { '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/non-nullable-type-assertion-style': 'off', '@typescript-eslint/prefer-includes': 'off', + '@typescript-eslint/prefer-optional-chain': 'off', '@typescript-eslint/prefer-nullish-coalescing': 'off', '@typescript-eslint/prefer-readonly': 'off', '@typescript-eslint/prefer-readonly-parameter-types': 'off', diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index e62a6d4e4854..05f19f54bbca 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -1,12 +1,26 @@ -import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import type { + ParserServicesWithTypeInformation, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import { visitorKeys } from '@typescript-eslint/visitor-keys'; +import { isBooleanLiteralType, unionTypeParts } from 'ts-api-utils'; import * as ts from 'typescript'; import * as util from '../util'; type TMessageIds = 'preferOptionalChain' | 'optionalChainSuggest'; -type TOptions = []; +interface TypeAwareOptions { + checkAny?: boolean; + checkUnknown?: boolean; + checkString?: boolean; + checkNumber?: boolean; + checkBoolean?: boolean; + checkBigInt?: boolean; + requireNullish?: boolean; +} +type TOptions = [TypeAwareOptions]; const enum ComparisonValueType { Null = 'Null', // eslint-disable-line @typescript-eslint/internal/prefer-ast-types-enum @@ -58,7 +72,70 @@ interface InvalidOperand { } type Operand = ValidOperand | InvalidOperand; -function gatherLogicalOperands(node: TSESTree.LogicalExpression): { +function isValidFalseBooleanCheckType( + node: TSESTree.Node, + operator: TSESTree.LogicalExpression['operator'], + checkType: 'true' | 'false', + parserServices: ParserServicesWithTypeInformation, + options: TypeAwareOptions, +): boolean { + const type = parserServices.getTypeAtLocation(node); + const types = unionTypeParts(type); + + const disallowFalseLiteral = + (operator === '||' && checkType === 'false') || + (operator === '&&' && checkType === 'true'); + if (disallowFalseLiteral) { + /* + ``` + declare const x: false | {a: string}; + x && x.a; + !x || x.a; + ``` + + We don't want to consider these two cases because the boolean expression + narrows out the `false` - so converting the chain to `x?.a` would introduce + a build error + */ + if ( + types.some(t => isBooleanLiteralType(t) && t.intrinsicName === 'false') + ) { + return false; + } + } + + const nullishFlags = ts.TypeFlags.Null | ts.TypeFlags.Undefined; + if (options.requireNullish === true) { + return types.some(t => util.isTypeFlagSet(t, nullishFlags)); + } + + let allowedFlags = nullishFlags | ts.TypeFlags.Object; + if (options.checkAny === true) { + allowedFlags |= ts.TypeFlags.Any; + } + if (options.checkUnknown === true) { + allowedFlags |= ts.TypeFlags.Unknown; + } + if (options.checkString === true) { + allowedFlags |= ts.TypeFlags.StringLike; + } + if (options.checkNumber === true) { + allowedFlags |= ts.TypeFlags.NumberLike; + } + if (options.checkBoolean === true) { + allowedFlags |= ts.TypeFlags.BooleanLike; + } + if (options.checkBigInt === true) { + allowedFlags |= ts.TypeFlags.BigIntLike; + } + return types.every(t => util.isTypeFlagSet(t, allowedFlags)); +} + +function gatherLogicalOperands( + node: TSESTree.LogicalExpression, + parserServices: ParserServicesWithTypeInformation, + options: TypeAwareOptions, +): { operands: Operand[]; seenLogicals: Set; } { @@ -169,8 +246,16 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { } case AST_NODE_TYPES.UnaryExpression: - if (operand.operator === '!') { - // TODO(#4820) - use types here to determine if there is a boolean type that might be refined out + if ( + operand.operator === '!' && + isValidFalseBooleanCheckType( + operand.argument, + node.operator, + 'false', + parserServices, + options, + ) + ) { result.push({ type: OperandValidity.Valid, comparedName: operand.argument, @@ -188,13 +273,24 @@ function gatherLogicalOperands(node: TSESTree.LogicalExpression): { continue; default: - // TODO(#4820) - use types here to determine if there is a boolean type that might be refined out - result.push({ - type: OperandValidity.Valid, - comparedName: operand, - comparisonType: NullishComparisonType.Boolean, - node: operand, - }); + if ( + isValidFalseBooleanCheckType( + operand, + node.operator, + 'true', + parserServices, + options, + ) + ) { + result.push({ + type: OperandValidity.Valid, + comparedName: operand, + comparisonType: NullishComparisonType.Boolean, + node: operand, + }); + } else { + result.push({ type: OperandValidity.Invalid }); + } continue; } } @@ -806,6 +902,8 @@ function analyzeChain( start: subChain[0].node.loc.start, end: subChain[subChain.length - 1].node.loc.end, }, + // TODO add an auto fixer if and only if the result type would be unchanged + // else add the fixer as a suggestion fixer }); } @@ -864,6 +962,7 @@ export default util.createRule({ description: 'Enforce using concise optional chain expressions instead of chained logical ands, negated logical ors, or empty objects', recommended: 'strict', + requiresTypeChecking: true, }, hasSuggestions: true, messages: { @@ -871,12 +970,64 @@ export default util.createRule({ "Prefer using an optional chain expression instead, as it's more concise and easier to read.", optionalChainSuggest: 'Change to an optional chain.', }, - schema: [], + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + checkAny: { + type: 'boolean', + description: + 'Check operands that are typed as `any` when inspecting "loose boolean" operands.', + }, + checkUnknown: { + type: 'boolean', + description: + 'Check operands that are typed as `unknown` when inspecting "loose boolean" operands.', + }, + checkString: { + type: 'boolean', + description: + 'Check operands that are typed as `string` when inspecting "loose boolean" operands.', + }, + checkNumber: { + type: 'boolean', + description: + 'Check operands that are typed as `number` when inspecting "loose boolean" operands.', + }, + checkBoolean: { + type: 'boolean', + description: + 'Check operands that are typed as `boolean` when inspecting "loose boolean" operands.', + }, + checkBigInt: { + type: 'boolean', + description: + 'Check operands that are typed as `bigint` when inspecting "loose boolean" operands.', + }, + requireNullish: { + type: 'boolean', + description: + 'Skip operands that are not typed with `null` and/or `undefined` when inspecting "loose boolean" operands.', + }, + }, + }, + ], }, - defaultOptions: [], - create(context) { + defaultOptions: [ + { + checkAny: true, + checkUnknown: true, + checkString: true, + checkNumber: true, + checkBoolean: true, + checkBigInt: true, + requireNullish: false, + }, + ], + create(context, [options]) { const sourceCode = context.getSourceCode(); - const parserServices = util.getParserServices(context, true); + const parserServices = util.getParserServices(context); const seenLogicals = new Set(); @@ -952,7 +1103,7 @@ export default util.createRule({ } const { operands, seenLogicals: newSeenLogicals } = - gatherLogicalOperands(node); + gatherLogicalOperands(node, parserServices, options); for (const logical of newSeenLogicals) { seenLogicals.add(logical); diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index 50db24afd664..0f42d700878f 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -4,17 +4,21 @@ import type { InferOptionsTypeFromRule, } from '../../../src/util'; import type { InvalidTestCase } from '../../RuleTester'; -import { noFormat, RuleTester } from '../../RuleTester'; +import { getFixturesRootDir, noFormat, RuleTester } from '../../RuleTester'; import * as BaseCases from './base-cases'; const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: getFixturesRootDir(), + }, }); function tempRemoveFixerTODO({ output: _, ...invalid -}: InvalidTestCase): InvalidTestCase< +}: InvalidTestCase): InvalidTestCase< InferMessageIdsTypeFromRule, InferOptionsTypeFromRule > { @@ -765,10 +769,12 @@ describe('hand-crafted cases', () => { { code: '
&& (
).wtf;', parserOptions: { ecmaFeatures: { jsx: true } }, + filename: 'react.tsx', }, { code: '<> && (<>).wtf;', parserOptions: { ecmaFeatures: { jsx: true } }, + filename: 'react.tsx', }, 'foo[x++] && foo[x++].bar;', 'foo[yield x] && foo[yield x].bar;', @@ -777,6 +783,98 @@ describe('hand-crafted cases', () => { '(x || y) != null && (x || y).foo;', // TODO - should we handle this? '(await foo) && (await foo).bar;', + { + code: ` + declare const x: string; + x && x.length; + `, + options: [ + { + requireNullish: true, + }, + ], + }, + { + code: ` + declare const x: string | number | boolean | object; + x && x.toString(); + `, + options: [ + { + requireNullish: true, + }, + ], + }, + { + code: ` + declare const x: any; + x && x.length; + `, + options: [ + { + checkAny: false, + }, + ], + }, + { + code: ` + declare const x: bigint; + x && x.length; + `, + options: [ + { + checkBigInt: false, + }, + ], + }, + { + code: ` + declare const x: boolean; + x && x.length; + `, + options: [ + { + checkBoolean: false, + }, + ], + }, + { + code: ` + declare const x: number; + x && x.length; + `, + options: [ + { + checkNumber: false, + }, + ], + }, + { + code: ` + declare const x: string; + x && x.length; + `, + options: [ + { + checkString: false, + }, + ], + }, + { + code: ` + declare const x: unknown; + x && x.length; + `, + options: [ + { + checkUnknown: false, + }, + ], + }, + '(x = {}) && (x.y = true) != null && x.y.toString();', + "('x' as `${'x'}`) && ('x' as `${'x'}`).length;", + '`x` && `x`.length;', + '`x${a}` && `x${a}`.length;', ], invalid: [ // two errors @@ -855,6 +953,7 @@ describe('hand-crafted cases', () => { jsx: true, }, }, + filename: 'react.tsx', }, { code: 'foo && foo.bar(baz => typeof baz);', @@ -1189,6 +1288,7 @@ foo?.bar(/* comment */a, jsx: true, }, }, + filename: 'react.tsx', }, // case with this keyword at the start of expression { @@ -1293,12 +1393,12 @@ foo?.bar(/* comment */a, }, { code: ` - class Foo { - constructor() { - new.target && new.target.length; + class Foo { + constructor() { + new.target && new.target.length; + } } - } - `, + `, output: null, errors: [ { @@ -1307,12 +1407,12 @@ foo?.bar(/* comment */a, { messageId: 'optionalChainSuggest', output: ` - class Foo { - constructor() { - new.target?.length; + class Foo { + constructor() { + new.target?.length; + } } - } - `, + `, }, ], }, @@ -1617,6 +1717,26 @@ foo?.bar(/* comment */a, }, ], }, + { + code: ` + !a || + a.b == null || + a.b.c === null || + a.b.c === undefined || + a.b.c.d == null || + a.b.c.d.e === null || + a.b.c.d.e.f === undefined || + typeof a.b.c.d.e.f.g === 'undefined' || + a.b.c.d.e.f.g.h + `, + output: 'a?.b?.c?.d?.e?.f?.g?.h', + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, // yoda checks are flagged { code: 'undefined !== foo && null !== foo.bar && foo.bar.baz', diff --git a/packages/scope-manager/src/referencer/VisitorBase.ts b/packages/scope-manager/src/referencer/VisitorBase.ts index 3432f35f5e2f..e21bfc030a7f 100644 --- a/packages/scope-manager/src/referencer/VisitorBase.ts +++ b/packages/scope-manager/src/referencer/VisitorBase.ts @@ -67,7 +67,7 @@ abstract class VisitorBase { * Dispatching node. */ visit(node: TSESTree.Node | null | undefined): void { - if (node == null || node.type == null) { + if (node?.type == null) { return; } diff --git a/packages/type-utils/package.json b/packages/type-utils/package.json index b3fd65270240..9ef0297ce637 100644 --- a/packages/type-utils/package.json +++ b/packages/type-utils/package.json @@ -48,7 +48,7 @@ "@typescript-eslint/typescript-estree": "5.58.0", "@typescript-eslint/utils": "5.58.0", "debug": "^4.3.4", - "ts-api-utils": "^0.0.44" + "ts-api-utils": "^0.0.46" }, "devDependencies": { "@typescript-eslint/parser": "5.58.0", diff --git a/packages/typescript-estree/package.json b/packages/typescript-estree/package.json index 0ed646d735a3..45bb74af13a3 100644 --- a/packages/typescript-estree/package.json +++ b/packages/typescript-estree/package.json @@ -58,7 +58,7 @@ "globby": "^11.1.0", "is-glob": "^4.0.3", "semver": "^7.3.7", - "ts-api-utils": "^0.0.39" + "ts-api-utils": "^0.0.46" }, "devDependencies": { "@babel/code-frame": "*", diff --git a/packages/utils/src/eslint-utils/getParserServices.ts b/packages/utils/src/eslint-utils/getParserServices.ts index d0e6a9eb3944..4b65afe3cf66 100644 --- a/packages/utils/src/eslint-utils/getParserServices.ts +++ b/packages/utils/src/eslint-utils/getParserServices.ts @@ -7,21 +7,54 @@ import type { const ERROR_MESSAGE = 'You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.'; -type GetParserServicesResult = T extends true - ? ParserServices - : ParserServicesWithTypeInformation; - /** - * Try to retrieve typescript parser service from context + * Try to retrieve type-aware parser service from context. + * This **_will_** throw if it is not available. + */ +function getParserServices< + TMessageIds extends string, + TOptions extends readonly unknown[], +>( + context: Readonly>, +): ParserServicesWithTypeInformation; +/** + * Try to retrieve type-aware parser service from context. + * This **_will_** throw if it is not available. */ function getParserServices< TMessageIds extends string, TOptions extends readonly unknown[], - TAllowWithoutFullTypeInformation extends boolean = false, >( context: Readonly>, - allowWithoutFullTypeInformation: TAllowWithoutFullTypeInformation = false as TAllowWithoutFullTypeInformation, -): GetParserServicesResult { + allowWithoutFullTypeInformation: false, +): ParserServicesWithTypeInformation; +/** + * Try to retrieve type-aware parser service from context. + * This **_will not_** throw if it is not available. + */ +function getParserServices< + TMessageIds extends string, + TOptions extends readonly unknown[], +>( + context: Readonly>, + allowWithoutFullTypeInformation: true, +): ParserServices; +/** + * Try to retrieve type-aware parser service from context. + * This may or may not throw if it is not available, depending on if `allowWithoutFullTypeInformation` is `true` + */ +function getParserServices< + TMessageIds extends string, + TOptions extends readonly unknown[], +>( + context: Readonly>, + allowWithoutFullTypeInformation: boolean, +): ParserServices; + +function getParserServices( + context: Readonly>, + allowWithoutFullTypeInformation = false, +): ParserServices { // This check is unnecessary if the user is using the latest version of our parser. // // However the world isn't perfect: @@ -33,8 +66,7 @@ function getParserServices< // This check allows us to handle bad user setups whilst providing a nice user-facing // error message explaining the problem. if ( - context.parserServices == null || - context.parserServices.esTreeNodeToTSNodeMap == null || + context.parserServices?.esTreeNodeToTSNodeMap == null || context.parserServices.tsNodeToESTreeNodeMap == null ) { throw new Error(ERROR_MESSAGE); @@ -49,7 +81,7 @@ function getParserServices< throw new Error(ERROR_MESSAGE); } - return context.parserServices as GetParserServicesResult; + return context.parserServices; } export { getParserServices }; diff --git a/yarn.lock b/yarn.lock index 49b40829268e..9d1694089252 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14228,15 +14228,10 @@ trough@^1.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406" integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA== -ts-api-utils@^0.0.39: - version "0.0.39" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-0.0.39.tgz#cd3d2dd88a68cbc8259cf9944fbd4dbd19f1d69c" - integrity sha512-JSEMbhv2bJCgJUhN/6y58Om6k7rmZ7BwJsklWwv0srfMc7HkhnfHA1sGpGltS6VSTMT4PVEqj/IVsCAPcU1l/g== - -ts-api-utils@^0.0.44: - version "0.0.44" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-0.0.44.tgz#4e6582ecad2fa141cc31d62f22ac590a5ff6eb05" - integrity sha512-pYeAeynJqwgxewln4Ok+nVtiYqhfQW3MI2VFB+lLmhuf1fGjAJ5fSOcrqjwD6+lZjeGpMdVg4vKQsPtI0Mlikg== +ts-api-utils@^0.0.46: + version "0.0.46" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-0.0.46.tgz#4458cb3f8fe2c68409043bd3f9d2fc1c6cf155ae" + integrity sha512-YKJeSx39n0mMk+hrpyHKyTgxA3s7Pz/j1cXYR+t8HcwwZupzOR5xDGKnOEw3gmLaUeFUQt3FJD39AH9Ajn/mdA== ts-essentials@^2.0.3: version "2.0.12" From 2f6666437676c1b1b5a30a43a7293a96a8cd517b Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Thu, 27 Apr 2023 16:37:50 +0930 Subject: [PATCH 13/26] update configs --- packages/eslint-plugin/src/configs/disable-type-checked.ts | 2 +- packages/eslint-plugin/src/configs/stylistic.ts | 1 - packages/eslint-plugin/src/rules/prefer-optional-chain.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin/src/configs/disable-type-checked.ts b/packages/eslint-plugin/src/configs/disable-type-checked.ts index 8518e1cb2620..38a7ffd079d8 100644 --- a/packages/eslint-plugin/src/configs/disable-type-checked.ts +++ b/packages/eslint-plugin/src/configs/disable-type-checked.ts @@ -36,8 +36,8 @@ export = { '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/non-nullable-type-assertion-style': 'off', '@typescript-eslint/prefer-includes': 'off', - '@typescript-eslint/prefer-optional-chain': 'off', '@typescript-eslint/prefer-nullish-coalescing': 'off', + '@typescript-eslint/prefer-optional-chain': 'off', '@typescript-eslint/prefer-readonly': 'off', '@typescript-eslint/prefer-readonly-parameter-types': 'off', '@typescript-eslint/prefer-reduce-type-parameter': 'off', diff --git a/packages/eslint-plugin/src/configs/stylistic.ts b/packages/eslint-plugin/src/configs/stylistic.ts index ed5ce3ded8c9..d88f10090eb3 100644 --- a/packages/eslint-plugin/src/configs/stylistic.ts +++ b/packages/eslint-plugin/src/configs/stylistic.ts @@ -24,7 +24,6 @@ export = { '@typescript-eslint/prefer-for-of': 'error', '@typescript-eslint/prefer-function-type': 'error', '@typescript-eslint/prefer-namespace-keyword': 'error', - '@typescript-eslint/prefer-optional-chain': 'error', '@typescript-eslint/sort-type-constituents': 'error', }, }; diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 05f19f54bbca..68242e1eb900 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -961,7 +961,7 @@ export default util.createRule({ docs: { description: 'Enforce using concise optional chain expressions instead of chained logical ands, negated logical ors, or empty objects', - recommended: 'strict', + recommended: 'stylistic', requiresTypeChecking: true, }, hasSuggestions: true, From 1b17c93266804ca48a94717b350803e9461ec59e Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Thu, 27 Apr 2023 16:51:05 +0930 Subject: [PATCH 14/26] update docs --- .../docs/rules/prefer-optional-chain.md | 176 +++++++++++++++++- 1 file changed, 173 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/prefer-optional-chain.md b/packages/eslint-plugin/docs/rules/prefer-optional-chain.md index 4a9ada8b08ff..2e5c73194681 100644 --- a/packages/eslint-plugin/docs/rules/prefer-optional-chain.md +++ b/packages/eslint-plugin/docs/rules/prefer-optional-chain.md @@ -57,9 +57,179 @@ foo?.a?.b?.c?.d?.e; -:::note -There are a few edge cases where this rule will false positive. Use your best judgement when evaluating reported errors. -::: +## Options + +In the context of the descriptions below a "loose boolean" operand is any operand that implicitly coerces the value to a boolean. +Specifically the argument of the not operator (`!loose`) or a bare value in a logical expression (`loose && looser`). + +### `checkAny` + +When this option is `true` the rule will check operands that are typed as `any` when inspecting "loose boolean" operands. + + + +#### ❌ Incorrect for `checkAny: true` + +```ts +declare const thing: any; + +thing && thing.toString(); +``` + +#### ✅ Correct for `checkAny: false` + +```ts +declare const thing: any; + +thing && thing.toString(); +``` + + + +### `checkUnknown` + +When this option is `true` the rule will check operands that are typed as `unknown` when inspecting "loose boolean" operands. + + + +#### ❌ Incorrect for `checkUnknown: true` + +```ts +declare const thing: unknown; + +thing && thing.toString(); +``` + +#### ✅ Correct for `checkUnknown: false` + +```ts +declare const thing: unknown; + +thing && thing.toString(); +``` + + + +### `checkString` + +When this option is `true` the rule will check operands that are typed as `string` when inspecting "loose boolean" operands. + + + +#### ❌ Incorrect for `checkString: true` + +```ts +declare const thing: string; + +thing && thing.toString(); +``` + +#### ✅ Correct for `checkString: false` + +```ts +declare const thing: string; + +thing && thing.toString(); +``` + + + +### `checkNumber` + +When this option is `true` the rule will check operands that are typed as `number` when inspecting "loose boolean" operands. + + + +#### ❌ Incorrect for `checkNumber: true` + +```ts +declare const thing: number; + +thing && thing.toString(); +``` + +#### ✅ Correct for `checkNumber: false` + +```ts +declare const thing: number; + +thing && thing.toString(); +``` + + + +### `checkBoolean` + +When this option is `true` the rule will check operands that are typed as `boolean` when inspecting "loose boolean" operands. + + + +#### ❌ Incorrect for `checkBoolean: true` + +```ts +declare const thing: boolean; + +thing && thing.toString(); +``` + +#### ✅ Correct for `checkBoolean: false` + +```ts +declare const thing: boolean; + +thing && thing.toString(); +``` + + + +### `checkBigInt` + +When this option is `true` the rule will check operands that are typed as `bigint` when inspecting "loose boolean" operands. + + + +#### ❌ Incorrect for `checkBigInt: true` + +```ts +declare const thing: bigint; + +thing && thing.toString(); +``` + +#### ✅ Correct for `checkBigInt: false` + +```ts +declare const thing: bigint; + +thing && thing.toString(); +``` + + + +### `requireNullish` + +When this option is `true` the rule will skip operands that are not typed with `null` and/or `undefined` when inspecting "loose boolean" operands. + + + +#### ❌ Incorrect for `requireNullish: true` + +```ts +declare const thing1: string | null; +thing1 && thing1.toString(); +``` + +#### ✅ Correct for `requireNullish: true` + +```ts +declare const thing1: string | null; +thing1?.toString(); + +declare const thing2: string; +thing2 && thing2.toString(); +``` + + ## When Not To Use It From f45a30fe5210d4db28d730a518cb30bd3f5a7751 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Thu, 27 Apr 2023 17:08:54 +0930 Subject: [PATCH 15/26] schema snap --- .../prefer-optional-chain.shot | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin/tests/schema-snapshots/prefer-optional-chain.shot b/packages/eslint-plugin/tests/schema-snapshots/prefer-optional-chain.shot index 4f5b7623fd8e..1dbaf95653b9 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/prefer-optional-chain.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/prefer-optional-chain.shot @@ -4,11 +4,63 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos " # SCHEMA: -[] +[ + { + "additionalProperties": false, + "properties": { + "checkAny": { + "description": "Check operands that are typed as \`any\` when inspecting \\"loose boolean\\" operands.", + "type": "boolean" + }, + "checkBigInt": { + "description": "Check operands that are typed as \`bigint\` when inspecting \\"loose boolean\\" operands.", + "type": "boolean" + }, + "checkBoolean": { + "description": "Check operands that are typed as \`boolean\` when inspecting \\"loose boolean\\" operands.", + "type": "boolean" + }, + "checkNumber": { + "description": "Check operands that are typed as \`number\` when inspecting \\"loose boolean\\" operands.", + "type": "boolean" + }, + "checkString": { + "description": "Check operands that are typed as \`string\` when inspecting \\"loose boolean\\" operands.", + "type": "boolean" + }, + "checkUnknown": { + "description": "Check operands that are typed as \`unknown\` when inspecting \\"loose boolean\\" operands.", + "type": "boolean" + }, + "requireNullish": { + "description": "Skip operands that are not typed with \`null\` and/or \`undefined\` when inspecting \\"loose boolean\\" operands.", + "type": "boolean" + } + }, + "type": "object" + } +] # TYPES: -/** No options declared */ -type Options = [];" +type Options = [ + { + /** Check operands that are typed as \`any\` when inspecting "loose boolean" operands. */ + checkAny?: boolean; + /** Check operands that are typed as \`bigint\` when inspecting "loose boolean" operands. */ + checkBigInt?: boolean; + /** Check operands that are typed as \`boolean\` when inspecting "loose boolean" operands. */ + checkBoolean?: boolean; + /** Check operands that are typed as \`number\` when inspecting "loose boolean" operands. */ + checkNumber?: boolean; + /** Check operands that are typed as \`string\` when inspecting "loose boolean" operands. */ + checkString?: boolean; + /** Check operands that are typed as \`unknown\` when inspecting "loose boolean" operands. */ + checkUnknown?: boolean; + /** Skip operands that are not typed with \`null\` and/or \`undefined\` when inspecting "loose boolean" operands. */ + requireNullish?: boolean; + }, +]; +" `; From d90a7c5033d7643d170d4cd361319e00477922de Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 6 May 2023 19:06:34 +0800 Subject: [PATCH 16/26] WIP add fixer back --- .cspell.json | 1 + .../src/rules/prefer-optional-chain.ts | 300 ++++++++++++++++-- .../prefer-optional-chain.test.ts | 237 +++++++------- 3 files changed, 395 insertions(+), 143 deletions(-) diff --git a/.cspell.json b/.cspell.json index 044fb2b43420..82164063c3ef 100644 --- a/.cspell.json +++ b/.cspell.json @@ -81,6 +81,7 @@ "esquery", "esrecurse", "estree", + "falsey", "falsiness", "globby", "IDE's", diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 130ae86c3bf1..9429cd924408 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -1,17 +1,29 @@ import type { ParserServicesWithTypeInformation, - TSESLint, TSESTree, } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import type { + ReportDescriptor, + ReportFixFunction, + RuleContext, + RuleFix, + SourceCode, +} from '@typescript-eslint/utils/ts-eslint'; import { visitorKeys } from '@typescript-eslint/visitor-keys'; -import { isBooleanLiteralType, unionTypeParts } from 'ts-api-utils'; +import { + isBigIntLiteralType, + isBooleanLiteralType, + isNumberLiteralType, + isStringLiteralType, + unionTypeParts, +} from 'ts-api-utils'; import * as ts from 'typescript'; import * as util from '../util'; export type TMessageIds = 'preferOptionalChain' | 'optionalChainSuggest'; -interface TypeAwareOptions { +interface Options { checkAny?: boolean; checkUnknown?: boolean; checkString?: boolean; @@ -19,8 +31,9 @@ interface TypeAwareOptions { checkBoolean?: boolean; checkBigInt?: boolean; requireNullish?: boolean; + allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing?: boolean; } -export type TOptions = [TypeAwareOptions]; +export type TOptions = [Options]; const enum ComparisonValueType { Null = 'Null', // eslint-disable-line @typescript-eslint/internal/prefer-ast-types-enum @@ -72,20 +85,21 @@ interface InvalidOperand { } type Operand = ValidOperand | InvalidOperand; +const NULLISH_FLAGS = ts.TypeFlags.Null | ts.TypeFlags.Undefined; function isValidFalseBooleanCheckType( node: TSESTree.Node, operator: TSESTree.LogicalExpression['operator'], checkType: 'true' | 'false', parserServices: ParserServicesWithTypeInformation, - options: TypeAwareOptions, + options: Options, ): boolean { const type = parserServices.getTypeAtLocation(node); const types = unionTypeParts(type); - const disallowFalseLiteral = + const disallowFalseyLiteral = (operator === '||' && checkType === 'false') || (operator === '&&' && checkType === 'true'); - if (disallowFalseLiteral) { + if (disallowFalseyLiteral) { /* ``` declare const x: false | {a: string}; @@ -94,22 +108,24 @@ function isValidFalseBooleanCheckType( ``` We don't want to consider these two cases because the boolean expression - narrows out the `false` - so converting the chain to `x?.a` would introduce - a build error + narrows out the non-nullish falsey cases - so converting the chain to `x?.a` + would introduce a build error */ if ( - types.some(t => isBooleanLiteralType(t) && t.intrinsicName === 'false') + types.some(t => isBooleanLiteralType(t) && t.intrinsicName === 'false') || + types.some(t => isStringLiteralType(t) && t.value === '') || + types.some(t => isNumberLiteralType(t) && t.value === 0) || + types.some(t => isBigIntLiteralType(t) && t.value.base10Value === '0') ) { return false; } } - const nullishFlags = ts.TypeFlags.Null | ts.TypeFlags.Undefined; if (options.requireNullish === true) { - return types.some(t => util.isTypeFlagSet(t, nullishFlags)); + return types.some(t => util.isTypeFlagSet(t, NULLISH_FLAGS)); } - let allowedFlags = nullishFlags | ts.TypeFlags.Object; + let allowedFlags = NULLISH_FLAGS | ts.TypeFlags.Object; if (options.checkAny === true) { allowedFlags |= ts.TypeFlags.Any; } @@ -134,7 +150,7 @@ function isValidFalseBooleanCheckType( function gatherLogicalOperands( node: TSESTree.LogicalExpression, parserServices: ParserServicesWithTypeInformation, - options: TypeAwareOptions, + options: Options, ): { operands: Operand[]; seenLogicals: Set; @@ -699,13 +715,13 @@ function compareNodesUncached( nodeA.quasis.length === nodeBTemplate.quasis.length && nodeA.quasis.every((elA, idx) => { const elB = nodeBTemplate.quasis[idx]; - return elA === elB; + return elA.value.cooked === elB.value.cooked; }); if (!areQuasisEqual) { return NodeComparisonResult.Invalid; } - return compareNodes(nodeA, nodeBTemplate); + return NodeComparisonResult.Equal; } case AST_NODE_TYPES.TemplateElement: { @@ -864,8 +880,227 @@ const analyzeOrChainOperand: OperandAnalyzer = (operand, index, chain) => { } }; +function getFixer( + sourceCode: SourceCode, + parserServices: ParserServicesWithTypeInformation, + options: Options, + chain: ValidOperand[], +): + | { + suggest: NonNullable['suggest']>; + } + | { + fix: NonNullable['fix']>; + } { + const lastOperand = chain[chain.length - 1]; + + let useSuggestionFixer: boolean; + if ( + options.allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing === + true + ) { + // user has opted-in to the unsafe behavior + useSuggestionFixer = false; + } else { + // optional chain specifically will union `undefined` into the final type + // so we need to make sure that there is at least one operand that includes + // `undefined`, or else we're going to change the final type - which is + // unsafe and might cause downstream type errors. + + if ( + lastOperand.comparisonType !== NullishComparisonType.Boolean && + lastOperand.comparisonType !== NullishComparisonType.NotBoolean + ) { + // we know the last operand is an equality check - so the change in types + // DOES NOT matter and will not change the runtime result or cause a type + // check error + useSuggestionFixer = false; + } else { + useSuggestionFixer = true; + + // TODO - we could further reduce the false-positive rate of this check by + // checking for cases where the change in types don't matter like + // the test location of an if/while/etc statement. + // but it's quite complex to do this without false-negatives, so + // for now we'll just be over-eager with our matching. + for (const operand of chain) { + const types = unionTypeParts( + parserServices.getTypeAtLocation(operand.node), + ); + if (types.some(t => util.isTypeFlagSet(t, ts.TypeFlags.Undefined))) { + useSuggestionFixer = false; + break; + } + } + } + } + + // In its most naive form we could just slap `?.` for every single part of the + // chain. However this would be undesirable because it'd create unnecessary + // conditions in the user's code where there were none before - and it would + // cause errors with rules like our `no-unnecessary-condition`. + // + // Instead we want to include the minimum number of `?.` required to correctly + // unify the code into a single chain. Naively you might think that we can + // just take the final operand add `?.` after the locations from the previous + // operands - however this won't be correct either because earlier operands + // can include a necessary `?.` that's not needed or included in a later + // operand. + // + // So instead what we need to do is to start at the first operand and + // iteratively diff it against the next operand, and add the difference to the + // first operand. + // + // eg + // `foo && foo.bar && foo.bar.baz?.bam && foo.bar.baz.bam()` + // 1) `foo` + // 2) diff(`foo`, `foo.bar`) = `.bar` + // 3) result = `foo?.bar` + // 4) diff(`foo.bar`, `foo.bar.baz?.bam`) = `.baz?.bam` + // 5) result = `foo?.bar?.baz?.bam` + // 6) diff(`foo.bar.baz?.bam`, `foo.bar.baz.bam()`) = `()` + // 7) result = `foo?.bar?.baz?.bam?.()` + + let current = flattenChainExpression(sourceCode, chain[0].comparedName); + const parts = [...current]; + + for (let i = 1; i < chain.length; i += 1) { + const nextOperand = flattenChainExpression( + sourceCode, + chain[i].comparedName, + ); + const diff = nextOperand.slice(parts.length); + // we need to make the first operand of the diff optional so it matches the + // logic before merging + // foo.bar && foo.bar.baz + // diff = .baz + // result = foo.bar?.baz + if (diff.length > 0) { + diff[0].optional = true; + parts.push(...diff); + } + current = nextOperand; + } + + let newCode = parts + .map(part => { + let str = ''; + if (part.optional) { + str += '?.'; + } else if (part.requiresDot) { + str += '.'; + } + str += part.text; + return str; + }) + .join(''); + + if (lastOperand.node.type === AST_NODE_TYPES.BinaryExpression) { + // retain the ending comparison for cases like + // x && x.a != null + newCode += + ' ' + + lastOperand.node.operator + + ' ' + + sourceCode.getText(lastOperand.node.right); + } + + const fix: ReportFixFunction = fixer => + fixer.replaceTextRange( + [chain[0].node.range[0], lastOperand.node.range[1]], + newCode, + ); + + return useSuggestionFixer + ? { suggest: [{ fix, messageId: 'optionalChainSuggest' }] } + : { fix }; + + interface FlattenedChain { + text: string; + optional: boolean; + requiresDot: boolean; + } + function flattenChainExpression( + sourceCode: SourceCode, + node: TSESTree.Node, + ): FlattenedChain[] { + switch (node.type) { + case AST_NODE_TYPES.ChainExpression: + return flattenChainExpression(sourceCode, node.expression); + + case AST_NODE_TYPES.CallExpression: { + const argumentsText = (() => { + const closingParenToken = util.nullThrows( + sourceCode.getLastToken(node), + util.NullThrowsReasons.MissingToken( + 'closing parenthesis', + node.type, + ), + ); + const openingParenToken = util.nullThrows( + sourceCode.getFirstTokenBetween( + node.typeArguments ?? node.callee, + closingParenToken, + util.isOpeningParenToken, + ), + util.NullThrowsReasons.MissingToken( + 'opening parenthesis', + node.type, + ), + ); + return sourceCode.text.substring( + openingParenToken.range[0], + closingParenToken.range[1], + ); + })(); + + const typeArgumentsText = (() => { + if (node.typeArguments == null) { + return ''; + } + + return sourceCode.getText(node.typeArguments); + })(); + + return [ + ...flattenChainExpression(sourceCode, node.callee), + { + text: typeArgumentsText + argumentsText, + optional: node.optional, + requiresDot: false, + }, + ]; + } + + case AST_NODE_TYPES.MemberExpression: { + const propertyText = sourceCode.getText(node.property); + return [ + ...flattenChainExpression(sourceCode, node.object), + { + text: node.computed ? `[${propertyText}]` : propertyText, + optional: node.optional, + requiresDot: !node.computed, + }, + ]; + } + + default: + return [ + { + text: sourceCode.getText(node), + optional: false, + requiresDot: false, + }, + ]; + } + } +} + function analyzeChain( - context: TSESLint.RuleContext, + context: RuleContext, + sourceCode: SourceCode, + parserServices: ParserServicesWithTypeInformation, + options: Options, operator: TSESTree.LogicalExpression['operator'], chain: ValidOperand[], ): void { @@ -902,8 +1137,7 @@ function analyzeChain( start: subChain[0].node.loc.start, end: subChain[subChain.length - 1].node.loc.end, }, - // TODO add an auto fixer if and only if the result type would be unchanged - // else add the fixer as a suggestion fixer + ...getFixer(sourceCode, parserServices, options, subChain), }); } @@ -964,6 +1198,7 @@ export default util.createRule({ recommended: 'stylistic', requiresTypeChecking: true, }, + fixable: 'code', hasSuggestions: true, messages: { preferOptionalChain: @@ -1010,6 +1245,11 @@ export default util.createRule({ description: 'Skip operands that are not typed with `null` and/or `undefined` when inspecting "loose boolean" operands.', }, + allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing: { + type: 'boolean', + description: + 'Allow autofixers that will change the return type of the expression. This option is considered unsafe as it may break the build.', + }, }, }, ], @@ -1073,7 +1313,7 @@ export default util.createRule({ suggest: [ { messageId: 'optionalChainSuggest', - fix: (fixer): TSESLint.RuleFix => { + fix: (fixer): RuleFix => { const leftNodeText = sourceCode.getText(leftNode); // Any node that is made of an operator with higher or equal precedence, const maybeWrappedLeftNode = isLeftSideLowerPrecedence() @@ -1112,7 +1352,14 @@ export default util.createRule({ let currentChain: ValidOperand[] = []; for (const operand of operands) { if (operand.type === OperandValidity.Invalid) { - analyzeChain(context, node.operator, currentChain); + analyzeChain( + context, + sourceCode, + parserServices, + options, + node.operator, + currentChain, + ); currentChain = []; } else { currentChain.push(operand); @@ -1120,7 +1367,16 @@ export default util.createRule({ } // make sure to check whatever's left - analyzeChain(context, node.operator, currentChain); + if (currentChain.length > 0) { + analyzeChain( + context, + sourceCode, + parserServices, + options, + node.operator, + currentChain, + ); + } }, }; }, diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index 73b6c07dad50..4ff40e91b739 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -1,10 +1,5 @@ -import type { InvalidTestCase } from '@typescript-eslint/rule-tester'; import { noFormat, RuleTester } from '@typescript-eslint/rule-tester'; -import type { - TMessageIds, - TOptions, -} from '../../../src/rules/prefer-optional-chain'; import rule from '../../../src/rules/prefer-optional-chain'; import { getFixturesRootDir } from '../../RuleTester'; import * as BaseCases from './base-cases'; @@ -17,18 +12,6 @@ const ruleTester = new RuleTester({ }, }); -function tempRemoveFixerTODO({ - output: _, - ...invalid -}: InvalidTestCase): InvalidTestCase { - return { - ...invalid, - output: null, - // @ts-expect-error -- TODO - errors: invalid.errors.map(({ suggestions: _, ...err }) => err), - }; -} - describe('|| {}', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [ @@ -874,6 +857,40 @@ describe('hand-crafted cases', () => { "('x' as `${'x'}`) && ('x' as `${'x'}`).length;", '`x` && `x`.length;', '`x${a}` && `x${a}`.length;', + + // falsey unions should be ignored + ` + declare const x: false | { a: string }; + x && x.a; + `, + ` + declare const x: false | { a: string }; + !x || x.a; + `, + ` + declare const x: '' | { a: string }; + x && x.a; + `, + ` + declare const x: '' | { a: string }; + !x || x.a; + `, + ` + declare const x: 0 | { a: string }; + x && x.a; + `, + ` + declare const x: 0 | { a: string }; + !x || x.a; + `, + ` + declare const x: 0n | { a: string }; + x && x.a; + `, + ` + declare const x: 0n | { a: string }; + !x || x.a; + `, ], invalid: [ // two errors @@ -1091,7 +1108,7 @@ describe('hand-crafted cases', () => { ], }, { - code: 'foo && foo.bar(a) && foo.bar(a, b).baz', + code: 'foo && foo.bar(a) && foo.bar(a, b).baz;', output: 'foo?.bar(a) && foo.bar(a, b).baz', errors: [ { @@ -1101,7 +1118,7 @@ describe('hand-crafted cases', () => { ], }, { - code: 'foo() && foo()(bar)', + code: 'foo() && foo()(bar);', output: 'foo()?.(bar)', errors: [ { @@ -1112,7 +1129,7 @@ describe('hand-crafted cases', () => { }, // type parameters are considered { - code: 'foo && foo() && foo().bar', + code: 'foo && foo() && foo().bar;', output: 'foo?.()?.bar', errors: [ { @@ -1122,7 +1139,7 @@ describe('hand-crafted cases', () => { ], }, { - code: 'foo && foo() && foo().bar', + code: 'foo && foo() && foo().bar;', output: 'foo?.() && foo().bar', errors: [ { @@ -1137,7 +1154,7 @@ describe('hand-crafted cases', () => { foo && foo.bar(/* comment */a, // comment2 b, ); - `, + `, output: null, errors: [ { @@ -1504,7 +1521,7 @@ foo?.bar(/* comment */a, ], }, { - code: 'foo !== null && foo.bar !== null', + code: 'foo !== null && foo.bar !== null;', output: null, errors: [ { @@ -1512,14 +1529,14 @@ foo?.bar(/* comment */a, suggestions: [ { messageId: 'optionalChainSuggest', - output: 'foo?.bar != null', + output: 'foo?.bar != null;', }, ], }, ], }, { - code: 'foo != null && foo.bar != null', + code: 'foo != null && foo.bar != null;', output: null, errors: [ { @@ -1527,14 +1544,14 @@ foo?.bar(/* comment */a, suggestions: [ { messageId: 'optionalChainSuggest', - output: 'foo?.bar != null', + output: 'foo?.bar != null;', }, ], }, ], }, { - code: 'foo != null && foo.bar !== null', + code: 'foo != null && foo.bar !== null;', output: null, errors: [ { @@ -1542,14 +1559,14 @@ foo?.bar(/* comment */a, suggestions: [ { messageId: 'optionalChainSuggest', - output: 'foo?.bar != null', + output: 'foo?.bar != null;', }, ], }, ], }, { - code: 'foo !== null && foo.bar != null', + code: 'foo !== null && foo.bar != null;', output: null, errors: [ { @@ -1557,7 +1574,7 @@ foo?.bar(/* comment */a, suggestions: [ { messageId: 'optionalChainSuggest', - output: 'foo?.bar != null', + output: 'foo?.bar != null;', }, ], }, @@ -1565,8 +1582,8 @@ foo?.bar(/* comment */a, }, // https://github.com/typescript-eslint/typescript-eslint/issues/6332 { - code: 'unrelated != null && foo != null && foo.bar != null', - output: 'unrelated != null && foo?.bar != null', + code: 'unrelated != null && foo != null && foo.bar != null;', + output: 'unrelated != null && foo?.bar != null;', errors: [ { messageId: 'preferOptionalChain', @@ -1575,8 +1592,8 @@ foo?.bar(/* comment */a, ], }, { - code: 'unrelated1 != null && unrelated2 != null && foo != null && foo.bar != null', - output: 'unrelated1 != null && unrelated2 != null && foo?.bar != null', + code: 'unrelated1 != null && unrelated2 != null && foo != null && foo.bar != null;', + output: 'unrelated1 != null && unrelated2 != null && foo?.bar != null;', errors: [ { messageId: 'preferOptionalChain', @@ -1586,8 +1603,8 @@ foo?.bar(/* comment */a, }, // https://github.com/typescript-eslint/typescript-eslint/issues/1461 { - code: 'foo1 != null && foo1.bar != null && foo2 != null && foo2.bar != null', - output: 'foo1?.bar != null && foo2?.bar != null', + code: 'foo1 != null && foo1.bar != null && foo2 != null && foo2.bar != null;', + output: 'foo1?.bar != null && foo2?.bar != null;', errors: [ { messageId: 'preferOptionalChain', @@ -1600,8 +1617,8 @@ foo?.bar(/* comment */a, ], }, { - code: 'foo && foo.a && bar && bar.a', - output: 'foo?.a && bar?.a', + code: 'foo && foo.a && bar && bar.a;', + output: 'foo?.a && bar?.a;', errors: [ { messageId: 'preferOptionalChain', @@ -1615,8 +1632,8 @@ foo?.bar(/* comment */a, }, // randomly placed optional chain tokens are ignored { - code: 'foo.bar.baz != null && foo?.bar?.baz.bam != null', - output: 'foo.bar.baz?.bam != null', + code: 'foo.bar.baz != null && foo?.bar?.baz.bam != null;', + output: 'foo.bar.baz?.bam != null;', errors: [ { messageId: 'preferOptionalChain', @@ -1625,7 +1642,7 @@ foo?.bar(/* comment */a, ], }, { - code: 'foo?.bar.baz != null && foo.bar?.baz.bam != null', + code: 'foo?.bar.baz != null && foo.bar?.baz.bam != null;', output: 'foo?.bar.baz?.bam != null', errors: [ { @@ -1635,7 +1652,7 @@ foo?.bar(/* comment */a, ], }, { - code: 'foo?.bar?.baz != null && foo.bar.baz.bam != null', + code: 'foo?.bar?.baz != null && foo.bar.baz.bam != null;', output: 'foo?.bar?.baz?.bam != null', errors: [ { @@ -1646,7 +1663,7 @@ foo?.bar(/* comment */a, }, // randomly placed non-null assertions are ignored { - code: 'foo.bar.baz != null && foo!.bar!.baz.bam != null', + code: 'foo.bar.baz != null && foo!.bar!.baz.bam != null;', output: 'foo.bar.baz?.bam != null', errors: [ { @@ -1656,7 +1673,7 @@ foo?.bar(/* comment */a, ], }, { - code: 'foo!.bar.baz != null && foo.bar!.baz.bam != null', + code: 'foo!.bar.baz != null && foo.bar!.baz.bam != null;', output: 'foo?.bar.baz?.bam != null', errors: [ { @@ -1666,7 +1683,7 @@ foo?.bar(/* comment */a, ], }, { - code: 'foo!.bar!.baz != null && foo.bar.baz.bam != null', + code: 'foo!.bar!.baz != null && foo.bar.baz.bam != null;', output: 'foo?.bar?.baz?.bam != null', errors: [ { @@ -1679,16 +1696,16 @@ foo?.bar(/* comment */a, { code: ` a && - a.b != null && - a.b.c !== undefined && - a.b.c !== null && - a.b.c.d != null && - a.b.c.d.e !== null && - a.b.c.d.e.f !== undefined && - typeof a.b.c.d.e.f.g !== 'undefined' && - a.b.c.d.e.f.g.h + a.b != null && + a.b.c !== undefined && + a.b.c !== null && + a.b.c.d != null && + a.b.c.d.e !== null && + a.b.c.d.e.f !== undefined && + typeof a.b.c.d.e.f.g !== 'undefined' && + a.b.c.d.e.f.g.h; `, - output: 'a?.b?.c?.d?.e?.f?.g?.h', + output: 'a?.b?.c?.d?.e?.f?.g?.h;', errors: [ { messageId: 'preferOptionalChain', @@ -1699,16 +1716,16 @@ foo?.bar(/* comment */a, { code: ` !a || - a.b == null || - a.b.c === undefined || - a.b.c === null || - a.b.c.d == null || - a.b.c.d.e === null || - a.b.c.d.e.f === undefined || - typeof a.b.c.d.e.f.g === 'undefined' || - a.b.c.d.e.f.g.h + a.b == null || + a.b.c === undefined || + a.b.c === null || + a.b.c.d == null || + a.b.c.d.e === null || + a.b.c.d.e.f === undefined || + typeof a.b.c.d.e.f.g === 'undefined' || + a.b.c.d.e.f.g.h; `, - output: 'a?.b?.c?.d?.e?.f?.g?.h', + output: 'a?.b?.c?.d?.e?.f?.g?.h;', errors: [ { messageId: 'preferOptionalChain', @@ -1719,16 +1736,16 @@ foo?.bar(/* comment */a, { code: ` !a || - a.b == null || - a.b.c === null || - a.b.c === undefined || - a.b.c.d == null || - a.b.c.d.e === null || - a.b.c.d.e.f === undefined || - typeof a.b.c.d.e.f.g === 'undefined' || - a.b.c.d.e.f.g.h + a.b == null || + a.b.c === null || + a.b.c === undefined || + a.b.c.d == null || + a.b.c.d.e === null || + a.b.c.d.e.f === undefined || + typeof a.b.c.d.e.f.g === 'undefined' || + a.b.c.d.e.f.g.h; `, - output: 'a?.b?.c?.d?.e?.f?.g?.h', + output: 'a?.b?.c?.d?.e?.f?.g?.h;', errors: [ { messageId: 'preferOptionalChain', @@ -1738,8 +1755,8 @@ foo?.bar(/* comment */a, }, // yoda checks are flagged { - code: 'undefined !== foo && null !== foo.bar && foo.bar.baz', - output: 'foo?.bar?.baz', + code: 'undefined !== foo && null !== foo.bar && foo.bar.baz;', + output: 'foo?.bar?.baz;', errors: [ { messageId: 'preferOptionalChain', @@ -1748,8 +1765,8 @@ foo?.bar(/* comment */a, ], }, { - code: "null != foo && 'undefined' !== typeof foo.bar && foo.bar.baz", - output: 'foo?.bar?.baz', + code: "null != foo && 'undefined' !== typeof foo.bar && foo.bar.baz;", + output: 'foo?.bar?.baz;', errors: [ { messageId: 'preferOptionalChain', @@ -1759,8 +1776,8 @@ foo?.bar(/* comment */a, }, // await { - code: '(await foo).bar && (await foo).bar.baz', - output: '(await foo).bar?.baz', + code: '(await foo).bar && (await foo).bar.baz;', + output: '(await foo).bar?.baz;', errors: [ { messageId: 'preferOptionalChain', @@ -1768,9 +1785,7 @@ foo?.bar(/* comment */a, }, ], }, - ] - // TODO - remove this once fixer is reimplemented - .map(tempRemoveFixerTODO), + ], }); }); @@ -1820,9 +1835,7 @@ describe('base cases', () => { }, ], })), - ] - // TODO - remove this once fixer is reimplemented - .map(tempRemoveFixerTODO), + ], }); }); @@ -1830,52 +1843,40 @@ describe('base cases', () => { describe('!== null', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], - invalid: BaseCases.all() - .map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!== null &&'), - })) - // TODO - remove this once fixer is reimplemented - .map(tempRemoveFixerTODO), + invalid: BaseCases.all().map(c => ({ + ...c, + code: c.code.replace(/&&/g, '!== null &&'), + })), }); }); describe('!= null', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], - invalid: BaseCases.all() - .map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!= null &&'), - })) - // TODO - remove this once fixer is reimplemented - .map(tempRemoveFixerTODO), + invalid: BaseCases.all().map(c => ({ + ...c, + code: c.code.replace(/&&/g, '!= null &&'), + })), }); }); describe('!== undefined', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], - invalid: BaseCases.all() - .map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!== undefined &&'), - })) - // TODO - remove this once fixer is reimplemented - .map(tempRemoveFixerTODO), + invalid: BaseCases.all().map(c => ({ + ...c, + code: c.code.replace(/&&/g, '!== undefined &&'), + })), }); }); describe('!= undefined', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], - invalid: BaseCases.all() - .map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!= undefined &&'), - })) - // TODO - remove this once fixer is reimplemented - .map(tempRemoveFixerTODO), + invalid: BaseCases.all().map(c => ({ + ...c, + code: c.code.replace(/&&/g, '!= undefined &&'), + })), }); }); }); @@ -1899,9 +1900,7 @@ describe('base cases', () => { ], }, ], - })) - // TODO - remove this once fixer is reimplemented - .map(tempRemoveFixerTODO), + })), }); }); @@ -1918,9 +1917,7 @@ describe('base cases', () => { ...c, code: c.code.replace(/\./g, '.\n'), })), - ] - // TODO - remove this once fixer is reimplemented - .map(tempRemoveFixerTODO), + ], }); }); @@ -1959,9 +1956,7 @@ describe('base cases', () => { }, ], })), - ] - // TODO - remove this once fixer is reimplemented - .map(tempRemoveFixerTODO), + ], }); }); }); From d0a67aef252da5981ece409eccf531d45e183574 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 9 May 2023 15:21:39 +0930 Subject: [PATCH 17/26] algorithm improvements and some test fixes --- .../src/rules/prefer-optional-chain.ts | 258 +++-- .../src/util/getOperatorPrecedence.ts | 144 ++- .../rules/prefer-optional-chain/base-cases.ts | 348 +++---- .../prefer-optional-chain.test.ts | 912 +++++++++--------- 4 files changed, 947 insertions(+), 715 deletions(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 9429cd924408..cdb6f1b92719 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -78,6 +78,7 @@ interface ValidOperand { type: OperandValidity.Valid; comparedName: TSESTree.Node; comparisonType: NullishComparisonType; + isYoda: boolean; node: TSESTree.Expression; } interface InvalidOperand { @@ -163,18 +164,20 @@ function gatherLogicalOperands( case AST_NODE_TYPES.BinaryExpression: { // check for "yoda" style logical: null != x - const { comparedExpression, comparedValue } = (() => { + const { comparedExpression, comparedValue, isYoda } = (() => { // non-yoda checks are by far the most common, so check for them first const comparedValueRight = getComparisonValueType(operand.right); if (comparedValueRight) { return { comparedExpression: operand.left, comparedValue: comparedValueRight, + isYoda: false, }; } else { return { comparedExpression: operand.right, comparedValue: getComparisonValueType(operand.left), + isYoda: true, }; } })(); @@ -191,6 +194,7 @@ function gatherLogicalOperands( comparisonType: operand.operator.startsWith('!') ? NullishComparisonType.NotStrictEqualUndefined : NullishComparisonType.StrictEqualUndefined, + isYoda, node: operand, }); continue; @@ -215,6 +219,7 @@ function gatherLogicalOperands( comparisonType: operand.operator.startsWith('!') ? NullishComparisonType.NotEqualNullOrUndefined : NullishComparisonType.EqualNullOrUndefined, + isYoda, node: operand, }); continue; @@ -234,6 +239,7 @@ function gatherLogicalOperands( comparisonType: operand.operator.startsWith('!') ? NullishComparisonType.NotStrictEqualNull : NullishComparisonType.StrictEqualNull, + isYoda, node: operand, }); continue; @@ -245,6 +251,7 @@ function gatherLogicalOperands( comparisonType: operand.operator.startsWith('!') ? NullishComparisonType.NotStrictEqualUndefined : NullishComparisonType.StrictEqualUndefined, + isYoda, node: operand, }); continue; @@ -276,6 +283,7 @@ function gatherLogicalOperands( type: OperandValidity.Valid, comparedName: operand.argument, comparisonType: NullishComparisonType.NotBoolean, + isYoda: false, node: operand, }); continue; @@ -302,6 +310,7 @@ function gatherLogicalOperands( type: OperandValidity.Valid, comparedName: operand, comparisonType: NullishComparisonType.Boolean, + isYoda: false, node: operand, }); } else { @@ -787,16 +796,37 @@ function compareNodes( return result; } +function includesType( + parserServices: ParserServicesWithTypeInformation, + node: TSESTree.Node, + typeFlagIn: ts.TypeFlags, +): boolean { + const typeFlag = typeFlagIn | ts.TypeFlags.Any | ts.TypeFlags.Unknown; + const types = unionTypeParts(parserServices.getTypeAtLocation(node)); + for (const type of types) { + if (util.isTypeFlagSet(type, typeFlag)) { + return true; + } + } + return false; +} + // I hate that these functions are identical aside from the enum values used // I can't think of a good way to reuse the code here in a way that will preserve // the type safety and simplicity. type OperandAnalyzer = ( + parserServices: ParserServicesWithTypeInformation, operand: ValidOperand, index: number, chain: readonly ValidOperand[], ) => readonly [ValidOperand] | readonly [ValidOperand, ValidOperand] | null; -const analyzeAndChainOperand: OperandAnalyzer = (operand, index, chain) => { +const analyzeAndChainOperand: OperandAnalyzer = ( + parserServices, + operand, + index, + chain, +) => { switch (operand.comparisonType) { case NullishComparisonType.Boolean: case NullishComparisonType.NotEqualNullOrUndefined: @@ -812,9 +842,21 @@ const analyzeAndChainOperand: OperandAnalyzer = (operand, index, chain) => { NodeComparisonResult.Equal ) { return [operand, nextOperand]; - } else { - return [operand]; } + if ( + includesType( + parserServices, + operand.comparedName, + ts.TypeFlags.Undefined, + ) + ) { + // we know the next operand is not an `undefined` check and that this + // operand includes `undefined` - which means that making this an + // optional chain would change the runtime behavior of the expression + return null; + } + + return [operand]; } case NullishComparisonType.NotStrictEqualUndefined: { @@ -827,24 +869,39 @@ const analyzeAndChainOperand: OperandAnalyzer = (operand, index, chain) => { NodeComparisonResult.Equal ) { return [operand, nextOperand]; - } else { - return [operand]; } + if ( + includesType( + parserServices, + operand.comparedName, + ts.TypeFlags.Undefined, + ) + ) { + // we know the next operand is not a `null` check and that this + // operand includes `null` - which means that making this an + // optional chain would change the runtime behavior of the expression + return null; + } + + return [operand]; } default: return null; } }; -const analyzeOrChainOperand: OperandAnalyzer = (operand, index, chain) => { +const analyzeOrChainOperand: OperandAnalyzer = ( + parserServices, + operand, + index, + chain, +) => { switch (operand.comparisonType) { case NullishComparisonType.NotBoolean: case NullishComparisonType.EqualNullOrUndefined: return [operand]; case NullishComparisonType.StrictEqualNull: { - // TODO(#4820) - use types here to determine if the value is just `null` (eg `=== null` would be enough on its own) - // handle `x === null || x === undefined` const nextOperand = chain[index + 1] as ValidOperand | undefined; if ( @@ -854,14 +911,24 @@ const analyzeOrChainOperand: OperandAnalyzer = (operand, index, chain) => { NodeComparisonResult.Equal ) { return [operand, nextOperand]; - } else { + } + if ( + includesType( + parserServices, + operand.comparedName, + ts.TypeFlags.Undefined, + ) + ) { + // we know the next operand is not an `undefined` check and that this + // operand includes `undefined` - which means that making this an + // optional chain would change the runtime behavior of the expression return null; } + + return [operand]; } case NullishComparisonType.StrictEqualUndefined: { - // TODO(#4820) - use types here to determine if the value is just `undefined` (eg `=== undefined` would be enough on its own) - // handle `x === undefined || x === null` const nextOperand = chain[index + 1] as ValidOperand | undefined; if ( @@ -870,9 +937,21 @@ const analyzeOrChainOperand: OperandAnalyzer = (operand, index, chain) => { NodeComparisonResult.Equal ) { return [operand, nextOperand]; - } else { + } + if ( + includesType( + parserServices, + operand.comparedName, + ts.TypeFlags.Undefined, + ) + ) { + // we know the next operand is not a `null` check and that this + // operand includes `null` - which means that making this an + // optional chain would change the runtime behavior of the expression return null; } + + return [operand]; } default: @@ -883,6 +962,7 @@ const analyzeOrChainOperand: OperandAnalyzer = (operand, index, chain) => { function getFixer( sourceCode: SourceCode, parserServices: ParserServicesWithTypeInformation, + operator: '&&' | '||', options: Options, chain: ValidOperand[], ): @@ -908,8 +988,16 @@ function getFixer( // unsafe and might cause downstream type errors. if ( - lastOperand.comparisonType !== NullishComparisonType.Boolean && - lastOperand.comparisonType !== NullishComparisonType.NotBoolean + lastOperand.comparisonType === + NullishComparisonType.EqualNullOrUndefined || + lastOperand.comparisonType === + NullishComparisonType.NotEqualNullOrUndefined || + lastOperand.comparisonType === + NullishComparisonType.StrictEqualUndefined || + lastOperand.comparisonType === + NullishComparisonType.NotStrictEqualUndefined || + (operator === '||' && + lastOperand.comparisonType === NullishComparisonType.NotBoolean) ) { // we know the last operand is an equality check - so the change in types // DOES NOT matter and will not change the runtime result or cause a type @@ -918,20 +1006,20 @@ function getFixer( } else { useSuggestionFixer = true; - // TODO - we could further reduce the false-positive rate of this check by - // checking for cases where the change in types don't matter like - // the test location of an if/while/etc statement. - // but it's quite complex to do this without false-negatives, so - // for now we'll just be over-eager with our matching. for (const operand of chain) { - const types = unionTypeParts( - parserServices.getTypeAtLocation(operand.node), - ); - if (types.some(t => util.isTypeFlagSet(t, ts.TypeFlags.Undefined))) { + if ( + includesType(parserServices, operand.node, ts.TypeFlags.Undefined) + ) { useSuggestionFixer = false; break; } } + + // TODO - we could further reduce the false-positive rate of this check by + // checking for cases where the change in types don't matter like + // the test location of an if/while/etc statement. + // but it's quite complex to do this without false-negatives, so + // for now we'll just be over-eager with our matching. } } @@ -961,25 +1049,24 @@ function getFixer( // 6) diff(`foo.bar.baz?.bam`, `foo.bar.baz.bam()`) = `()` // 7) result = `foo?.bar?.baz?.bam?.()` - let current = flattenChainExpression(sourceCode, chain[0].comparedName); - const parts = [...current]; - - for (let i = 1; i < chain.length; i += 1) { + const parts = []; + for (const current of chain) { const nextOperand = flattenChainExpression( sourceCode, - chain[i].comparedName, + current.comparedName, ); const diff = nextOperand.slice(parts.length); - // we need to make the first operand of the diff optional so it matches the - // logic before merging - // foo.bar && foo.bar.baz - // diff = .baz - // result = foo.bar?.baz if (diff.length > 0) { - diff[0].optional = true; + if (parts.length > 0) { + // we need to make the first operand of the diff optional so it matches the + // logic before merging + // foo.bar && foo.bar.baz + // diff = .baz + // result = foo.bar?.baz + diff[0].optional = true; + } parts.push(...diff); } - current = nextOperand; } let newCode = parts @@ -987,10 +1074,22 @@ function getFixer( let str = ''; if (part.optional) { str += '?.'; - } else if (part.requiresDot) { - str += '.'; + } else { + if (part.nonNull) { + str += '!'; + } + if (part.requiresDot) { + str += '.'; + } + } + if ( + part.precedence !== util.OperatorPrecedence.Invalid && + part.precedence < util.OperatorPrecedence.Member + ) { + str += `(${part.text})`; + } else { + str += part.text; } - str += part.text; return str; }) .join(''); @@ -998,11 +1097,34 @@ function getFixer( if (lastOperand.node.type === AST_NODE_TYPES.BinaryExpression) { // retain the ending comparison for cases like // x && x.a != null - newCode += - ' ' + - lastOperand.node.operator + - ' ' + - sourceCode.getText(lastOperand.node.right); + // x && typeof x.a !== 'undefined' + const operator = lastOperand.node.operator; + const { left, right } = (() => { + if (lastOperand.isYoda) { + const unaryOperator = + lastOperand.node.right.type === AST_NODE_TYPES.UnaryExpression + ? lastOperand.node.right.operator + ' ' + : ''; + + return { + left: sourceCode.getText(lastOperand.node.left), + right: unaryOperator + newCode, + }; + } else { + const unaryOperator = + lastOperand.node.left.type === AST_NODE_TYPES.UnaryExpression + ? lastOperand.node.left.operator + ' ' + : ''; + return { + left: unaryOperator + newCode, + right: sourceCode.getText(lastOperand.node.right), + }; + } + })(); + + newCode = `${left} ${operator} ${right}`; + } else if (lastOperand.comparisonType === NullishComparisonType.NotBoolean) { + newCode = `!${newCode}`; } const fix: ReportFixFunction = fixer => @@ -1016,9 +1138,11 @@ function getFixer( : { fix }; interface FlattenedChain { - text: string; + nonNull: boolean; optional: boolean; + precedence: util.OperatorPrecedence; requiresDot: boolean; + text: string; } function flattenChainExpression( sourceCode: SourceCode, @@ -1065,9 +1189,12 @@ function getFixer( return [ ...flattenChainExpression(sourceCode, node.callee), { - text: typeArgumentsText + argumentsText, + nonNull: false, optional: node.optional, + // no precedence for this + precedence: util.OperatorPrecedence.Invalid, requiresDot: false, + text: typeArgumentsText + argumentsText, }, ]; } @@ -1077,19 +1204,29 @@ function getFixer( return [ ...flattenChainExpression(sourceCode, node.object), { - text: node.computed ? `[${propertyText}]` : propertyText, + nonNull: node.object.type === AST_NODE_TYPES.TSNonNullExpression, optional: node.optional, + precedence: node.computed + ? // computed is already wrapped in [] so no need to wrap in () as well + util.OperatorPrecedence.Invalid + : util.getOperatorPrecedenceForNode(node.property), requiresDot: !node.computed, + text: node.computed ? `[${propertyText}]` : propertyText, }, ]; } + case AST_NODE_TYPES.TSNonNullExpression: + return flattenChainExpression(sourceCode, node.expression); + default: return [ { - text: sourceCode.getText(node), + nonNull: false, optional: false, + precedence: util.getOperatorPrecedenceForNode(node), requiresDot: false, + text: sourceCode.getText(node), }, ]; } @@ -1105,7 +1242,11 @@ function analyzeChain( chain: ValidOperand[], ): void { // need at least 2 operands in a chain for it to be a chain - if (chain.length <= 1) { + if ( + chain.length <= 1 || + /* istanbul ignore next -- previous checks make this unreachable, but keep it for exhaustiveness check */ + operator === '??' + ) { return; } @@ -1116,10 +1257,6 @@ function analyzeChain( case '||': return analyzeOrChainOperand; - - case '??': - /* istanbul ignore next -- previous checks make this unreachable, but keep it for exhaustiveness check */ - return null; } })(); if (!analyzeOperand) { @@ -1137,7 +1274,7 @@ function analyzeChain( start: subChain[0].node.loc.start, end: subChain[subChain.length - 1].node.loc.end, }, - ...getFixer(sourceCode, parserServices, options, subChain), + ...getFixer(sourceCode, parserServices, operator, options, subChain), }); } @@ -1152,7 +1289,7 @@ function analyzeChain( | undefined; const operand = chain[i]; - const validatedOperands = analyzeOperand(operand, i, chain); + const validatedOperands = analyzeOperand(parserServices, operand, i, chain); if (!validatedOperands) { maybeReportThenReset(); continue; @@ -1160,14 +1297,14 @@ function analyzeChain( // in case multiple operands were consumed - make sure to correctly increment the index i += validatedOperands.length - 1; - // purposely inspect and push the last operand because the prior operands don't matter - // this also means we won't false-positive in cases like - // foo !== null && foo !== undefined - const currentOperand = validatedOperands[validatedOperands.length - 1]; + const currentOperand = validatedOperands[0]; if (lastOperand) { const comparisonResult = compareNodes( lastOperand.comparedName, - currentOperand.comparedName, + // purposely inspect and push the last operand because the prior operands don't matter + // this also means we won't false-positive in cases like + // foo !== null && foo !== undefined + validatedOperands[validatedOperands.length - 1].comparedName, ); if (comparisonResult === NodeComparisonResult.Subset) { // the operands are comparable, so we can continue searching @@ -1263,6 +1400,7 @@ export default util.createRule({ checkBoolean: true, checkBigInt: true, requireNullish: false, + allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing: false, }, ], create(context, [options]) { diff --git a/packages/eslint-plugin/src/util/getOperatorPrecedence.ts b/packages/eslint-plugin/src/util/getOperatorPrecedence.ts index b7a9d75fb155..abbcb9191a59 100644 --- a/packages/eslint-plugin/src/util/getOperatorPrecedence.ts +++ b/packages/eslint-plugin/src/util/getOperatorPrecedence.ts @@ -1,3 +1,5 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import { SyntaxKind } from 'typescript'; export enum OperatorPrecedence { @@ -192,12 +194,115 @@ export enum OperatorPrecedence { Invalid = -1, } +export function getOperatorPrecedenceForNode( + node: TSESTree.Node, +): OperatorPrecedence { + switch (node.type) { + case AST_NODE_TYPES.SpreadElement: + case AST_NODE_TYPES.RestElement: + return OperatorPrecedence.Spread; + + case AST_NODE_TYPES.YieldExpression: + return OperatorPrecedence.Yield; + + case AST_NODE_TYPES.ConditionalExpression: + return OperatorPrecedence.Conditional; + + case AST_NODE_TYPES.SequenceExpression: + return OperatorPrecedence.Comma; + + case AST_NODE_TYPES.AssignmentExpression: + case AST_NODE_TYPES.BinaryExpression: + case AST_NODE_TYPES.LogicalExpression: + switch (node.operator) { + case '==': + case '+=': + case '-=': + case '**=': + case '*=': + case '/=': + case '%=': + case '<<=': + case '>>=': + case '>>>=': + case '&=': + case '^=': + case '|=': + case '||=': + case '&&=': + case '??=': + return OperatorPrecedence.Assignment; + + default: + return getBinaryOperatorPrecedence(node.operator); + } + + case AST_NODE_TYPES.TSTypeAssertion: + case AST_NODE_TYPES.TSNonNullExpression: + case AST_NODE_TYPES.UnaryExpression: + case AST_NODE_TYPES.AwaitExpression: + return OperatorPrecedence.Unary; + + case AST_NODE_TYPES.UpdateExpression: + // TODO: Should prefix `++` and `--` be moved to the `Update` precedence? + if (node.prefix) { + return OperatorPrecedence.Unary; + } + return OperatorPrecedence.Update; + + case AST_NODE_TYPES.ChainExpression: + return getOperatorPrecedenceForNode(node.expression); + + case AST_NODE_TYPES.CallExpression: + return OperatorPrecedence.LeftHandSide; + + case AST_NODE_TYPES.NewExpression: + return node.arguments.length > 0 + ? OperatorPrecedence.Member + : OperatorPrecedence.LeftHandSide; + + case AST_NODE_TYPES.TaggedTemplateExpression: + case AST_NODE_TYPES.MemberExpression: + case AST_NODE_TYPES.MetaProperty: + return OperatorPrecedence.Member; + + case AST_NODE_TYPES.TSAsExpression: + return OperatorPrecedence.Relational; + + case AST_NODE_TYPES.ThisExpression: + case AST_NODE_TYPES.Super: + case AST_NODE_TYPES.Identifier: + case AST_NODE_TYPES.PrivateIdentifier: + case AST_NODE_TYPES.Literal: + case AST_NODE_TYPES.ArrayExpression: + case AST_NODE_TYPES.ObjectExpression: + case AST_NODE_TYPES.FunctionExpression: + case AST_NODE_TYPES.ArrowFunctionExpression: + case AST_NODE_TYPES.ClassExpression: + case AST_NODE_TYPES.TemplateLiteral: + case AST_NODE_TYPES.JSXElement: + case AST_NODE_TYPES.JSXFragment: + // we don't have nodes for these cases + // case SyntaxKind.ParenthesizedExpression: + // case SyntaxKind.OmittedExpression: + return OperatorPrecedence.Primary; + + default: + return OperatorPrecedence.Invalid; + } +} + +type ValueOf = T[keyof T]; +type TSESTreeOperatorKind = + | ValueOf + | ValueOf; export function getOperatorPrecedence( nodeKind: SyntaxKind, operatorKind: SyntaxKind, hasArguments?: boolean, ): OperatorPrecedence { switch (nodeKind) { + // A list of comma-separated expressions. This node is only created by transformations. case SyntaxKind.CommaListExpression: return OperatorPrecedence.Comma; @@ -298,46 +403,83 @@ export function getOperatorPrecedence( } export function getBinaryOperatorPrecedence( - kind: SyntaxKind, + kind: SyntaxKind | TSESTreeOperatorKind, ): OperatorPrecedence { switch (kind) { case SyntaxKind.QuestionQuestionToken: + case '??': return OperatorPrecedence.Coalesce; + case SyntaxKind.BarBarToken: + case '||': return OperatorPrecedence.LogicalOR; + case SyntaxKind.AmpersandAmpersandToken: + case '&&': return OperatorPrecedence.LogicalAND; + case SyntaxKind.BarToken: + case '|': return OperatorPrecedence.BitwiseOR; + case SyntaxKind.CaretToken: + case '^': return OperatorPrecedence.BitwiseXOR; + case SyntaxKind.AmpersandToken: + case '&': return OperatorPrecedence.BitwiseAND; + case SyntaxKind.EqualsEqualsToken: + case '==': case SyntaxKind.ExclamationEqualsToken: + case '!=': case SyntaxKind.EqualsEqualsEqualsToken: + case '===': case SyntaxKind.ExclamationEqualsEqualsToken: + case '!==': return OperatorPrecedence.Equality; + case SyntaxKind.LessThanToken: + case '<': case SyntaxKind.GreaterThanToken: + case '>': case SyntaxKind.LessThanEqualsToken: + case '<=': case SyntaxKind.GreaterThanEqualsToken: + case '>=': case SyntaxKind.InstanceOfKeyword: + case 'instanceof': case SyntaxKind.InKeyword: + case 'in': case SyntaxKind.AsKeyword: + // case 'as': -- we don't have a token for this return OperatorPrecedence.Relational; + case SyntaxKind.LessThanLessThanToken: + case '<<': case SyntaxKind.GreaterThanGreaterThanToken: + case '>>': case SyntaxKind.GreaterThanGreaterThanGreaterThanToken: + case '>>>': return OperatorPrecedence.Shift; + case SyntaxKind.PlusToken: + case '+': case SyntaxKind.MinusToken: + case '-': return OperatorPrecedence.Additive; + case SyntaxKind.AsteriskToken: + case '*': case SyntaxKind.SlashToken: + case '/': case SyntaxKind.PercentToken: + case '%': return OperatorPrecedence.Multiplicative; + case SyntaxKind.AsteriskAsteriskToken: + case '**': return OperatorPrecedence.Exponentiation; } diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts index c87fbfb76e1d..66587556f6b2 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts @@ -1,208 +1,156 @@ -import type { TSESLint } from '@typescript-eslint/utils'; +import type { InvalidTestCase } from '@typescript-eslint/utils/ts-eslint'; -import type rule from '../../../src/rules/prefer-optional-chain'; import type { - InferMessageIdsTypeFromRule, - InferOptionsTypeFromRule, -} from '../../../src/util'; + TMessageIds, + TOptions, +} from '../../../src/rules/prefer-optional-chain'; -type InvalidTestCase = TSESLint.InvalidTestCase< - InferMessageIdsTypeFromRule, - InferOptionsTypeFromRule ->; +type MutateCodeFn = (c: string) => string; +type MutateOutputFn = (c: string) => string; +type BaseCaseCreator = ( + operator: '&&' | '||', + mutateCode?: MutateCodeFn, + mutateOutput?: MutateOutputFn, +) => InvalidTestCase[]; -interface BaseCase { - canReplaceAndWithOr: boolean; - output: string; - code: string; -} - -const mapper = (c: BaseCase): InvalidTestCase => ({ - code: c.code.trim(), - output: null, - errors: [ +export const identity: MutateCodeFn = c => c; +export const BaseCases: BaseCaseCreator = ( + operator, + mutateCode = identity, + mutateOutput = mutateCode, +) => + [ + // chained members { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: c.output.trim(), - }, - ], + code: `foo ${operator} foo.bar`, + output: 'foo?.bar', + }, + { + code: `foo.bar ${operator} foo.bar.baz`, + output: 'foo.bar?.baz', + }, + { + code: `foo ${operator} foo()`, + output: 'foo?.()', + }, + { + code: `foo.bar ${operator} foo.bar()`, + output: 'foo.bar?.()', + }, + { + code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz`, + output: 'foo?.bar?.baz?.buzz', + }, + { + code: `foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz`, + output: 'foo.bar?.baz?.buzz', }, - ], -}); - -const baseCases: BaseCase[] = [ - // chained members - { - code: 'foo && foo.bar', - output: 'foo?.bar', - canReplaceAndWithOr: true, - }, - { - code: 'foo.bar && foo.bar.baz', - output: 'foo.bar?.baz', - canReplaceAndWithOr: true, - }, - { - code: 'foo && foo()', - output: 'foo?.()', - canReplaceAndWithOr: true, - }, - { - code: 'foo.bar && foo.bar()', - output: 'foo.bar?.()', - canReplaceAndWithOr: true, - }, - { - code: 'foo && foo.bar && foo.bar.baz && foo.bar.baz.buzz', - output: 'foo?.bar?.baz?.buzz', - canReplaceAndWithOr: true, - }, - { - code: 'foo.bar && foo.bar.baz && foo.bar.baz.buzz', - output: 'foo.bar?.baz?.buzz', - canReplaceAndWithOr: true, - }, - // case with a jump (i.e. a non-nullish prop) - { - code: 'foo && foo.bar && foo.bar.baz.buzz', - output: 'foo?.bar?.baz.buzz', - canReplaceAndWithOr: true, - }, - { - code: 'foo.bar && foo.bar.baz.buzz', - output: 'foo.bar?.baz.buzz', - canReplaceAndWithOr: true, - }, - // case where for some reason there is a doubled up expression - { - code: 'foo && foo.bar && foo.bar.baz && foo.bar.baz && foo.bar.baz.buzz', - output: 'foo?.bar?.baz?.buzz', - canReplaceAndWithOr: true, - }, - { - code: 'foo.bar && foo.bar.baz && foo.bar.baz && foo.bar.baz.buzz', - output: 'foo.bar?.baz?.buzz', - canReplaceAndWithOr: true, - }, - // chained members with element access - { - code: 'foo && foo[bar] && foo[bar].baz && foo[bar].baz.buzz', - output: 'foo?.[bar]?.baz?.buzz', - canReplaceAndWithOr: true, - }, - { // case with a jump (i.e. a non-nullish prop) - code: 'foo && foo[bar].baz && foo[bar].baz.buzz', - output: 'foo?.[bar].baz?.buzz', - canReplaceAndWithOr: true, - }, - // case with a property access in computed property - { - code: 'foo && foo[bar.baz] && foo[bar.baz].buzz', - output: 'foo?.[bar.baz]?.buzz', - canReplaceAndWithOr: true, - }, - // case with this keyword - { - code: 'foo[this.bar] && foo[this.bar].baz', - output: 'foo[this.bar]?.baz', - canReplaceAndWithOr: true, - }, - // chained calls - { - code: 'foo && foo.bar && foo.bar.baz && foo.bar.baz.buzz()', - output: 'foo?.bar?.baz?.buzz()', - canReplaceAndWithOr: true, - }, - { - code: 'foo && foo.bar && foo.bar.baz && foo.bar.baz.buzz && foo.bar.baz.buzz()', - output: 'foo?.bar?.baz?.buzz?.()', - canReplaceAndWithOr: true, - }, - { - code: 'foo.bar && foo.bar.baz && foo.bar.baz.buzz && foo.bar.baz.buzz()', - output: 'foo.bar?.baz?.buzz?.()', - canReplaceAndWithOr: true, - }, - // case with a jump (i.e. a non-nullish prop) - { - code: 'foo && foo.bar && foo.bar.baz.buzz()', - output: 'foo?.bar?.baz.buzz()', - canReplaceAndWithOr: true, - }, - { - code: 'foo.bar && foo.bar.baz.buzz()', - output: 'foo.bar?.baz.buzz()', - canReplaceAndWithOr: true, - }, - { + { + code: `foo ${operator} foo.bar ${operator} foo.bar.baz.buzz`, + output: 'foo?.bar?.baz.buzz', + }, + { + code: `foo.bar ${operator} foo.bar.baz.buzz`, + output: 'foo.bar?.baz.buzz', + }, + // case where for some reason there is a doubled up expression + { + code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz`, + output: 'foo?.bar?.baz?.buzz', + }, + { + code: `foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz`, + output: 'foo.bar?.baz?.buzz', + }, + // chained members with element access + { + code: `foo ${operator} foo[bar] ${operator} foo[bar].baz ${operator} foo[bar].baz.buzz`, + output: 'foo?.[bar]?.baz?.buzz', + }, + { + // case with a jump (i.e. a non-nullish prop) + code: `foo ${operator} foo[bar].baz ${operator} foo[bar].baz.buzz`, + output: 'foo?.[bar].baz?.buzz', + }, + // case with a property access in computed property + { + code: `foo ${operator} foo[bar.baz] ${operator} foo[bar.baz].buzz`, + output: 'foo?.[bar.baz]?.buzz', + }, + // case with this keyword + { + code: `foo[this.bar] ${operator} foo[this.bar].baz`, + output: 'foo[this.bar]?.baz', + }, + // chained calls + { + code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz()`, + output: 'foo?.bar?.baz?.buzz()', + }, + { + code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz ${operator} foo.bar.baz.buzz()`, + output: 'foo?.bar?.baz?.buzz?.()', + }, + { + code: `foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz ${operator} foo.bar.baz.buzz()`, + output: 'foo.bar?.baz?.buzz?.()', + }, // case with a jump (i.e. a non-nullish prop) - code: 'foo && foo.bar && foo.bar.baz.buzz && foo.bar.baz.buzz()', - output: 'foo?.bar?.baz.buzz?.()', - canReplaceAndWithOr: true, - }, - { - // case with a call expr inside the chain for some inefficient reason - code: 'foo && foo.bar() && foo.bar().baz && foo.bar().baz.buzz && foo.bar().baz.buzz()', - output: 'foo?.bar()?.baz?.buzz?.()', - canReplaceAndWithOr: true, - }, - // chained calls with element access - { - code: 'foo && foo.bar && foo.bar.baz && foo.bar.baz[buzz]()', - output: 'foo?.bar?.baz?.[buzz]()', - canReplaceAndWithOr: true, - }, - { - code: 'foo && foo.bar && foo.bar.baz && foo.bar.baz[buzz] && foo.bar.baz[buzz]()', - output: 'foo?.bar?.baz?.[buzz]?.()', - canReplaceAndWithOr: true, - }, - // (partially) pre-optional chained - { - code: 'foo && foo?.bar && foo?.bar.baz && foo?.bar.baz[buzz] && foo?.bar.baz[buzz]()', - output: 'foo?.bar?.baz?.[buzz]?.()', - canReplaceAndWithOr: true, - }, - { - code: 'foo && foo?.bar.baz && foo?.bar.baz[buzz]', - output: 'foo?.bar.baz?.[buzz]', - canReplaceAndWithOr: true, - }, - { - code: 'foo && foo?.() && foo?.().bar', - output: 'foo?.()?.bar', - canReplaceAndWithOr: true, - }, - { - code: 'foo.bar && foo.bar?.() && foo.bar?.().baz', - output: 'foo.bar?.()?.baz', - canReplaceAndWithOr: true, - }, -]; - -interface Selector { - all(): InvalidTestCase[]; - select>( - key: K, - value: BaseCase[K], - ): Selector; -} - -const selector = (cases: BaseCase[]): Selector => ({ - all: () => cases.map(mapper), - select: >( - key: K, - value: BaseCase[K], - ): Selector => { - const selectedCases = baseCases.filter(c => c[key] === value); - return selector(selectedCases); - }, -}); - -const { all, select } = selector(baseCases); - -export { all, select }; + { + code: `foo ${operator} foo.bar ${operator} foo.bar.baz.buzz()`, + output: 'foo?.bar?.baz.buzz()', + }, + { + code: `foo.bar ${operator} foo.bar.baz.buzz()`, + output: 'foo.bar?.baz.buzz()', + }, + { + // case with a jump (i.e. a non-nullish prop) + code: `foo ${operator} foo.bar ${operator} foo.bar.baz.buzz ${operator} foo.bar.baz.buzz()`, + output: 'foo?.bar?.baz.buzz?.()', + }, + { + // case with a call expr inside the chain for some inefficient reason + code: `foo ${operator} foo.bar() ${operator} foo.bar().baz ${operator} foo.bar().baz.buzz ${operator} foo.bar().baz.buzz()`, + output: 'foo?.bar()?.baz?.buzz?.()', + }, + // chained calls with element access + { + code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz[buzz]()`, + output: 'foo?.bar?.baz?.[buzz]()', + }, + { + code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz[buzz] ${operator} foo.bar.baz[buzz]()`, + output: 'foo?.bar?.baz?.[buzz]?.()', + }, + // (partially) pre-optional chained + { + code: `foo ${operator} foo?.bar ${operator} foo?.bar.baz ${operator} foo?.bar.baz[buzz] ${operator} foo?.bar.baz[buzz]()`, + output: 'foo?.bar?.baz?.[buzz]?.()', + }, + { + code: `foo ${operator} foo?.bar.baz ${operator} foo?.bar.baz[buzz]`, + output: 'foo?.bar.baz?.[buzz]', + }, + { + code: `foo ${operator} foo?.() ${operator} foo?.().bar`, + output: 'foo?.()?.bar', + }, + { + code: `foo.bar ${operator} foo.bar?.() ${operator} foo.bar?.().baz`, + output: 'foo.bar?.()?.baz', + }, + ].map(({ code, output }): InvalidTestCase => { + const fixOutput = mutateOutput(output); + return { + code: mutateCode(code), + output: fixOutput, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }; + }); diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index 4ff40e91b739..397988aad5a0 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -2,7 +2,7 @@ import { noFormat, RuleTester } from '@typescript-eslint/rule-tester'; import rule from '../../../src/rules/prefer-optional-chain'; import { getFixturesRootDir } from '../../RuleTester'; -import * as BaseCases from './base-cases'; +import { BaseCases, identity } from './base-cases'; const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', @@ -216,32 +216,48 @@ describe('|| {}', () => { ], }, { - code: noFormat`if (foo) { (foo || {}).bar; }`, + code: ` + if (foo) { + (foo || {}).bar; + } + `, errors: [ { messageId: 'optionalChainSuggest', - column: 12, - endColumn: 27, + column: 13, + endColumn: 28, suggestions: [ { messageId: 'optionalChainSuggest', - output: `if (foo) { foo?.bar; }`, + output: ` + if (foo) { + foo?.bar; + } + `, }, ], }, ], }, { - code: noFormat`if ((foo || {}).bar) { foo.bar; }`, + code: ` + if ((foo || {}).bar) { + foo.bar; + } + `, errors: [ { messageId: 'optionalChainSuggest', - column: 5, - endColumn: 20, + column: 15, + endColumn: 30, suggestions: [ { messageId: 'optionalChainSuggest', - output: `if (foo?.bar) { foo.bar; }`, + output: ` + if (foo?.bar) { + foo.bar; + } + `, }, ], }, @@ -458,7 +474,7 @@ describe('|| {}', () => { suggestions: [ { messageId: 'optionalChainSuggest', - output: `if (foo) { foo?.bar; }`, + output: 'if (foo) { foo?.bar; }', }, ], }, @@ -474,7 +490,7 @@ describe('|| {}', () => { suggestions: [ { messageId: 'optionalChainSuggest', - output: `if (foo?.bar) { foo.bar; }`, + output: 'if (foo?.bar) { foo.bar; }', }, ], }, @@ -497,7 +513,7 @@ describe('|| {}', () => { ], }, { - code: noFormat`(a > b || {}).bar;`, + code: '(a > b || {}).bar;', errors: [ { messageId: 'optionalChainSuggest', @@ -522,7 +538,7 @@ describe('|| {}', () => { suggestions: [ { messageId: 'optionalChainSuggest', - output: `((typeof x) as string)?.bar;`, + output: '((typeof x) as string)?.bar;', }, ], }, @@ -896,72 +912,57 @@ describe('hand-crafted cases', () => { // two errors { code: noFormat`foo && foo.bar && foo.bar.baz || baz && baz.bar && baz.bar.foo`, - output: null, + output: 'foo?.bar?.baz || baz?.bar?.foo', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: `foo?.bar?.baz || baz && baz.bar && baz.bar.foo`, - }, - ], + suggestions: null, }, { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: `foo && foo.bar && foo.bar.baz || baz?.bar?.foo`, - }, - ], + suggestions: null, }, ], }, - // case with inconsistent checks + // case with inconsistent checks should "break" the chain { code: 'foo && foo.bar != null && foo.bar.baz !== undefined && foo.bar.baz.buzz;', - output: null, + output: + 'foo?.bar != null && foo.bar.baz !== undefined && foo.bar.baz.buzz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar?.baz?.buzz;', - }, - ], + suggestions: null, }, ], }, { - code: noFormat`foo.bar && foo.bar.baz != null && foo.bar.baz.qux !== undefined && foo.bar.baz.qux.buzz;`, - output: null, + code: ` + foo.bar && + foo.bar.baz != null && + foo.bar.baz.qux !== undefined && + foo.bar.baz.qux.buzz; + `, + output: ` + foo.bar?.baz != null && + foo.bar.baz.qux !== undefined && + foo.bar.baz.qux.buzz; + `, errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo.bar?.baz?.qux?.buzz;', - }, - ], + suggestions: null, }, ], }, // ensure essential whitespace isn't removed { code: 'foo && foo.bar(baz => );', - output: null, + output: 'foo?.bar(baz => );', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar(baz => );', - }, - ], + suggestions: null, }, ], parserOptions: { @@ -973,143 +974,78 @@ describe('hand-crafted cases', () => { }, { code: 'foo && foo.bar(baz => typeof baz);', - output: null, + output: 'foo?.bar(baz => typeof baz);', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar(baz => typeof baz);', - }, - ], - }, - ], - }, - { - code: noFormat`foo && foo["some long string"] && foo["some long string"].baz`, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: `foo?.["some long string"]?.baz`, - }, - ], - }, - ], - }, - { - code: noFormat`foo && foo[\`some long string\`] && foo[\`some long string\`].baz`, - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: `foo?.[\`some long string\`]?.baz`, - }, - ], + suggestions: null, }, ], }, { code: "foo && foo['some long string'] && foo['some long string'].baz;", - output: null, + output: "foo?.['some long string']?.baz;", errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: "foo?.['some long string']?.baz;", - }, - ], + suggestions: null, }, ], }, { code: 'foo && foo[`some long string`] && foo[`some long string`].baz;', - output: null, + output: 'foo?.[`some long string`]?.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.[`some long string`]?.baz;', - }, - ], + suggestions: null, }, ], }, { code: 'foo && foo[`some ${long} string`] && foo[`some ${long} string`].baz;', - output: null, + output: 'foo?.[`some ${long} string`]?.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.[`some ${long} string`]?.baz;', - }, - ], + suggestions: null, }, ], }, // complex computed properties should be handled correctly { code: 'foo && foo[bar as string] && foo[bar as string].baz;', - output: null, + output: 'foo?.[bar as string]?.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.[bar as string]?.baz;', - }, - ], + suggestions: null, }, ], }, { code: 'foo && foo[1 + 2] && foo[1 + 2].baz;', - output: null, + output: 'foo?.[1 + 2]?.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.[1 + 2]?.baz;', - }, - ], + suggestions: null, }, ], }, { code: 'foo && foo[typeof bar] && foo[typeof bar].baz;', - output: null, + output: 'foo?.[typeof bar]?.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.[typeof bar]?.baz;', - }, - ], + suggestions: null, }, ], }, { code: 'foo && foo.bar(a) && foo.bar(a, b).baz;', - output: 'foo?.bar(a) && foo.bar(a, b).baz', + output: 'foo?.bar(a) && foo.bar(a, b).baz;', errors: [ { messageId: 'preferOptionalChain', @@ -1119,7 +1055,7 @@ describe('hand-crafted cases', () => { }, { code: 'foo() && foo()(bar);', - output: 'foo()?.(bar)', + output: 'foo()?.(bar);', errors: [ { messageId: 'preferOptionalChain', @@ -1130,7 +1066,7 @@ describe('hand-crafted cases', () => { // type parameters are considered { code: 'foo && foo() && foo().bar;', - output: 'foo?.()?.bar', + output: 'foo?.()?.bar;', errors: [ { messageId: 'preferOptionalChain', @@ -1140,7 +1076,7 @@ describe('hand-crafted cases', () => { }, { code: 'foo && foo() && foo().bar;', - output: 'foo?.() && foo().bar', + output: 'foo?.() && foo().bar;', errors: [ { messageId: 'preferOptionalChain', @@ -1151,152 +1087,95 @@ describe('hand-crafted cases', () => { // should preserve comments in a call expression { code: noFormat` -foo && foo.bar(/* comment */a, - // comment2 - b, ); + foo && foo.bar(/* comment */a, + // comment2 + b, ); + `, + output: ` + foo?.bar(/* comment */a, + // comment2 + b, ); `, - output: null, errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: ` -foo?.bar(/* comment */a, - // comment2 - b, ); - `, - }, - ], + suggestions: null, }, ], }, // ensure binary expressions that are the last expression do not get removed + // these get autofixers because the trailing binary means the type doesn't matter { code: 'foo && foo.bar != null;', - output: null, + output: 'foo?.bar != null;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar != null;', - }, - ], + suggestions: null, }, ], }, { code: 'foo && foo.bar != undefined;', - output: null, + output: 'foo?.bar != undefined;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar != undefined;', - }, - ], + suggestions: null, }, ], }, { code: 'foo && foo.bar != null && baz;', - output: null, + output: 'foo?.bar != null && baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar != null && baz;', - }, - ], + suggestions: null, }, ], }, // case with this keyword at the start of expression { code: 'this.bar && this.bar.baz;', - output: null, + output: 'this.bar?.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'this.bar?.baz;', - }, - ], + suggestions: null, }, ], }, // other weird cases { code: 'foo && foo?.();', - output: null, + output: 'foo?.();', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.();', - }, - ], + suggestions: null, }, ], }, { code: 'foo.bar && foo.bar?.();', - output: null, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo.bar?.();', - }, - ], - }, - ], - }, - // using suggestion instead of autofix - { - code: 'foo && foo.bar != null && foo.bar.baz !== undefined && foo.bar.baz.buzz;', - output: null, + output: 'foo.bar?.();', errors: [ { messageId: 'preferOptionalChain', - line: 1, - column: 1, - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar?.baz?.buzz;', - }, - ], + suggestions: null, }, ], }, { code: 'foo && foo.bar(baz => );', - output: null, + output: 'foo?.bar(baz => );', errors: [ { messageId: 'preferOptionalChain', line: 1, column: 1, - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar(baz => );', - }, - ], + suggestions: null, }, ], parserOptions: { @@ -1309,101 +1188,66 @@ foo?.bar(/* comment */a, // case with this keyword at the start of expression { code: '!this.bar || !this.bar.baz;', - output: null, + output: '!this.bar?.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: '!this.bar?.baz;', - }, - ], + suggestions: null, }, ], }, { code: '!a.b || !a.b();', - output: null, + output: '!a.b?.();', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: '!a.b?.();', - }, - ], + suggestions: null, }, ], }, { code: '!foo.bar || !foo.bar.baz;', - output: null, + output: '!foo.bar?.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: '!foo.bar?.baz;', - }, - ], + suggestions: null, }, ], }, { code: '!foo[bar] || !foo[bar]?.[baz];', - output: null, + output: '!foo[bar]?.[baz];', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: '!foo[bar]?.[baz];', - }, - ], + suggestions: null, }, ], }, { code: '!foo || !foo?.bar.baz;', - output: null, + output: '!foo?.bar.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: '!foo?.bar.baz;', - }, - ], + suggestions: null, }, ], }, // two errors { - code: noFormat`(!foo || !foo.bar || !foo.bar.baz) && (!baz || !baz.bar || !baz.bar.foo);`, - output: null, + code: '(!foo || !foo.bar || !foo.bar.baz) && (!baz || !baz.bar || !baz.bar.foo);', + output: '(!foo?.bar?.baz) && (!baz?.bar?.foo);', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: noFormat`(!foo?.bar?.baz) && (!baz || !baz.bar || !baz.bar.foo);`, - }, - ], + suggestions: null, }, { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: noFormat`(!foo || !foo.bar || !foo.bar.baz) && (!baz?.bar?.foo);`, - }, - ], + suggestions: null, }, ], }, @@ -1435,54 +1279,39 @@ foo?.bar(/* comment */a, ], }, { - code: noFormat`import.meta && import.meta?.baz;`, - output: null, + code: 'import.meta && import.meta?.baz;', + output: 'import.meta?.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: noFormat`import.meta?.baz;`, - }, - ], + suggestions: null, }, ], }, { - code: noFormat`!import.meta || !import.meta?.baz;`, - output: null, + code: '!import.meta || !import.meta?.baz;', + output: '!import.meta?.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: noFormat`!import.meta?.baz;`, - }, - ], + suggestions: null, }, ], }, { - code: noFormat`import.meta && import.meta?.() && import.meta?.().baz;`, - output: null, + code: 'import.meta && import.meta?.() && import.meta?.().baz;', + output: 'import.meta?.()?.baz;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: noFormat`import.meta?.()?.baz;`, - }, - ], + suggestions: null, }, ], }, // non-null expressions { code: '!foo() || !foo().bar;', - output: null, + output: '!foo()?.bar;', errors: [ { messageId: 'preferOptionalChain', @@ -1492,7 +1321,7 @@ foo?.bar(/* comment */a, }, { code: '!foo!.bar || !foo!.bar.baz;', - output: null, + output: '!foo!.bar?.baz;', errors: [ { messageId: 'preferOptionalChain', @@ -1502,7 +1331,7 @@ foo?.bar(/* comment */a, }, { code: '!foo!.bar!.baz || !foo!.bar!.baz!.paz;', - output: null, + output: '!foo!.bar!.baz?.paz;', errors: [ { messageId: 'preferOptionalChain', @@ -1512,7 +1341,7 @@ foo?.bar(/* comment */a, }, { code: '!foo.bar!.baz || !foo.bar!.baz!.paz;', - output: null, + output: '!foo.bar!.baz?.paz;', errors: [ { messageId: 'preferOptionalChain', @@ -1521,7 +1350,10 @@ foo?.bar(/* comment */a, ], }, { - code: 'foo !== null && foo.bar !== null;', + code: ` + declare const foo: { bar: string } | null; + foo !== null && foo.bar !== null; + `, output: null, errors: [ { @@ -1529,7 +1361,10 @@ foo?.bar(/* comment */a, suggestions: [ { messageId: 'optionalChainSuggest', - output: 'foo?.bar != null;', + output: ` + declare const foo: { bar: string } | null; + foo?.bar !== null; + `, }, ], }, @@ -1537,21 +1372,19 @@ foo?.bar(/* comment */a, }, { code: 'foo != null && foo.bar != null;', - output: null, + output: 'foo?.bar != null;', errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar != null;', - }, - ], + suggestions: null, }, ], }, { - code: 'foo != null && foo.bar !== null;', + code: ` + declare const foo: { bar: string | null } | null; + foo != null && foo.bar !== null; + `, output: null, errors: [ { @@ -1559,24 +1392,28 @@ foo?.bar(/* comment */a, suggestions: [ { messageId: 'optionalChainSuggest', - output: 'foo?.bar != null;', + output: ` + declare const foo: { bar: string | null } | null; + foo?.bar !== null; + `, }, ], }, ], }, { - code: 'foo !== null && foo.bar != null;', - output: null, + code: ` + declare const foo: { bar: string | null } | null; + foo !== null && foo.bar != null; + `, + output: ` + declare const foo: { bar: string | null } | null; + foo?.bar != null; + `, errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: 'foo?.bar != null;', - }, - ], + suggestions: null, }, ], }, @@ -1643,7 +1480,7 @@ foo?.bar(/* comment */a, }, { code: 'foo?.bar.baz != null && foo.bar?.baz.bam != null;', - output: 'foo?.bar.baz?.bam != null', + output: 'foo?.bar.baz?.bam != null;', errors: [ { messageId: 'preferOptionalChain', @@ -1653,7 +1490,7 @@ foo?.bar(/* comment */a, }, { code: 'foo?.bar?.baz != null && foo.bar.baz.bam != null;', - output: 'foo?.bar?.baz?.bam != null', + output: 'foo?.bar?.baz?.bam != null;', errors: [ { messageId: 'preferOptionalChain', @@ -1661,10 +1498,10 @@ foo?.bar(/* comment */a, }, ], }, - // randomly placed non-null assertions are ignored + // randomly placed non-null assertions are retained as long as they're in an earlier operand { code: 'foo.bar.baz != null && foo!.bar!.baz.bam != null;', - output: 'foo.bar.baz?.bam != null', + output: 'foo.bar.baz?.bam != null;', errors: [ { messageId: 'preferOptionalChain', @@ -1674,7 +1511,7 @@ foo?.bar(/* comment */a, }, { code: 'foo!.bar.baz != null && foo.bar!.baz.bam != null;', - output: 'foo?.bar.baz?.bam != null', + output: 'foo!.bar.baz?.bam != null;', errors: [ { messageId: 'preferOptionalChain', @@ -1684,7 +1521,7 @@ foo?.bar(/* comment */a, }, { code: 'foo!.bar!.baz != null && foo.bar.baz.bam != null;', - output: 'foo?.bar?.baz?.bam != null', + output: 'foo!.bar!.baz?.bam != null;', errors: [ { messageId: 'preferOptionalChain', @@ -1701,11 +1538,15 @@ foo?.bar(/* comment */a, a.b.c !== null && a.b.c.d != null && a.b.c.d.e !== null && - a.b.c.d.e.f !== undefined && + a.b.c.d.e !== undefined && + a.b.c.d.e.f != undefined && typeof a.b.c.d.e.f.g !== 'undefined' && + a.b.c.d.e.f.g !== null && a.b.c.d.e.f.g.h; `, - output: 'a?.b?.c?.d?.e?.f?.g?.h;', + output: ` + a?.b?.c?.d?.e?.f?.g?.h; + `, errors: [ { messageId: 'preferOptionalChain', @@ -1721,11 +1562,15 @@ foo?.bar(/* comment */a, a.b.c === null || a.b.c.d == null || a.b.c.d.e === null || - a.b.c.d.e.f === undefined || + a.b.c.d.e === undefined || + a.b.c.d.e.f == undefined || typeof a.b.c.d.e.f.g === 'undefined' || - a.b.c.d.e.f.g.h; + a.b.c.d.e.f.g === null || + !a.b.c.d.e.f.g.h; + `, + output: ` + !a?.b?.c?.d?.e?.f?.g?.h; `, - output: 'a?.b?.c?.d?.e?.f?.g?.h;', errors: [ { messageId: 'preferOptionalChain', @@ -1741,11 +1586,15 @@ foo?.bar(/* comment */a, a.b.c === undefined || a.b.c.d == null || a.b.c.d.e === null || - a.b.c.d.e.f === undefined || + a.b.c.d.e === undefined || + a.b.c.d.e.f == undefined || typeof a.b.c.d.e.f.g === 'undefined' || - a.b.c.d.e.f.g.h; + a.b.c.d.e.f.g === null || + !a.b.c.d.e.f.g.h; + `, + output: ` + !a?.b?.c?.d?.e?.f?.g?.h; `, - output: 'a?.b?.c?.d?.e?.f?.g?.h;', errors: [ { messageId: 'preferOptionalChain', @@ -1755,7 +1604,7 @@ foo?.bar(/* comment */a, }, // yoda checks are flagged { - code: 'undefined !== foo && null !== foo.bar && foo.bar.baz;', + code: 'undefined !== foo && null !== foo && null != foo.bar && foo.bar.baz;', output: 'foo?.bar?.baz;', errors: [ { @@ -1765,8 +1614,164 @@ foo?.bar(/* comment */a, ], }, { - code: "null != foo && 'undefined' !== typeof foo.bar && foo.bar.baz;", - output: 'foo?.bar?.baz;', + code: ` + null != foo && + 'undefined' !== typeof foo.bar && + null !== foo.bar && + foo.bar.baz; + `, + output: ` + foo?.bar?.baz; + `, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: ` + null != foo && + 'undefined' !== typeof foo.bar && + null !== foo.bar && + null != foo.bar.baz; + `, + output: ` + null != foo?.bar?.baz; + `, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + // We should retain the split strict equals check if it's the last operand + { + code: ` + null != foo && + 'undefined' !== typeof foo.bar && + null !== foo.bar && + null !== foo.bar.baz && + 'undefined' !== typeof foo.bar.baz; + `, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: ` + null !== foo?.bar?.baz && + 'undefined' !== typeof foo.bar.baz; + `, + }, + ], + }, + ], + }, + { + code: ` + foo != null && + typeof foo.bar !== 'undefined' && + foo.bar !== null && + foo.bar.baz !== null && + typeof foo.bar.baz !== 'undefined'; + `, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: ` + foo?.bar?.baz !== null && + typeof foo.bar.baz !== 'undefined'; + `, + }, + ], + }, + ], + }, + { + code: ` + null != foo && + 'undefined' !== typeof foo.bar && + null !== foo.bar && + null !== foo.bar.baz && + undefined !== foo.bar.baz; + `, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: ` + null !== foo?.bar?.baz && + undefined !== foo.bar.baz; + `, + }, + ], + }, + ], + }, + { + code: ` + foo != null && + typeof foo.bar !== 'undefined' && + foo.bar !== null && + foo.bar.baz !== null && + foo.bar.baz !== undefined; + `, + output: null, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: ` + foo?.bar?.baz !== null && + foo.bar.baz !== undefined; + `, + }, + ], + }, + ], + }, + { + code: ` + null != foo && + 'undefined' !== typeof foo.bar && + null !== foo.bar && + undefined !== foo.bar.baz && + null !== foo.bar.baz; + `, + output: ` + undefined !== foo?.bar?.baz && + null !== foo.bar.baz; + `, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: ` + foo != null && + typeof foo.bar !== 'undefined' && + foo.bar !== null && + foo.bar.baz !== undefined && + foo.bar.baz !== null; + `, + output: ` + foo?.bar?.baz !== undefined && + foo.bar.baz !== null; + `, errors: [ { messageId: 'preferOptionalChain', @@ -1785,6 +1790,31 @@ foo?.bar(/* comment */a, }, ], }, + // TODO - should we handle this case and expand the range, or should we leave this as is? + { + code: ` + !a || + a.b == null || + a.b.c === undefined || + a.b.c === null || + a.b.c.d == null || + a.b.c.d.e === null || + a.b.c.d.e === undefined || + a.b.c.d.e.f == undefined || + a.b.c.d.e.f.g == null || + a.b.c.d.e.f.g.h; + `, + output: ` + a?.b?.c?.d?.e?.f?.g == null || + a.b.c.d.e.f.g.h; + `, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, ], }); }); @@ -1794,89 +1824,56 @@ describe('base cases', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], invalid: [ - ...BaseCases.all(), - // it should ignore whitespace in the expressions - ...BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/\./g, '. '), - })), - ...BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/\./g, '.\n'), - })), + ...BaseCases('&&'), // it should ignore parts of the expression that aren't part of the expression chain - ...BaseCases.all().map(c => ({ - ...c, - code: `${c.code} && bing`, - errors: [ - { - ...c.errors[0], - suggestions: [ - { - ...c.errors[0].suggestions![0], - output: `${c.errors[0].suggestions![0].output} && bing`, - }, - ], - }, - ], - })), - ...BaseCases.all().map(c => ({ - ...c, - code: `${c.code} && bing.bong`, - errors: [ - { - ...c.errors[0], - suggestions: [ - { - ...c.errors[0].suggestions![0], - output: `${c.errors[0].suggestions![0].output} && bing.bong`, - }, - ], - }, - ], - })), + ...BaseCases('&&', c => `${c} && bing`), + ...BaseCases('&&', c => `${c} && bing.bong`), ], }); - }); - describe('strict nullish equality checks', () => { - describe('!== null', () => { - ruleTester.run('prefer-optional-chain', rule, { - valid: [], - invalid: BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!== null &&'), - })), + describe('strict nullish equality checks', () => { + describe('!== null', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases( + '&&', + c => c.replace(/&&/g, '!== null &&'), + identity, + ), + }); }); - }); - describe('!= null', () => { - ruleTester.run('prefer-optional-chain', rule, { - valid: [], - invalid: BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!= null &&'), - })), + describe('!= null', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases( + '&&', + c => c.replace(/&&/g, '!= null &&'), + identity, + ), + }); }); - }); - describe('!== undefined', () => { - ruleTester.run('prefer-optional-chain', rule, { - valid: [], - invalid: BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!== undefined &&'), - })), + describe('!== undefined', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases( + '&&', + c => c.replace(/&&/g, '!== undefined &&'), + identity, + ), + }); }); - }); - describe('!= undefined', () => { - ruleTester.run('prefer-optional-chain', rule, { - valid: [], - invalid: BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/&&/g, '!= undefined &&'), - })), + describe('!= undefined', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases( + '&&', + c => c.replace(/&&/g, '!= undefined &&'), + identity, + ), + }); }); }); }); @@ -1884,23 +1881,53 @@ describe('base cases', () => { describe('or', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], - invalid: BaseCases.select('canReplaceAndWithOr', true) - .all() - .map(c => ({ - ...c, - code: c.code.replace(/(^|\s)foo/g, '$1!foo').replace(/&&/g, '||'), - errors: [ - { - ...c.errors[0], - suggestions: [ - { - ...c.errors[0].suggestions![0], - output: `!${c.errors[0].suggestions![0].output}`, - }, - ], - }, - ], - })), + invalid: BaseCases('||', identity, c => `!${c}`), + }); + + describe('strict nullish equality checks', () => { + describe('=== null', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases( + '||', + c => c.replace(/\|\|/g, '=== null ||'), + identity, + ), + }); + }); + + describe('== null', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases( + '||', + c => c.replace(/\|\|/g, '== null ||'), + identity, + ), + }); + }); + + describe('=== undefined', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases( + '||', + c => c.replace(/\|\|/g, '=== undefined ||'), + identity, + ), + }); + }); + + describe('== undefined', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases( + '||', + c => c.replace(/\|\|/g, '== undefined ||'), + identity, + ), + }); + }); }); }); @@ -1909,14 +1936,19 @@ describe('base cases', () => { valid: [], invalid: [ // it should ignore whitespace in the expressions - ...BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/\./g, '. '), - })), - ...BaseCases.all().map(c => ({ - ...c, - code: c.code.replace(/\./g, '.\n'), - })), + ...BaseCases( + '&&', + c => c.replace(/\./g, '. '), + // note - the rule will use raw text for computed expressions - so we + // need to ensure that the spacing for the computed member + // expressions is retained for correct fixer matching + c => c.replace(/(\[.+\])/g, m => m.replace(/\./g, '. ')), + ), + ...BaseCases( + '&&', + c => c.replace(/\./g, '.\n'), + c => c.replace(/(\[.+\])/g, m => m.replace(/\./g, '.\n')), + ), ], }); }); @@ -1926,36 +1958,8 @@ describe('base cases', () => { valid: [], invalid: [ // it should ignore parts of the expression that aren't part of the expression chain - ...BaseCases.all().map(c => ({ - ...c, - code: `${c.code} && bing`, - errors: [ - { - ...c.errors[0], - suggestions: [ - { - ...c.errors[0].suggestions![0], - output: `${c.errors[0].suggestions![0].output} && bing`, - }, - ], - }, - ], - })), - ...BaseCases.all().map(c => ({ - ...c, - code: `${c.code} && bing.bong`, - errors: [ - { - ...c.errors[0], - suggestions: [ - { - ...c.errors[0].suggestions![0], - output: `${c.errors[0].suggestions![0].output} && bing.bong`, - }, - ], - }, - ], - })), + ...BaseCases('&&', c => `${c} && bing`), + ...BaseCases('&&', c => `${c} && bing.bong`), ], }); }); From 3071db77d5307752675254fbf1e21f8cc5a7e514 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 23 May 2023 17:17:41 +0930 Subject: [PATCH 18/26] modularise the rule --- .cspell.json | 1 - .../PreferOptionalChainOptions.ts | 14 + .../analyzeChain.ts | 556 +++++++ .../compareNodes.ts | 412 +++++ .../gatherLogicalOperands.ts | 371 +++++ .../src/rules/prefer-optional-chain.ts | 1343 +---------------- .../prefer-optional-chain.test.ts | 84 +- 7 files changed, 1413 insertions(+), 1368 deletions(-) create mode 100644 packages/eslint-plugin/src/rules/prefer-optional-chain-utils/PreferOptionalChainOptions.ts create mode 100644 packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts create mode 100644 packages/eslint-plugin/src/rules/prefer-optional-chain-utils/compareNodes.ts create mode 100644 packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts diff --git a/.cspell.json b/.cspell.json index 82164063c3ef..044fb2b43420 100644 --- a/.cspell.json +++ b/.cspell.json @@ -81,7 +81,6 @@ "esquery", "esrecurse", "estree", - "falsey", "falsiness", "globby", "IDE's", diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/PreferOptionalChainOptions.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/PreferOptionalChainOptions.ts new file mode 100644 index 000000000000..b755c9463954 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/PreferOptionalChainOptions.ts @@ -0,0 +1,14 @@ +export type PreferOptionalChainMessageIds = + | 'preferOptionalChain' + | 'optionalChainSuggest'; + +export interface PreferOptionalChainOptions { + checkAny?: boolean; + checkUnknown?: boolean; + checkString?: boolean; + checkNumber?: boolean; + checkBoolean?: boolean; + checkBigInt?: boolean; + requireNullish?: boolean; + allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing?: boolean; +} diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts new file mode 100644 index 000000000000..29466693ca2d --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts @@ -0,0 +1,556 @@ +import type { + ParserServicesWithTypeInformation, + TSESTree, +} from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import type { + ReportDescriptor, + ReportFixFunction, + RuleContext, + SourceCode, +} from '@typescript-eslint/utils/ts-eslint'; +import { unionTypeParts } from 'ts-api-utils'; +import * as ts from 'typescript'; + +import * as util from '../../util'; +import { compareNodes, NodeComparisonResult } from './compareNodes'; +import type { ValidOperand } from './gatherLogicalOperands'; +import { NullishComparisonType } from './gatherLogicalOperands'; +import type { + PreferOptionalChainMessageIds, + PreferOptionalChainOptions, +} from './PreferOptionalChainOptions'; + +function includesType( + parserServices: ParserServicesWithTypeInformation, + node: TSESTree.Node, + typeFlagIn: ts.TypeFlags, +): boolean { + const typeFlag = typeFlagIn | ts.TypeFlags.Any | ts.TypeFlags.Unknown; + const types = unionTypeParts(parserServices.getTypeAtLocation(node)); + for (const type of types) { + if (util.isTypeFlagSet(type, typeFlag)) { + return true; + } + } + return false; +} + +// I hate that these functions are identical aside from the enum values used +// I can't think of a good way to reuse the code here in a way that will preserve +// the type safety and simplicity. + +type OperandAnalyzer = ( + parserServices: ParserServicesWithTypeInformation, + operand: ValidOperand, + index: number, + chain: readonly ValidOperand[], +) => readonly [ValidOperand] | readonly [ValidOperand, ValidOperand] | null; +const analyzeAndChainOperand: OperandAnalyzer = ( + parserServices, + operand, + index, + chain, +) => { + switch (operand.comparisonType) { + case NullishComparisonType.Boolean: + case NullishComparisonType.NotEqualNullOrUndefined: + return [operand]; + + case NullishComparisonType.NotStrictEqualNull: { + // handle `x !== null && x !== undefined` + const nextOperand = chain[index + 1] as ValidOperand | undefined; + if ( + nextOperand?.comparisonType === + NullishComparisonType.NotStrictEqualUndefined && + compareNodes(operand.comparedName, nextOperand.comparedName) === + NodeComparisonResult.Equal + ) { + return [operand, nextOperand]; + } + if ( + includesType( + parserServices, + operand.comparedName, + ts.TypeFlags.Undefined, + ) + ) { + // we know the next operand is not an `undefined` check and that this + // operand includes `undefined` - which means that making this an + // optional chain would change the runtime behavior of the expression + return null; + } + + return [operand]; + } + + case NullishComparisonType.NotStrictEqualUndefined: { + // handle `x !== undefined && x !== null` + const nextOperand = chain[index + 1] as ValidOperand | undefined; + if ( + nextOperand?.comparisonType === + NullishComparisonType.NotStrictEqualNull && + compareNodes(operand.comparedName, nextOperand.comparedName) === + NodeComparisonResult.Equal + ) { + return [operand, nextOperand]; + } + if ( + includesType( + parserServices, + operand.comparedName, + ts.TypeFlags.Undefined, + ) + ) { + // we know the next operand is not a `null` check and that this + // operand includes `null` - which means that making this an + // optional chain would change the runtime behavior of the expression + return null; + } + + return [operand]; + } + + default: + return null; + } +}; +const analyzeOrChainOperand: OperandAnalyzer = ( + parserServices, + operand, + index, + chain, +) => { + switch (operand.comparisonType) { + case NullishComparisonType.NotBoolean: + case NullishComparisonType.EqualNullOrUndefined: + return [operand]; + + case NullishComparisonType.StrictEqualNull: { + // handle `x === null || x === undefined` + const nextOperand = chain[index + 1] as ValidOperand | undefined; + if ( + nextOperand?.comparisonType === + NullishComparisonType.StrictEqualUndefined && + compareNodes(operand.comparedName, nextOperand.comparedName) === + NodeComparisonResult.Equal + ) { + return [operand, nextOperand]; + } + if ( + includesType( + parserServices, + operand.comparedName, + ts.TypeFlags.Undefined, + ) + ) { + // we know the next operand is not an `undefined` check and that this + // operand includes `undefined` - which means that making this an + // optional chain would change the runtime behavior of the expression + return null; + } + + return [operand]; + } + + case NullishComparisonType.StrictEqualUndefined: { + // handle `x === undefined || x === null` + const nextOperand = chain[index + 1] as ValidOperand | undefined; + if ( + nextOperand?.comparisonType === NullishComparisonType.StrictEqualNull && + compareNodes(operand.comparedName, nextOperand.comparedName) === + NodeComparisonResult.Equal + ) { + return [operand, nextOperand]; + } + if ( + includesType( + parserServices, + operand.comparedName, + ts.TypeFlags.Undefined, + ) + ) { + // we know the next operand is not a `null` check and that this + // operand includes `null` - which means that making this an + // optional chain would change the runtime behavior of the expression + return null; + } + + return [operand]; + } + + default: + return null; + } +}; + +function getFixer( + sourceCode: SourceCode, + parserServices: ParserServicesWithTypeInformation, + operator: '&&' | '||', + options: PreferOptionalChainOptions, + chain: ValidOperand[], +): + | { + suggest: NonNullable< + ReportDescriptor['suggest'] + >; + } + | { + fix: NonNullable['fix']>; + } { + const lastOperand = chain[chain.length - 1]; + + let useSuggestionFixer: boolean; + if ( + options.allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing === + true + ) { + // user has opted-in to the unsafe behavior + useSuggestionFixer = false; + } else { + // optional chain specifically will union `undefined` into the final type + // so we need to make sure that there is at least one operand that includes + // `undefined`, or else we're going to change the final type - which is + // unsafe and might cause downstream type errors. + + if ( + lastOperand.comparisonType === + NullishComparisonType.EqualNullOrUndefined || + lastOperand.comparisonType === + NullishComparisonType.NotEqualNullOrUndefined || + lastOperand.comparisonType === + NullishComparisonType.StrictEqualUndefined || + lastOperand.comparisonType === + NullishComparisonType.NotStrictEqualUndefined || + (operator === '||' && + lastOperand.comparisonType === NullishComparisonType.NotBoolean) + ) { + // we know the last operand is an equality check - so the change in types + // DOES NOT matter and will not change the runtime result or cause a type + // check error + useSuggestionFixer = false; + } else { + useSuggestionFixer = true; + + for (const operand of chain) { + if ( + includesType(parserServices, operand.node, ts.TypeFlags.Undefined) + ) { + useSuggestionFixer = false; + break; + } + } + + // TODO - we could further reduce the false-positive rate of this check by + // checking for cases where the change in types don't matter like + // the test location of an if/while/etc statement. + // but it's quite complex to do this without false-negatives, so + // for now we'll just be over-eager with our matching. + } + } + + // In its most naive form we could just slap `?.` for every single part of the + // chain. However this would be undesirable because it'd create unnecessary + // conditions in the user's code where there were none before - and it would + // cause errors with rules like our `no-unnecessary-condition`. + // + // Instead we want to include the minimum number of `?.` required to correctly + // unify the code into a single chain. Naively you might think that we can + // just take the final operand add `?.` after the locations from the previous + // operands - however this won't be correct either because earlier operands + // can include a necessary `?.` that's not needed or included in a later + // operand. + // + // So instead what we need to do is to start at the first operand and + // iteratively diff it against the next operand, and add the difference to the + // first operand. + // + // eg + // `foo && foo.bar && foo.bar.baz?.bam && foo.bar.baz.bam()` + // 1) `foo` + // 2) diff(`foo`, `foo.bar`) = `.bar` + // 3) result = `foo?.bar` + // 4) diff(`foo.bar`, `foo.bar.baz?.bam`) = `.baz?.bam` + // 5) result = `foo?.bar?.baz?.bam` + // 6) diff(`foo.bar.baz?.bam`, `foo.bar.baz.bam()`) = `()` + // 7) result = `foo?.bar?.baz?.bam?.()` + + const parts = []; + for (const current of chain) { + const nextOperand = flattenChainExpression( + sourceCode, + current.comparedName, + ); + const diff = nextOperand.slice(parts.length); + if (diff.length > 0) { + if (parts.length > 0) { + // we need to make the first operand of the diff optional so it matches the + // logic before merging + // foo.bar && foo.bar.baz + // diff = .baz + // result = foo.bar?.baz + diff[0].optional = true; + } + parts.push(...diff); + } + } + + let newCode = parts + .map(part => { + let str = ''; + if (part.optional) { + str += '?.'; + } else { + if (part.nonNull) { + str += '!'; + } + if (part.requiresDot) { + str += '.'; + } + } + if ( + part.precedence !== util.OperatorPrecedence.Invalid && + part.precedence < util.OperatorPrecedence.Member + ) { + str += `(${part.text})`; + } else { + str += part.text; + } + return str; + }) + .join(''); + + if (lastOperand.node.type === AST_NODE_TYPES.BinaryExpression) { + // retain the ending comparison for cases like + // x && x.a != null + // x && typeof x.a !== 'undefined' + const operator = lastOperand.node.operator; + const { left, right } = (() => { + if (lastOperand.isYoda) { + const unaryOperator = + lastOperand.node.right.type === AST_NODE_TYPES.UnaryExpression + ? lastOperand.node.right.operator + ' ' + : ''; + + return { + left: sourceCode.getText(lastOperand.node.left), + right: unaryOperator + newCode, + }; + } else { + const unaryOperator = + lastOperand.node.left.type === AST_NODE_TYPES.UnaryExpression + ? lastOperand.node.left.operator + ' ' + : ''; + return { + left: unaryOperator + newCode, + right: sourceCode.getText(lastOperand.node.right), + }; + } + })(); + + newCode = `${left} ${operator} ${right}`; + } else if (lastOperand.comparisonType === NullishComparisonType.NotBoolean) { + newCode = `!${newCode}`; + } + + const fix: ReportFixFunction = fixer => + fixer.replaceTextRange( + [chain[0].node.range[0], lastOperand.node.range[1]], + newCode, + ); + + return useSuggestionFixer + ? { suggest: [{ fix, messageId: 'optionalChainSuggest' }] } + : { fix }; + + interface FlattenedChain { + nonNull: boolean; + optional: boolean; + precedence: util.OperatorPrecedence; + requiresDot: boolean; + text: string; + } + function flattenChainExpression( + sourceCode: SourceCode, + node: TSESTree.Node, + ): FlattenedChain[] { + switch (node.type) { + case AST_NODE_TYPES.ChainExpression: + return flattenChainExpression(sourceCode, node.expression); + + case AST_NODE_TYPES.CallExpression: { + const argumentsText = (() => { + const closingParenToken = util.nullThrows( + sourceCode.getLastToken(node), + util.NullThrowsReasons.MissingToken( + 'closing parenthesis', + node.type, + ), + ); + const openingParenToken = util.nullThrows( + sourceCode.getFirstTokenBetween( + node.typeArguments ?? node.callee, + closingParenToken, + util.isOpeningParenToken, + ), + util.NullThrowsReasons.MissingToken( + 'opening parenthesis', + node.type, + ), + ); + return sourceCode.text.substring( + openingParenToken.range[0], + closingParenToken.range[1], + ); + })(); + + const typeArgumentsText = (() => { + if (node.typeArguments == null) { + return ''; + } + + return sourceCode.getText(node.typeArguments); + })(); + + return [ + ...flattenChainExpression(sourceCode, node.callee), + { + nonNull: false, + optional: node.optional, + // no precedence for this + precedence: util.OperatorPrecedence.Invalid, + requiresDot: false, + text: typeArgumentsText + argumentsText, + }, + ]; + } + + case AST_NODE_TYPES.MemberExpression: { + const propertyText = sourceCode.getText(node.property); + return [ + ...flattenChainExpression(sourceCode, node.object), + { + nonNull: node.object.type === AST_NODE_TYPES.TSNonNullExpression, + optional: node.optional, + precedence: node.computed + ? // computed is already wrapped in [] so no need to wrap in () as well + util.OperatorPrecedence.Invalid + : util.getOperatorPrecedenceForNode(node.property), + requiresDot: !node.computed, + text: node.computed ? `[${propertyText}]` : propertyText, + }, + ]; + } + + case AST_NODE_TYPES.TSNonNullExpression: + return flattenChainExpression(sourceCode, node.expression); + + default: + return [ + { + nonNull: false, + optional: false, + precedence: util.getOperatorPrecedenceForNode(node), + requiresDot: false, + text: sourceCode.getText(node), + }, + ]; + } + } +} + +export function analyzeChain( + context: RuleContext< + PreferOptionalChainMessageIds, + [PreferOptionalChainOptions] + >, + sourceCode: SourceCode, + parserServices: ParserServicesWithTypeInformation, + options: PreferOptionalChainOptions, + operator: TSESTree.LogicalExpression['operator'], + chain: ValidOperand[], +): void { + // need at least 2 operands in a chain for it to be a chain + if ( + chain.length <= 1 || + /* istanbul ignore next -- previous checks make this unreachable, but keep it for exhaustiveness check */ + operator === '??' + ) { + return; + } + + const analyzeOperand = ((): OperandAnalyzer | null => { + switch (operator) { + case '&&': + return analyzeAndChainOperand; + + case '||': + return analyzeOrChainOperand; + } + })(); + if (!analyzeOperand) { + return; + } + + let subChain: ValidOperand[] = []; + const maybeReportThenReset = ( + newChainSeed?: readonly ValidOperand[], + ): void => { + if (subChain.length > 1) { + context.report({ + messageId: 'preferOptionalChain', + loc: { + start: subChain[0].node.loc.start, + end: subChain[subChain.length - 1].node.loc.end, + }, + ...getFixer(sourceCode, parserServices, operator, options, subChain), + }); + } + + // we've reached the end of a chain of logical expressions + // we know the validated + subChain = newChainSeed ? [...newChainSeed] : []; + }; + + for (let i = 0; i < chain.length; i += 1) { + const lastOperand = subChain[subChain.length - 1] as + | ValidOperand + | undefined; + const operand = chain[i]; + + const validatedOperands = analyzeOperand(parserServices, operand, i, chain); + if (!validatedOperands) { + maybeReportThenReset(); + continue; + } + // in case multiple operands were consumed - make sure to correctly increment the index + i += validatedOperands.length - 1; + + const currentOperand = validatedOperands[0]; + if (lastOperand) { + const comparisonResult = compareNodes( + lastOperand.comparedName, + // purposely inspect and push the last operand because the prior operands don't matter + // this also means we won't false-positive in cases like + // foo !== null && foo !== undefined + validatedOperands[validatedOperands.length - 1].comparedName, + ); + if (comparisonResult === NodeComparisonResult.Subset) { + // the operands are comparable, so we can continue searching + subChain.push(currentOperand); + } else if (comparisonResult === NodeComparisonResult.Invalid) { + maybeReportThenReset(validatedOperands); + } else if (comparisonResult === NodeComparisonResult.Equal) { + // purposely don't push this case because the node is a no-op and if + // we consider it then we might report on things like + // foo && foo + } + } else { + subChain.push(currentOperand); + } + } + + // check the leftovers + maybeReportThenReset(); +} diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/compareNodes.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/compareNodes.ts new file mode 100644 index 000000000000..d9dce486ec91 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/compareNodes.ts @@ -0,0 +1,412 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import { visitorKeys } from '@typescript-eslint/visitor-keys'; + +export const enum NodeComparisonResult { + /** the two nodes are comparably the same */ + Equal = 'Equal', + /** the left node is a subset of the right node */ + Subset = 'Subset', + /** the left node is not the same or is a superset of the right node */ + Invalid = 'Invalid', +} + +function compareArrays( + arrayA: unknown[], + arrayB: unknown[], +): NodeComparisonResult.Equal | NodeComparisonResult.Invalid { + if (arrayA.length !== arrayB.length) { + return NodeComparisonResult.Invalid; + } + + const result = arrayA.every((elA, idx) => { + const elB = arrayB[idx]; + if (elA == null || elB == null) { + return elA === elB; + } + return compareUnknownValues(elA, elB) === NodeComparisonResult.Equal; + }); + if (result) { + return NodeComparisonResult.Equal; + } + return NodeComparisonResult.Invalid; +} + +function isValidNode(x: unknown): x is TSESTree.Node { + return ( + typeof x === 'object' && + x != null && + 'type' in x && + typeof x.type === 'string' + ); +} +function isValidChainExpressionToLookThrough( + node: TSESTree.Node, +): node is TSESTree.ChainExpression { + return ( + !( + node.parent?.type === AST_NODE_TYPES.MemberExpression && + node.parent.object === node + ) && + !( + node.parent?.type === AST_NODE_TYPES.CallExpression && + node.parent.callee === node + ) && + node.type === AST_NODE_TYPES.ChainExpression + ); +} +function compareUnknownValues( + valueA: unknown, + valueB: unknown, +): NodeComparisonResult { + /* istanbul ignore if -- not possible for us to test this - it's just a sanity safeguard */ + if (valueA == null || valueB == null) { + if (valueA !== valueB) { + return NodeComparisonResult.Invalid; + } + return NodeComparisonResult.Equal; + } + + /* istanbul ignore if -- not possible for us to test this - it's just a sanity safeguard */ + if (!isValidNode(valueA) || !isValidNode(valueB)) { + return NodeComparisonResult.Invalid; + } + + return compareNodes(valueA, valueB); +} +function compareByVisiting( + nodeA: TSESTree.Node, + nodeB: TSESTree.Node, +): NodeComparisonResult.Equal | NodeComparisonResult.Invalid { + const currentVisitorKeys = visitorKeys[nodeA.type]; + /* istanbul ignore if -- not possible for us to test this - it's just a sanity safeguard */ + if (currentVisitorKeys == null) { + // we don't know how to visit this node, so assume it's invalid to avoid false-positives / broken fixers + return NodeComparisonResult.Invalid; + } + + if (currentVisitorKeys.length === 0) { + // assume nodes with no keys are constant things like keywords + return NodeComparisonResult.Equal; + } + + for (const key of currentVisitorKeys) { + // @ts-expect-error - dynamic access but it's safe + const nodeAChildOrChildren = nodeA[key] as unknown; + // @ts-expect-error - dynamic access but it's safe + const nodeBChildOrChildren = nodeB[key] as unknown; + + if (Array.isArray(nodeAChildOrChildren)) { + const arrayA = nodeAChildOrChildren as unknown[]; + const arrayB = nodeBChildOrChildren as unknown[]; + + const result = compareArrays(arrayA, arrayB); + if (result !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + // fallthrough to the next key as the key was "equal" + } else { + const result = compareUnknownValues( + nodeAChildOrChildren, + nodeBChildOrChildren, + ); + if (result !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + // fallthrough to the next key as the key was "equal" + } + } + + return NodeComparisonResult.Equal; +} +type CompareNodesArgument = TSESTree.Node | null | undefined; +function compareNodesUncached( + nodeA: TSESTree.Node, + nodeB: TSESTree.Node, +): NodeComparisonResult { + if (nodeA.type !== nodeB.type) { + // special cases where nodes are allowed to be non-equal + + // look through a chain expression node at the top-level because it only + // exists to delimit the end of an optional chain + // + // a?.b && a.b.c + // ^^^^ ChainExpression, MemberExpression + // ^^^^^ MemberExpression + // + // except for in this class of cases + // (a?.b).c && a.b.c + // because the parentheses have runtime meaning (sad face) + if (isValidChainExpressionToLookThrough(nodeA)) { + return compareNodes(nodeA.expression, nodeB); + } + if (isValidChainExpressionToLookThrough(nodeB)) { + return compareNodes(nodeA, nodeB.expression); + } + + // look through the type-only non-null assertion because its existence could + // possibly be replaced by an optional chain instead + // + // a.b! && a.b.c + // ^^^^ TSNonNullExpression + if (nodeA.type === AST_NODE_TYPES.TSNonNullExpression) { + return compareNodes(nodeA.expression, nodeB); + } + if (nodeB.type === AST_NODE_TYPES.TSNonNullExpression) { + return compareNodes(nodeA, nodeB.expression); + } + + // special case for subset optional chains where the node types don't match, + // but we want to try comparing by discarding the "extra" code + // + // a && a.b + // ^ compare this + // a && a() + // ^ compare this + // a.b && a.b() + // ^^^ compare this + // a() && a().b + // ^^^ compare this + // import.meta && import.meta.b + // ^^^^^^^^^^^ compare this + if ( + nodeA.type === AST_NODE_TYPES.CallExpression || + nodeA.type === AST_NODE_TYPES.Identifier || + nodeA.type === AST_NODE_TYPES.MemberExpression || + nodeA.type === AST_NODE_TYPES.MetaProperty + ) { + switch (nodeB.type) { + case AST_NODE_TYPES.MemberExpression: + if (nodeB.property.type === AST_NODE_TYPES.PrivateIdentifier) { + // Private identifiers in optional chaining is not currently allowed + // TODO - handle this once TS supports it (https://github.com/microsoft/TypeScript/issues/42734) + return NodeComparisonResult.Invalid; + } + if ( + compareNodes(nodeA, nodeB.object) !== NodeComparisonResult.Invalid + ) { + return NodeComparisonResult.Subset; + } + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.CallExpression: + if ( + compareNodes(nodeA, nodeB.callee) !== NodeComparisonResult.Invalid + ) { + return NodeComparisonResult.Subset; + } + return NodeComparisonResult.Invalid; + + default: + return NodeComparisonResult.Invalid; + } + } + + return NodeComparisonResult.Invalid; + } + + switch (nodeA.type) { + // these expressions create a new instance each time - so it makes no sense to compare the chain + case AST_NODE_TYPES.ArrayExpression: + case AST_NODE_TYPES.ArrowFunctionExpression: + case AST_NODE_TYPES.ClassExpression: + case AST_NODE_TYPES.FunctionExpression: + case AST_NODE_TYPES.JSXElement: + case AST_NODE_TYPES.JSXFragment: + case AST_NODE_TYPES.NewExpression: + case AST_NODE_TYPES.ObjectExpression: + return NodeComparisonResult.Invalid; + + // chaining from assignments could change the value irrevocably - so it makes no sense to compare the chain + case AST_NODE_TYPES.AssignmentExpression: + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.CallExpression: { + const nodeBCall = nodeB as typeof nodeA; + + // check for cases like + // foo() && foo()(bar) + // ^^^^^ nodeA + // ^^^^^^^^^^ nodeB + // we don't want to check the arguments in this case + const aSubsetOfB = compareNodes(nodeA, nodeBCall.callee); + if (aSubsetOfB !== NodeComparisonResult.Invalid) { + return NodeComparisonResult.Subset; + } + + const calleeCompare = compareNodes(nodeA.callee, nodeBCall.callee); + if (calleeCompare !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + + // NOTE - we purposely ignore optional flag because for our purposes + // foo?.bar() && foo.bar?.()?.baz + // or + // foo.bar() && foo?.bar?.()?.baz + // are going to be exactly the same + + const argumentCompare = compareArrays( + nodeA.arguments, + nodeBCall.arguments, + ); + if (argumentCompare !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + + const typeParamCompare = compareNodes( + nodeA.typeArguments, + nodeBCall.typeArguments, + ); + if (typeParamCompare === NodeComparisonResult.Equal) { + return NodeComparisonResult.Equal; + } + + return NodeComparisonResult.Invalid; + } + + case AST_NODE_TYPES.ChainExpression: + // special case handling for ChainExpression because it's allowed to be a subset + return compareNodes(nodeA, (nodeB as typeof nodeA).expression); + + case AST_NODE_TYPES.Identifier: + case AST_NODE_TYPES.PrivateIdentifier: + if (nodeA.name === (nodeB as typeof nodeA).name) { + return NodeComparisonResult.Equal; + } + return NodeComparisonResult.Invalid; + + case AST_NODE_TYPES.Literal: { + const nodeBLiteral = nodeB as typeof nodeA; + if ( + nodeA.raw === nodeBLiteral.raw && + nodeA.value === nodeBLiteral.value + ) { + return NodeComparisonResult.Equal; + } + return NodeComparisonResult.Invalid; + } + + case AST_NODE_TYPES.MemberExpression: { + const nodeBMember = nodeB as typeof nodeA; + + if (nodeBMember.property.type === AST_NODE_TYPES.PrivateIdentifier) { + // Private identifiers in optional chaining is not currently allowed + // TODO - handle this once TS supports it (https://github.com/microsoft/TypeScript/issues/42734) + return NodeComparisonResult.Invalid; + } + + // check for cases like + // foo.bar && foo.bar.baz + // ^^^^^^^ nodeA + // ^^^^^^^^^^^ nodeB + // result === Equal + // + // foo.bar && foo.bar.baz.bam + // ^^^^^^^ nodeA + // ^^^^^^^^^^^^^^^ nodeB + // result === Subset + // + // we don't want to check the property in this case + const aSubsetOfB = compareNodes(nodeA, nodeBMember.object); + if (aSubsetOfB !== NodeComparisonResult.Invalid) { + return NodeComparisonResult.Subset; + } + + if (nodeA.computed !== nodeBMember.computed) { + return NodeComparisonResult.Invalid; + } + + // NOTE - we purposely ignore optional flag because for our purposes + // foo?.bar && foo.bar?.baz + // or + // foo.bar && foo?.bar?.baz + // are going to be exactly the same + + const objectCompare = compareNodes(nodeA.object, nodeBMember.object); + if (objectCompare !== NodeComparisonResult.Equal) { + return NodeComparisonResult.Invalid; + } + + return compareNodes(nodeA.property, nodeBMember.property); + } + case AST_NODE_TYPES.TSTemplateLiteralType: + case AST_NODE_TYPES.TemplateLiteral: { + const nodeBTemplate = nodeB as typeof nodeA; + const areQuasisEqual = + nodeA.quasis.length === nodeBTemplate.quasis.length && + nodeA.quasis.every((elA, idx) => { + const elB = nodeBTemplate.quasis[idx]; + return elA.value.cooked === elB.value.cooked; + }); + if (!areQuasisEqual) { + return NodeComparisonResult.Invalid; + } + + return NodeComparisonResult.Equal; + } + + case AST_NODE_TYPES.TemplateElement: { + const nodeBElement = nodeB as typeof nodeA; + if (nodeA.value.cooked === nodeBElement.value.cooked) { + return NodeComparisonResult.Equal; + } + return NodeComparisonResult.Invalid; + } + + // these aren't actually valid expressions. + // https://github.com/typescript-eslint/typescript-eslint/blob/20d7caee35ab84ae6381fdf04338c9e2b9e2bc48/packages/ast-spec/src/unions/Expression.ts#L37-L43 + case AST_NODE_TYPES.ArrayPattern: + case AST_NODE_TYPES.ObjectPattern: + /* istanbul ignore next */ + return NodeComparisonResult.Invalid; + + // update expression returns a number and also changes the value each time - so it makes no sense to compare the chain + case AST_NODE_TYPES.UpdateExpression: + return NodeComparisonResult.Invalid; + + // yield returns the value passed to the `next` function, so it may not be the same each time - so it makes no sense to compare the chain + case AST_NODE_TYPES.YieldExpression: + return NodeComparisonResult.Invalid; + + // general-case automatic handling of nodes to save us implementing every + // single case by hand. This just iterates the visitor keys to recursively + // check the children. + // + // Any specific logic cases or short-circuits should be listed as separate + // cases so that they don't fall into this generic handling + default: + return compareByVisiting(nodeA, nodeB); + } +} +const COMPARE_NODES_CACHE = new WeakMap< + TSESTree.Node, + WeakMap +>(); +/** + * Compares two nodes' ASTs to determine if the A is equal to or a subset of B + */ +export function compareNodes( + nodeA: CompareNodesArgument, + nodeB: CompareNodesArgument, +): NodeComparisonResult { + if (nodeA == null || nodeB == null) { + if (nodeA !== nodeB) { + return NodeComparisonResult.Invalid; + } + return NodeComparisonResult.Equal; + } + + const cached = COMPARE_NODES_CACHE.get(nodeA)?.get(nodeB); + if (cached) { + return cached; + } + + const result = compareNodesUncached(nodeA, nodeB); + let mapA = COMPARE_NODES_CACHE.get(nodeA); + if (mapA == null) { + mapA = new WeakMap(); + COMPARE_NODES_CACHE.set(nodeA, mapA); + } + mapA.set(nodeB, result); + return result; +} diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts new file mode 100644 index 000000000000..5ecb72291872 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts @@ -0,0 +1,371 @@ +import type { + ParserServicesWithTypeInformation, + TSESTree, +} from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import { + isBigIntLiteralType, + isBooleanLiteralType, + isNumberLiteralType, + isStringLiteralType, + unionTypeParts, +} from 'ts-api-utils'; +import * as ts from 'typescript'; + +import * as util from '../../util'; +import type { PreferOptionalChainOptions } from './PreferOptionalChainOptions'; + +const enum ComparisonValueType { + Null = 'Null', // eslint-disable-line @typescript-eslint/internal/prefer-ast-types-enum + Undefined = 'Undefined', + UndefinedStringLiteral = 'UndefinedStringLiteral', +} +export const enum OperandValidity { + Valid = 'Valid', + Invalid = 'Invalid', +} +export const enum NullishComparisonType { + /** `x != null`, `x != undefined` */ + NotEqualNullOrUndefined = 'NotEqualNullOrUndefined', + /** `x == null`, `x == undefined` */ + EqualNullOrUndefined = 'EqualNullOrUndefined', + + /** `x !== null` */ + NotStrictEqualNull = 'NotStrictEqualNull', + /** `x === null` */ + StrictEqualNull = 'StrictEqualNull', + + /** `x !== undefined`, `typeof x !== 'undefined'` */ + NotStrictEqualUndefined = 'NotStrictEqualUndefined', + /** `x === undefined`, `typeof x === 'undefined'` */ + StrictEqualUndefined = 'StrictEqualUndefined', + + /** `!x` */ + NotBoolean = 'NotBoolean', + /** `x` */ + Boolean = 'Boolean', // eslint-disable-line @typescript-eslint/internal/prefer-ast-types-enum +} +export interface ValidOperand { + type: OperandValidity.Valid; + comparedName: TSESTree.Node; + comparisonType: NullishComparisonType; + isYoda: boolean; + node: TSESTree.Expression; +} +export interface InvalidOperand { + type: OperandValidity.Invalid; +} +type Operand = ValidOperand | InvalidOperand; + +const NULLISH_FLAGS = ts.TypeFlags.Null | ts.TypeFlags.Undefined; +function isValidFalseBooleanCheckType( + node: TSESTree.Node, + operator: TSESTree.LogicalExpression['operator'], + checkType: 'true' | 'false', + parserServices: ParserServicesWithTypeInformation, + options: PreferOptionalChainOptions, +): boolean { + const type = parserServices.getTypeAtLocation(node); + const types = unionTypeParts(type); + + const disallowFalseyLiteral = + (operator === '||' && checkType === 'false') || + (operator === '&&' && checkType === 'true'); + if (disallowFalseyLiteral) { + /* + ``` + declare const x: false | {a: string}; + x && x.a; + !x || x.a; + ``` + + We don't want to consider these two cases because the boolean expression + narrows out the non-nullish falsy cases - so converting the chain to `x?.a` + would introduce a build error + */ + if ( + types.some(t => isBooleanLiteralType(t) && t.intrinsicName === 'false') || + types.some(t => isStringLiteralType(t) && t.value === '') || + types.some(t => isNumberLiteralType(t) && t.value === 0) || + types.some(t => isBigIntLiteralType(t) && t.value.base10Value === '0') + ) { + return false; + } + } + + if (options.requireNullish === true) { + return types.some(t => util.isTypeFlagSet(t, NULLISH_FLAGS)); + } + + let allowedFlags = NULLISH_FLAGS | ts.TypeFlags.Object; + if (options.checkAny === true) { + allowedFlags |= ts.TypeFlags.Any; + } + if (options.checkUnknown === true) { + allowedFlags |= ts.TypeFlags.Unknown; + } + if (options.checkString === true) { + allowedFlags |= ts.TypeFlags.StringLike; + } + if (options.checkNumber === true) { + allowedFlags |= ts.TypeFlags.NumberLike; + } + if (options.checkBoolean === true) { + allowedFlags |= ts.TypeFlags.BooleanLike; + } + if (options.checkBigInt === true) { + allowedFlags |= ts.TypeFlags.BigIntLike; + } + return types.every(t => util.isTypeFlagSet(t, allowedFlags)); +} + +export function gatherLogicalOperands( + node: TSESTree.LogicalExpression, + parserServices: ParserServicesWithTypeInformation, + options: PreferOptionalChainOptions, +): { + operands: Operand[]; + seenLogicals: Set; +} { + const result: Operand[] = []; + const { operands, seenLogicals } = flattenLogicalOperands(node); + + for (const operand of operands) { + switch (operand.type) { + case AST_NODE_TYPES.BinaryExpression: { + // check for "yoda" style logical: null != x + + const { comparedExpression, comparedValue, isYoda } = (() => { + // non-yoda checks are by far the most common, so check for them first + const comparedValueRight = getComparisonValueType(operand.right); + if (comparedValueRight) { + return { + comparedExpression: operand.left, + comparedValue: comparedValueRight, + isYoda: false, + }; + } else { + return { + comparedExpression: operand.right, + comparedValue: getComparisonValueType(operand.left), + isYoda: true, + }; + } + })(); + + if (comparedValue === ComparisonValueType.UndefinedStringLiteral) { + if ( + comparedExpression.type === AST_NODE_TYPES.UnaryExpression && + comparedExpression.operator === 'typeof' + ) { + // typeof x === 'undefined' + result.push({ + type: OperandValidity.Valid, + comparedName: comparedExpression.argument, + comparisonType: operand.operator.startsWith('!') + ? NullishComparisonType.NotStrictEqualUndefined + : NullishComparisonType.StrictEqualUndefined, + isYoda, + node: operand, + }); + continue; + } + + // y === 'undefined' + result.push({ type: OperandValidity.Invalid }); + continue; + } + + switch (operand.operator) { + case '!=': + case '==': + if ( + comparedValue === ComparisonValueType.Null || + comparedValue === ComparisonValueType.Undefined + ) { + // x == null, x == undefined + result.push({ + type: OperandValidity.Valid, + comparedName: comparedExpression, + comparisonType: operand.operator.startsWith('!') + ? NullishComparisonType.NotEqualNullOrUndefined + : NullishComparisonType.EqualNullOrUndefined, + isYoda, + node: operand, + }); + continue; + } + // x == something :( + result.push({ type: OperandValidity.Invalid }); + continue; + + case '!==': + case '===': { + const comparedName = comparedExpression; + switch (comparedValue) { + case ComparisonValueType.Null: + result.push({ + type: OperandValidity.Valid, + comparedName, + comparisonType: operand.operator.startsWith('!') + ? NullishComparisonType.NotStrictEqualNull + : NullishComparisonType.StrictEqualNull, + isYoda, + node: operand, + }); + continue; + + case ComparisonValueType.Undefined: + result.push({ + type: OperandValidity.Valid, + comparedName, + comparisonType: operand.operator.startsWith('!') + ? NullishComparisonType.NotStrictEqualUndefined + : NullishComparisonType.StrictEqualUndefined, + isYoda, + node: operand, + }); + continue; + + default: + // x === something :( + result.push({ type: OperandValidity.Invalid }); + continue; + } + } + } + + result.push({ type: OperandValidity.Invalid }); + continue; + } + + case AST_NODE_TYPES.UnaryExpression: + if ( + operand.operator === '!' && + isValidFalseBooleanCheckType( + operand.argument, + node.operator, + 'false', + parserServices, + options, + ) + ) { + result.push({ + type: OperandValidity.Valid, + comparedName: operand.argument, + comparisonType: NullishComparisonType.NotBoolean, + isYoda: false, + node: operand, + }); + continue; + } + result.push({ type: OperandValidity.Invalid }); + continue; + + case AST_NODE_TYPES.LogicalExpression: + // explicitly ignore the mixed logical expression cases + result.push({ type: OperandValidity.Invalid }); + continue; + + default: + if ( + isValidFalseBooleanCheckType( + operand, + node.operator, + 'true', + parserServices, + options, + ) + ) { + result.push({ + type: OperandValidity.Valid, + comparedName: operand, + comparisonType: NullishComparisonType.Boolean, + isYoda: false, + node: operand, + }); + } else { + result.push({ type: OperandValidity.Invalid }); + } + continue; + } + } + + return { + operands: result, + seenLogicals, + }; + + /* + The AST is always constructed such the first element is always the deepest element. + I.e. for this code: `foo && foo.bar && foo.bar.baz && foo.bar.baz.buzz` + The AST will look like this: + { + left: { + left: { + left: foo + right: foo.bar + } + right: foo.bar.baz + } + right: foo.bar.baz.buzz + } + + So given any logical expression, we can perform a depth-first traversal to get + the operands in order. + + Note that this function purposely does not inspect mixed logical expressions + like `foo || foo.bar && foo.bar.baz` - separate selector + */ + function flattenLogicalOperands(node: TSESTree.LogicalExpression): { + operands: TSESTree.Expression[]; + seenLogicals: Set; + } { + const operands: TSESTree.Expression[] = []; + const seenLogicals = new Set([node]); + + const stack: TSESTree.Expression[] = [node.right, node.left]; + let current: TSESTree.Expression | undefined; + while ((current = stack.pop())) { + if ( + current.type === AST_NODE_TYPES.LogicalExpression && + current.operator === node.operator + ) { + seenLogicals.add(current); + stack.push(current.right); + stack.push(current.left); + } else { + operands.push(current); + } + } + + return { + operands, + seenLogicals, + }; + } + + function getComparisonValueType( + node: TSESTree.Node, + ): ComparisonValueType | null { + switch (node.type) { + case AST_NODE_TYPES.Literal: + // eslint-disable-next-line eqeqeq -- intentional exact comparison against null + if (node.value === null && node.raw === 'null') { + return ComparisonValueType.Null; + } + if (node.value === 'undefined') { + return ComparisonValueType.UndefinedStringLiteral; + } + return null; + + case AST_NODE_TYPES.Identifier: + if (node.name === 'undefined') { + return ComparisonValueType.Undefined; + } + return null; + } + + return null; + } +} diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index cdb6f1b92719..247b09155822 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -1,1331 +1,24 @@ -import type { - ParserServicesWithTypeInformation, - TSESTree, -} from '@typescript-eslint/utils'; +import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; -import type { - ReportDescriptor, - ReportFixFunction, - RuleContext, - RuleFix, - SourceCode, -} from '@typescript-eslint/utils/ts-eslint'; -import { visitorKeys } from '@typescript-eslint/visitor-keys'; -import { - isBigIntLiteralType, - isBooleanLiteralType, - isNumberLiteralType, - isStringLiteralType, - unionTypeParts, -} from 'ts-api-utils'; +import type { RuleFix } from '@typescript-eslint/utils/ts-eslint'; import * as ts from 'typescript'; import * as util from '../util'; - -export type TMessageIds = 'preferOptionalChain' | 'optionalChainSuggest'; -interface Options { - checkAny?: boolean; - checkUnknown?: boolean; - checkString?: boolean; - checkNumber?: boolean; - checkBoolean?: boolean; - checkBigInt?: boolean; - requireNullish?: boolean; - allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing?: boolean; -} -export type TOptions = [Options]; - -const enum ComparisonValueType { - Null = 'Null', // eslint-disable-line @typescript-eslint/internal/prefer-ast-types-enum - Undefined = 'Undefined', - UndefinedStringLiteral = 'UndefinedStringLiteral', -} -const enum OperandValidity { - Valid = 'Valid', - Invalid = 'Invalid', -} -const enum NodeComparisonResult { - /** the two nodes are comparably the same */ - Equal = 'Equal', - /** the left node is a subset of the right node */ - Subset = 'Subset', - /** the left node is not the same or is a superset of the right node */ - Invalid = 'Invalid', -} -const enum NullishComparisonType { - /** `x != null`, `x != undefined` */ - NotEqualNullOrUndefined = 'NotEqualNullOrUndefined', - /** `x == null`, `x == undefined` */ - EqualNullOrUndefined = 'EqualNullOrUndefined', - - /** `x !== null` */ - NotStrictEqualNull = 'NotStrictEqualNull', - /** `x === null` */ - StrictEqualNull = 'StrictEqualNull', - - /** `x !== undefined`, `typeof x !== 'undefined'` */ - NotStrictEqualUndefined = 'NotStrictEqualUndefined', - /** `x === undefined`, `typeof x === 'undefined'` */ - StrictEqualUndefined = 'StrictEqualUndefined', - - /** `!x` */ - NotBoolean = 'NotBoolean', - /** `x` */ - Boolean = 'Boolean', // eslint-disable-line @typescript-eslint/internal/prefer-ast-types-enum -} - -interface ValidOperand { - type: OperandValidity.Valid; - comparedName: TSESTree.Node; - comparisonType: NullishComparisonType; - isYoda: boolean; - node: TSESTree.Expression; -} -interface InvalidOperand { - type: OperandValidity.Invalid; -} -type Operand = ValidOperand | InvalidOperand; - -const NULLISH_FLAGS = ts.TypeFlags.Null | ts.TypeFlags.Undefined; -function isValidFalseBooleanCheckType( - node: TSESTree.Node, - operator: TSESTree.LogicalExpression['operator'], - checkType: 'true' | 'false', - parserServices: ParserServicesWithTypeInformation, - options: Options, -): boolean { - const type = parserServices.getTypeAtLocation(node); - const types = unionTypeParts(type); - - const disallowFalseyLiteral = - (operator === '||' && checkType === 'false') || - (operator === '&&' && checkType === 'true'); - if (disallowFalseyLiteral) { - /* - ``` - declare const x: false | {a: string}; - x && x.a; - !x || x.a; - ``` - - We don't want to consider these two cases because the boolean expression - narrows out the non-nullish falsey cases - so converting the chain to `x?.a` - would introduce a build error - */ - if ( - types.some(t => isBooleanLiteralType(t) && t.intrinsicName === 'false') || - types.some(t => isStringLiteralType(t) && t.value === '') || - types.some(t => isNumberLiteralType(t) && t.value === 0) || - types.some(t => isBigIntLiteralType(t) && t.value.base10Value === '0') - ) { - return false; - } - } - - if (options.requireNullish === true) { - return types.some(t => util.isTypeFlagSet(t, NULLISH_FLAGS)); - } - - let allowedFlags = NULLISH_FLAGS | ts.TypeFlags.Object; - if (options.checkAny === true) { - allowedFlags |= ts.TypeFlags.Any; - } - if (options.checkUnknown === true) { - allowedFlags |= ts.TypeFlags.Unknown; - } - if (options.checkString === true) { - allowedFlags |= ts.TypeFlags.StringLike; - } - if (options.checkNumber === true) { - allowedFlags |= ts.TypeFlags.NumberLike; - } - if (options.checkBoolean === true) { - allowedFlags |= ts.TypeFlags.BooleanLike; - } - if (options.checkBigInt === true) { - allowedFlags |= ts.TypeFlags.BigIntLike; - } - return types.every(t => util.isTypeFlagSet(t, allowedFlags)); -} - -function gatherLogicalOperands( - node: TSESTree.LogicalExpression, - parserServices: ParserServicesWithTypeInformation, - options: Options, -): { - operands: Operand[]; - seenLogicals: Set; -} { - const result: Operand[] = []; - const { operands, seenLogicals } = flattenLogicalOperands(node); - - for (const operand of operands) { - switch (operand.type) { - case AST_NODE_TYPES.BinaryExpression: { - // check for "yoda" style logical: null != x - - const { comparedExpression, comparedValue, isYoda } = (() => { - // non-yoda checks are by far the most common, so check for them first - const comparedValueRight = getComparisonValueType(operand.right); - if (comparedValueRight) { - return { - comparedExpression: operand.left, - comparedValue: comparedValueRight, - isYoda: false, - }; - } else { - return { - comparedExpression: operand.right, - comparedValue: getComparisonValueType(operand.left), - isYoda: true, - }; - } - })(); - - if (comparedValue === ComparisonValueType.UndefinedStringLiteral) { - if ( - comparedExpression.type === AST_NODE_TYPES.UnaryExpression && - comparedExpression.operator === 'typeof' - ) { - // typeof x === 'undefined' - result.push({ - type: OperandValidity.Valid, - comparedName: comparedExpression.argument, - comparisonType: operand.operator.startsWith('!') - ? NullishComparisonType.NotStrictEqualUndefined - : NullishComparisonType.StrictEqualUndefined, - isYoda, - node: operand, - }); - continue; - } - - // y === 'undefined' - result.push({ type: OperandValidity.Invalid }); - continue; - } - - switch (operand.operator) { - case '!=': - case '==': - if ( - comparedValue === ComparisonValueType.Null || - comparedValue === ComparisonValueType.Undefined - ) { - // x == null, x == undefined - result.push({ - type: OperandValidity.Valid, - comparedName: comparedExpression, - comparisonType: operand.operator.startsWith('!') - ? NullishComparisonType.NotEqualNullOrUndefined - : NullishComparisonType.EqualNullOrUndefined, - isYoda, - node: operand, - }); - continue; - } - // x == something :( - result.push({ type: OperandValidity.Invalid }); - continue; - - case '!==': - case '===': { - const comparedName = comparedExpression; - switch (comparedValue) { - case ComparisonValueType.Null: - result.push({ - type: OperandValidity.Valid, - comparedName, - comparisonType: operand.operator.startsWith('!') - ? NullishComparisonType.NotStrictEqualNull - : NullishComparisonType.StrictEqualNull, - isYoda, - node: operand, - }); - continue; - - case ComparisonValueType.Undefined: - result.push({ - type: OperandValidity.Valid, - comparedName, - comparisonType: operand.operator.startsWith('!') - ? NullishComparisonType.NotStrictEqualUndefined - : NullishComparisonType.StrictEqualUndefined, - isYoda, - node: operand, - }); - continue; - - default: - // x === something :( - result.push({ type: OperandValidity.Invalid }); - continue; - } - } - } - - result.push({ type: OperandValidity.Invalid }); - continue; - } - - case AST_NODE_TYPES.UnaryExpression: - if ( - operand.operator === '!' && - isValidFalseBooleanCheckType( - operand.argument, - node.operator, - 'false', - parserServices, - options, - ) - ) { - result.push({ - type: OperandValidity.Valid, - comparedName: operand.argument, - comparisonType: NullishComparisonType.NotBoolean, - isYoda: false, - node: operand, - }); - continue; - } - result.push({ type: OperandValidity.Invalid }); - continue; - - case AST_NODE_TYPES.LogicalExpression: - // explicitly ignore the mixed logical expression cases - result.push({ type: OperandValidity.Invalid }); - continue; - - default: - if ( - isValidFalseBooleanCheckType( - operand, - node.operator, - 'true', - parserServices, - options, - ) - ) { - result.push({ - type: OperandValidity.Valid, - comparedName: operand, - comparisonType: NullishComparisonType.Boolean, - isYoda: false, - node: operand, - }); - } else { - result.push({ type: OperandValidity.Invalid }); - } - continue; - } - } - - return { - operands: result, - seenLogicals, - }; - - /* - The AST is always constructed such the first element is always the deepest element. - I.e. for this code: `foo && foo.bar && foo.bar.baz && foo.bar.baz.buzz` - The AST will look like this: - { - left: { - left: { - left: foo - right: foo.bar - } - right: foo.bar.baz - } - right: foo.bar.baz.buzz - } - - So given any logical expression, we can perform a depth-first traversal to get - the operands in order. - - Note that this function purposely does not inspect mixed logical expressions - like `foo || foo.bar && foo.bar.baz` - separate selector - */ - function flattenLogicalOperands(node: TSESTree.LogicalExpression): { - operands: TSESTree.Expression[]; - seenLogicals: Set; - } { - const operands: TSESTree.Expression[] = []; - const seenLogicals = new Set([node]); - - const stack: TSESTree.Expression[] = [node.right, node.left]; - let current: TSESTree.Expression | undefined; - while ((current = stack.pop())) { - if ( - current.type === AST_NODE_TYPES.LogicalExpression && - current.operator === node.operator - ) { - seenLogicals.add(current); - stack.push(current.right); - stack.push(current.left); - } else { - operands.push(current); - } - } - - return { - operands, - seenLogicals, - }; - } - - function getComparisonValueType( - node: TSESTree.Node, - ): ComparisonValueType | null { - switch (node.type) { - case AST_NODE_TYPES.Literal: - // eslint-disable-next-line eqeqeq -- intentional exact comparison against null - if (node.value === null && node.raw === 'null') { - return ComparisonValueType.Null; - } - if (node.value === 'undefined') { - return ComparisonValueType.UndefinedStringLiteral; - } - return null; - - case AST_NODE_TYPES.Identifier: - if (node.name === 'undefined') { - return ComparisonValueType.Undefined; - } - return null; - } - - return null; - } -} - -function compareArrays( - arrayA: unknown[], - arrayB: unknown[], -): NodeComparisonResult.Equal | NodeComparisonResult.Invalid { - if (arrayA.length !== arrayB.length) { - return NodeComparisonResult.Invalid; - } - - const result = arrayA.every((elA, idx) => { - const elB = arrayB[idx]; - if (elA == null || elB == null) { - return elA === elB; - } - return compareUnknownValues(elA, elB) === NodeComparisonResult.Equal; - }); - if (result) { - return NodeComparisonResult.Equal; - } - return NodeComparisonResult.Invalid; -} - -function isValidNode(x: unknown): x is TSESTree.Node { - return ( - typeof x === 'object' && - x != null && - 'type' in x && - typeof x.type === 'string' - ); -} -function isValidChainExpressionToLookThrough( - node: TSESTree.Node, -): node is TSESTree.ChainExpression { - return ( - !( - node.parent?.type === AST_NODE_TYPES.MemberExpression && - node.parent.object === node - ) && - !( - node.parent?.type === AST_NODE_TYPES.CallExpression && - node.parent.callee === node - ) && - node.type === AST_NODE_TYPES.ChainExpression - ); -} -function compareUnknownValues( - valueA: unknown, - valueB: unknown, -): NodeComparisonResult { - /* istanbul ignore if -- not possible for us to test this - it's just a sanity safeguard */ - if (valueA == null || valueB == null) { - if (valueA !== valueB) { - return NodeComparisonResult.Invalid; - } - return NodeComparisonResult.Equal; - } - - /* istanbul ignore if -- not possible for us to test this - it's just a sanity safeguard */ - if (!isValidNode(valueA) || !isValidNode(valueB)) { - return NodeComparisonResult.Invalid; - } - - return compareNodes(valueA, valueB); -} -function compareByVisiting( - nodeA: TSESTree.Node, - nodeB: TSESTree.Node, -): NodeComparisonResult.Equal | NodeComparisonResult.Invalid { - const currentVisitorKeys = visitorKeys[nodeA.type]; - /* istanbul ignore if -- not possible for us to test this - it's just a sanity safeguard */ - if (currentVisitorKeys == null) { - // we don't know how to visit this node, so assume it's invalid to avoid false-positives / broken fixers - return NodeComparisonResult.Invalid; - } - - if (currentVisitorKeys.length === 0) { - // assume nodes with no keys are constant things like keywords - return NodeComparisonResult.Equal; - } - - for (const key of currentVisitorKeys) { - // @ts-expect-error - dynamic access but it's safe - const nodeAChildOrChildren = nodeA[key] as unknown; - // @ts-expect-error - dynamic access but it's safe - const nodeBChildOrChildren = nodeB[key] as unknown; - - if (Array.isArray(nodeAChildOrChildren)) { - const arrayA = nodeAChildOrChildren as unknown[]; - const arrayB = nodeBChildOrChildren as unknown[]; - - const result = compareArrays(arrayA, arrayB); - if (result !== NodeComparisonResult.Equal) { - return NodeComparisonResult.Invalid; - } - // fallthrough to the next key as the key was "equal" - } else { - const result = compareUnknownValues( - nodeAChildOrChildren, - nodeBChildOrChildren, - ); - if (result !== NodeComparisonResult.Equal) { - return NodeComparisonResult.Invalid; - } - // fallthrough to the next key as the key was "equal" - } - } - - return NodeComparisonResult.Equal; -} -type CompareNodesArgument = TSESTree.Node | null | undefined; -function compareNodesUncached( - nodeA: TSESTree.Node, - nodeB: TSESTree.Node, -): NodeComparisonResult { - if (nodeA.type !== nodeB.type) { - // special cases where nodes are allowed to be non-equal - - // look through a chain expression node at the top-level because it only - // exists to delimit the end of an optional chain - // - // a?.b && a.b.c - // ^^^^ ChainExpression, MemberExpression - // ^^^^^ MemberExpression - // - // except for in this class of cases - // (a?.b).c && a.b.c - // because the parentheses have runtime meaning (sad face) - if (isValidChainExpressionToLookThrough(nodeA)) { - return compareNodes(nodeA.expression, nodeB); - } - if (isValidChainExpressionToLookThrough(nodeB)) { - return compareNodes(nodeA, nodeB.expression); - } - - // look through the type-only non-null assertion because its existence could - // possibly be replaced by an optional chain instead - // - // a.b! && a.b.c - // ^^^^ TSNonNullExpression - if (nodeA.type === AST_NODE_TYPES.TSNonNullExpression) { - return compareNodes(nodeA.expression, nodeB); - } - if (nodeB.type === AST_NODE_TYPES.TSNonNullExpression) { - return compareNodes(nodeA, nodeB.expression); - } - - // special case for subset optional chains where the node types don't match, - // but we want to try comparing by discarding the "extra" code - // - // a && a.b - // ^ compare this - // a && a() - // ^ compare this - // a.b && a.b() - // ^^^ compare this - // a() && a().b - // ^^^ compare this - // import.meta && import.meta.b - // ^^^^^^^^^^^ compare this - if ( - nodeA.type === AST_NODE_TYPES.CallExpression || - nodeA.type === AST_NODE_TYPES.Identifier || - nodeA.type === AST_NODE_TYPES.MemberExpression || - nodeA.type === AST_NODE_TYPES.MetaProperty - ) { - switch (nodeB.type) { - case AST_NODE_TYPES.MemberExpression: - if (nodeB.property.type === AST_NODE_TYPES.PrivateIdentifier) { - // Private identifiers in optional chaining is not currently allowed - // TODO - handle this once TS supports it (https://github.com/microsoft/TypeScript/issues/42734) - return NodeComparisonResult.Invalid; - } - if ( - compareNodes(nodeA, nodeB.object) !== NodeComparisonResult.Invalid - ) { - return NodeComparisonResult.Subset; - } - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.CallExpression: - if ( - compareNodes(nodeA, nodeB.callee) !== NodeComparisonResult.Invalid - ) { - return NodeComparisonResult.Subset; - } - return NodeComparisonResult.Invalid; - - default: - return NodeComparisonResult.Invalid; - } - } - - return NodeComparisonResult.Invalid; - } - - switch (nodeA.type) { - // these expressions create a new instance each time - so it makes no sense to compare the chain - case AST_NODE_TYPES.ArrayExpression: - case AST_NODE_TYPES.ArrowFunctionExpression: - case AST_NODE_TYPES.ClassExpression: - case AST_NODE_TYPES.FunctionExpression: - case AST_NODE_TYPES.JSXElement: - case AST_NODE_TYPES.JSXFragment: - case AST_NODE_TYPES.NewExpression: - case AST_NODE_TYPES.ObjectExpression: - return NodeComparisonResult.Invalid; - - // chaining from assignments could change the value irrevocably - so it makes no sense to compare the chain - case AST_NODE_TYPES.AssignmentExpression: - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.CallExpression: { - const nodeBCall = nodeB as typeof nodeA; - - // check for cases like - // foo() && foo()(bar) - // ^^^^^ nodeA - // ^^^^^^^^^^ nodeB - // we don't want to check the arguments in this case - const aSubsetOfB = compareNodes(nodeA, nodeBCall.callee); - if (aSubsetOfB !== NodeComparisonResult.Invalid) { - return NodeComparisonResult.Subset; - } - - const calleeCompare = compareNodes(nodeA.callee, nodeBCall.callee); - if (calleeCompare !== NodeComparisonResult.Equal) { - return NodeComparisonResult.Invalid; - } - - // NOTE - we purposely ignore optional flag because for our purposes - // foo?.bar() && foo.bar?.()?.baz - // or - // foo.bar() && foo?.bar?.()?.baz - // are going to be exactly the same - - const argumentCompare = compareArrays( - nodeA.arguments, - nodeBCall.arguments, - ); - if (argumentCompare !== NodeComparisonResult.Equal) { - return NodeComparisonResult.Invalid; - } - - const typeParamCompare = compareNodes( - nodeA.typeArguments, - nodeBCall.typeArguments, - ); - if (typeParamCompare === NodeComparisonResult.Equal) { - return NodeComparisonResult.Equal; - } - - return NodeComparisonResult.Invalid; - } - - case AST_NODE_TYPES.ChainExpression: - // special case handling for ChainExpression because it's allowed to be a subset - return compareNodes(nodeA, (nodeB as typeof nodeA).expression); - - case AST_NODE_TYPES.Identifier: - case AST_NODE_TYPES.PrivateIdentifier: - if (nodeA.name === (nodeB as typeof nodeA).name) { - return NodeComparisonResult.Equal; - } - return NodeComparisonResult.Invalid; - - case AST_NODE_TYPES.Literal: { - const nodeBLiteral = nodeB as typeof nodeA; - if ( - nodeA.raw === nodeBLiteral.raw && - nodeA.value === nodeBLiteral.value - ) { - return NodeComparisonResult.Equal; - } - return NodeComparisonResult.Invalid; - } - - case AST_NODE_TYPES.MemberExpression: { - const nodeBMember = nodeB as typeof nodeA; - - if (nodeBMember.property.type === AST_NODE_TYPES.PrivateIdentifier) { - // Private identifiers in optional chaining is not currently allowed - // TODO - handle this once TS supports it (https://github.com/microsoft/TypeScript/issues/42734) - return NodeComparisonResult.Invalid; - } - - // check for cases like - // foo.bar && foo.bar.baz - // ^^^^^^^ nodeA - // ^^^^^^^^^^^ nodeB - // result === Equal - // - // foo.bar && foo.bar.baz.bam - // ^^^^^^^ nodeA - // ^^^^^^^^^^^^^^^ nodeB - // result === Subset - // - // we don't want to check the property in this case - const aSubsetOfB = compareNodes(nodeA, nodeBMember.object); - if (aSubsetOfB !== NodeComparisonResult.Invalid) { - return NodeComparisonResult.Subset; - } - - if (nodeA.computed !== nodeBMember.computed) { - return NodeComparisonResult.Invalid; - } - - // NOTE - we purposely ignore optional flag because for our purposes - // foo?.bar && foo.bar?.baz - // or - // foo.bar && foo?.bar?.baz - // are going to be exactly the same - - const objectCompare = compareNodes(nodeA.object, nodeBMember.object); - if (objectCompare !== NodeComparisonResult.Equal) { - return NodeComparisonResult.Invalid; - } - - return compareNodes(nodeA.property, nodeBMember.property); - } - case AST_NODE_TYPES.TSTemplateLiteralType: - case AST_NODE_TYPES.TemplateLiteral: { - const nodeBTemplate = nodeB as typeof nodeA; - const areQuasisEqual = - nodeA.quasis.length === nodeBTemplate.quasis.length && - nodeA.quasis.every((elA, idx) => { - const elB = nodeBTemplate.quasis[idx]; - return elA.value.cooked === elB.value.cooked; - }); - if (!areQuasisEqual) { - return NodeComparisonResult.Invalid; - } - - return NodeComparisonResult.Equal; - } - - case AST_NODE_TYPES.TemplateElement: { - const nodeBElement = nodeB as typeof nodeA; - if (nodeA.value.cooked === nodeBElement.value.cooked) { - return NodeComparisonResult.Equal; - } - return NodeComparisonResult.Invalid; - } - - // these aren't actually valid expressions. - // https://github.com/typescript-eslint/typescript-eslint/blob/20d7caee35ab84ae6381fdf04338c9e2b9e2bc48/packages/ast-spec/src/unions/Expression.ts#L37-L43 - case AST_NODE_TYPES.ArrayPattern: - case AST_NODE_TYPES.ObjectPattern: - /* istanbul ignore next */ - return NodeComparisonResult.Invalid; - - // update expression returns a number and also changes the value each time - so it makes no sense to compare the chain - case AST_NODE_TYPES.UpdateExpression: - return NodeComparisonResult.Invalid; - - // yield returns the value passed to the `next` function, so it may not be the same each time - so it makes no sense to compare the chain - case AST_NODE_TYPES.YieldExpression: - return NodeComparisonResult.Invalid; - - // general-case automatic handling of nodes to save us implementing every - // single case by hand. This just iterates the visitor keys to recursively - // check the children. - // - // Any specific logic cases or short-circuits should be listed as separate - // cases so that they don't fall into this generic handling - default: - return compareByVisiting(nodeA, nodeB); - } -} -const COMPARE_NODES_CACHE = new WeakMap< - TSESTree.Node, - WeakMap ->(); -function compareNodes( - nodeA: CompareNodesArgument, - nodeB: CompareNodesArgument, -): NodeComparisonResult { - if (nodeA == null || nodeB == null) { - if (nodeA !== nodeB) { - return NodeComparisonResult.Invalid; - } - return NodeComparisonResult.Equal; - } - - const cached = COMPARE_NODES_CACHE.get(nodeA)?.get(nodeB); - if (cached) { - return cached; - } - - const result = compareNodesUncached(nodeA, nodeB); - let mapA = COMPARE_NODES_CACHE.get(nodeA); - if (mapA == null) { - mapA = new WeakMap(); - COMPARE_NODES_CACHE.set(nodeA, mapA); - } - mapA.set(nodeB, result); - return result; -} - -function includesType( - parserServices: ParserServicesWithTypeInformation, - node: TSESTree.Node, - typeFlagIn: ts.TypeFlags, -): boolean { - const typeFlag = typeFlagIn | ts.TypeFlags.Any | ts.TypeFlags.Unknown; - const types = unionTypeParts(parserServices.getTypeAtLocation(node)); - for (const type of types) { - if (util.isTypeFlagSet(type, typeFlag)) { - return true; - } - } - return false; -} - -// I hate that these functions are identical aside from the enum values used -// I can't think of a good way to reuse the code here in a way that will preserve -// the type safety and simplicity. - -type OperandAnalyzer = ( - parserServices: ParserServicesWithTypeInformation, - operand: ValidOperand, - index: number, - chain: readonly ValidOperand[], -) => readonly [ValidOperand] | readonly [ValidOperand, ValidOperand] | null; -const analyzeAndChainOperand: OperandAnalyzer = ( - parserServices, - operand, - index, - chain, -) => { - switch (operand.comparisonType) { - case NullishComparisonType.Boolean: - case NullishComparisonType.NotEqualNullOrUndefined: - return [operand]; - - case NullishComparisonType.NotStrictEqualNull: { - // handle `x !== null && x !== undefined` - const nextOperand = chain[index + 1] as ValidOperand | undefined; - if ( - nextOperand?.comparisonType === - NullishComparisonType.NotStrictEqualUndefined && - compareNodes(operand.comparedName, nextOperand.comparedName) === - NodeComparisonResult.Equal - ) { - return [operand, nextOperand]; - } - if ( - includesType( - parserServices, - operand.comparedName, - ts.TypeFlags.Undefined, - ) - ) { - // we know the next operand is not an `undefined` check and that this - // operand includes `undefined` - which means that making this an - // optional chain would change the runtime behavior of the expression - return null; - } - - return [operand]; - } - - case NullishComparisonType.NotStrictEqualUndefined: { - // handle `x !== undefined && x !== null` - const nextOperand = chain[index + 1] as ValidOperand | undefined; - if ( - nextOperand?.comparisonType === - NullishComparisonType.NotStrictEqualNull && - compareNodes(operand.comparedName, nextOperand.comparedName) === - NodeComparisonResult.Equal - ) { - return [operand, nextOperand]; - } - if ( - includesType( - parserServices, - operand.comparedName, - ts.TypeFlags.Undefined, - ) - ) { - // we know the next operand is not a `null` check and that this - // operand includes `null` - which means that making this an - // optional chain would change the runtime behavior of the expression - return null; - } - - return [operand]; - } - - default: - return null; - } -}; -const analyzeOrChainOperand: OperandAnalyzer = ( - parserServices, - operand, - index, - chain, -) => { - switch (operand.comparisonType) { - case NullishComparisonType.NotBoolean: - case NullishComparisonType.EqualNullOrUndefined: - return [operand]; - - case NullishComparisonType.StrictEqualNull: { - // handle `x === null || x === undefined` - const nextOperand = chain[index + 1] as ValidOperand | undefined; - if ( - nextOperand?.comparisonType === - NullishComparisonType.StrictEqualUndefined && - compareNodes(operand.comparedName, nextOperand.comparedName) === - NodeComparisonResult.Equal - ) { - return [operand, nextOperand]; - } - if ( - includesType( - parserServices, - operand.comparedName, - ts.TypeFlags.Undefined, - ) - ) { - // we know the next operand is not an `undefined` check and that this - // operand includes `undefined` - which means that making this an - // optional chain would change the runtime behavior of the expression - return null; - } - - return [operand]; - } - - case NullishComparisonType.StrictEqualUndefined: { - // handle `x === undefined || x === null` - const nextOperand = chain[index + 1] as ValidOperand | undefined; - if ( - nextOperand?.comparisonType === NullishComparisonType.StrictEqualNull && - compareNodes(operand.comparedName, nextOperand.comparedName) === - NodeComparisonResult.Equal - ) { - return [operand, nextOperand]; - } - if ( - includesType( - parserServices, - operand.comparedName, - ts.TypeFlags.Undefined, - ) - ) { - // we know the next operand is not a `null` check and that this - // operand includes `null` - which means that making this an - // optional chain would change the runtime behavior of the expression - return null; - } - - return [operand]; - } - - default: - return null; - } -}; - -function getFixer( - sourceCode: SourceCode, - parserServices: ParserServicesWithTypeInformation, - operator: '&&' | '||', - options: Options, - chain: ValidOperand[], -): - | { - suggest: NonNullable['suggest']>; - } - | { - fix: NonNullable['fix']>; - } { - const lastOperand = chain[chain.length - 1]; - - let useSuggestionFixer: boolean; - if ( - options.allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing === - true - ) { - // user has opted-in to the unsafe behavior - useSuggestionFixer = false; - } else { - // optional chain specifically will union `undefined` into the final type - // so we need to make sure that there is at least one operand that includes - // `undefined`, or else we're going to change the final type - which is - // unsafe and might cause downstream type errors. - - if ( - lastOperand.comparisonType === - NullishComparisonType.EqualNullOrUndefined || - lastOperand.comparisonType === - NullishComparisonType.NotEqualNullOrUndefined || - lastOperand.comparisonType === - NullishComparisonType.StrictEqualUndefined || - lastOperand.comparisonType === - NullishComparisonType.NotStrictEqualUndefined || - (operator === '||' && - lastOperand.comparisonType === NullishComparisonType.NotBoolean) - ) { - // we know the last operand is an equality check - so the change in types - // DOES NOT matter and will not change the runtime result or cause a type - // check error - useSuggestionFixer = false; - } else { - useSuggestionFixer = true; - - for (const operand of chain) { - if ( - includesType(parserServices, operand.node, ts.TypeFlags.Undefined) - ) { - useSuggestionFixer = false; - break; - } - } - - // TODO - we could further reduce the false-positive rate of this check by - // checking for cases where the change in types don't matter like - // the test location of an if/while/etc statement. - // but it's quite complex to do this without false-negatives, so - // for now we'll just be over-eager with our matching. - } - } - - // In its most naive form we could just slap `?.` for every single part of the - // chain. However this would be undesirable because it'd create unnecessary - // conditions in the user's code where there were none before - and it would - // cause errors with rules like our `no-unnecessary-condition`. - // - // Instead we want to include the minimum number of `?.` required to correctly - // unify the code into a single chain. Naively you might think that we can - // just take the final operand add `?.` after the locations from the previous - // operands - however this won't be correct either because earlier operands - // can include a necessary `?.` that's not needed or included in a later - // operand. - // - // So instead what we need to do is to start at the first operand and - // iteratively diff it against the next operand, and add the difference to the - // first operand. - // - // eg - // `foo && foo.bar && foo.bar.baz?.bam && foo.bar.baz.bam()` - // 1) `foo` - // 2) diff(`foo`, `foo.bar`) = `.bar` - // 3) result = `foo?.bar` - // 4) diff(`foo.bar`, `foo.bar.baz?.bam`) = `.baz?.bam` - // 5) result = `foo?.bar?.baz?.bam` - // 6) diff(`foo.bar.baz?.bam`, `foo.bar.baz.bam()`) = `()` - // 7) result = `foo?.bar?.baz?.bam?.()` - - const parts = []; - for (const current of chain) { - const nextOperand = flattenChainExpression( - sourceCode, - current.comparedName, - ); - const diff = nextOperand.slice(parts.length); - if (diff.length > 0) { - if (parts.length > 0) { - // we need to make the first operand of the diff optional so it matches the - // logic before merging - // foo.bar && foo.bar.baz - // diff = .baz - // result = foo.bar?.baz - diff[0].optional = true; - } - parts.push(...diff); - } - } - - let newCode = parts - .map(part => { - let str = ''; - if (part.optional) { - str += '?.'; - } else { - if (part.nonNull) { - str += '!'; - } - if (part.requiresDot) { - str += '.'; - } - } - if ( - part.precedence !== util.OperatorPrecedence.Invalid && - part.precedence < util.OperatorPrecedence.Member - ) { - str += `(${part.text})`; - } else { - str += part.text; - } - return str; - }) - .join(''); - - if (lastOperand.node.type === AST_NODE_TYPES.BinaryExpression) { - // retain the ending comparison for cases like - // x && x.a != null - // x && typeof x.a !== 'undefined' - const operator = lastOperand.node.operator; - const { left, right } = (() => { - if (lastOperand.isYoda) { - const unaryOperator = - lastOperand.node.right.type === AST_NODE_TYPES.UnaryExpression - ? lastOperand.node.right.operator + ' ' - : ''; - - return { - left: sourceCode.getText(lastOperand.node.left), - right: unaryOperator + newCode, - }; - } else { - const unaryOperator = - lastOperand.node.left.type === AST_NODE_TYPES.UnaryExpression - ? lastOperand.node.left.operator + ' ' - : ''; - return { - left: unaryOperator + newCode, - right: sourceCode.getText(lastOperand.node.right), - }; - } - })(); - - newCode = `${left} ${operator} ${right}`; - } else if (lastOperand.comparisonType === NullishComparisonType.NotBoolean) { - newCode = `!${newCode}`; - } - - const fix: ReportFixFunction = fixer => - fixer.replaceTextRange( - [chain[0].node.range[0], lastOperand.node.range[1]], - newCode, - ); - - return useSuggestionFixer - ? { suggest: [{ fix, messageId: 'optionalChainSuggest' }] } - : { fix }; - - interface FlattenedChain { - nonNull: boolean; - optional: boolean; - precedence: util.OperatorPrecedence; - requiresDot: boolean; - text: string; - } - function flattenChainExpression( - sourceCode: SourceCode, - node: TSESTree.Node, - ): FlattenedChain[] { - switch (node.type) { - case AST_NODE_TYPES.ChainExpression: - return flattenChainExpression(sourceCode, node.expression); - - case AST_NODE_TYPES.CallExpression: { - const argumentsText = (() => { - const closingParenToken = util.nullThrows( - sourceCode.getLastToken(node), - util.NullThrowsReasons.MissingToken( - 'closing parenthesis', - node.type, - ), - ); - const openingParenToken = util.nullThrows( - sourceCode.getFirstTokenBetween( - node.typeArguments ?? node.callee, - closingParenToken, - util.isOpeningParenToken, - ), - util.NullThrowsReasons.MissingToken( - 'opening parenthesis', - node.type, - ), - ); - return sourceCode.text.substring( - openingParenToken.range[0], - closingParenToken.range[1], - ); - })(); - - const typeArgumentsText = (() => { - if (node.typeArguments == null) { - return ''; - } - - return sourceCode.getText(node.typeArguments); - })(); - - return [ - ...flattenChainExpression(sourceCode, node.callee), - { - nonNull: false, - optional: node.optional, - // no precedence for this - precedence: util.OperatorPrecedence.Invalid, - requiresDot: false, - text: typeArgumentsText + argumentsText, - }, - ]; - } - - case AST_NODE_TYPES.MemberExpression: { - const propertyText = sourceCode.getText(node.property); - return [ - ...flattenChainExpression(sourceCode, node.object), - { - nonNull: node.object.type === AST_NODE_TYPES.TSNonNullExpression, - optional: node.optional, - precedence: node.computed - ? // computed is already wrapped in [] so no need to wrap in () as well - util.OperatorPrecedence.Invalid - : util.getOperatorPrecedenceForNode(node.property), - requiresDot: !node.computed, - text: node.computed ? `[${propertyText}]` : propertyText, - }, - ]; - } - - case AST_NODE_TYPES.TSNonNullExpression: - return flattenChainExpression(sourceCode, node.expression); - - default: - return [ - { - nonNull: false, - optional: false, - precedence: util.getOperatorPrecedenceForNode(node), - requiresDot: false, - text: sourceCode.getText(node), - }, - ]; - } - } -} - -function analyzeChain( - context: RuleContext, - sourceCode: SourceCode, - parserServices: ParserServicesWithTypeInformation, - options: Options, - operator: TSESTree.LogicalExpression['operator'], - chain: ValidOperand[], -): void { - // need at least 2 operands in a chain for it to be a chain - if ( - chain.length <= 1 || - /* istanbul ignore next -- previous checks make this unreachable, but keep it for exhaustiveness check */ - operator === '??' - ) { - return; - } - - const analyzeOperand = ((): OperandAnalyzer | null => { - switch (operator) { - case '&&': - return analyzeAndChainOperand; - - case '||': - return analyzeOrChainOperand; - } - })(); - if (!analyzeOperand) { - return; - } - - let subChain: ValidOperand[] = []; - const maybeReportThenReset = ( - newChainSeed?: readonly ValidOperand[], - ): void => { - if (subChain.length > 1) { - context.report({ - messageId: 'preferOptionalChain', - loc: { - start: subChain[0].node.loc.start, - end: subChain[subChain.length - 1].node.loc.end, - }, - ...getFixer(sourceCode, parserServices, operator, options, subChain), - }); - } - - // we've reached the end of a chain of logical expressions - // we know the validated - subChain = newChainSeed ? [...newChainSeed] : []; - }; - - for (let i = 0; i < chain.length; i += 1) { - const lastOperand = subChain[subChain.length - 1] as - | ValidOperand - | undefined; - const operand = chain[i]; - - const validatedOperands = analyzeOperand(parserServices, operand, i, chain); - if (!validatedOperands) { - maybeReportThenReset(); - continue; - } - // in case multiple operands were consumed - make sure to correctly increment the index - i += validatedOperands.length - 1; - - const currentOperand = validatedOperands[0]; - if (lastOperand) { - const comparisonResult = compareNodes( - lastOperand.comparedName, - // purposely inspect and push the last operand because the prior operands don't matter - // this also means we won't false-positive in cases like - // foo !== null && foo !== undefined - validatedOperands[validatedOperands.length - 1].comparedName, - ); - if (comparisonResult === NodeComparisonResult.Subset) { - // the operands are comparable, so we can continue searching - subChain.push(currentOperand); - } else if (comparisonResult === NodeComparisonResult.Invalid) { - maybeReportThenReset(validatedOperands); - } else if (comparisonResult === NodeComparisonResult.Equal) { - // purposely don't push this case because the node is a no-op and if - // we consider it then we might report on things like - // foo && foo - } - } else { - subChain.push(currentOperand); - } - } - - // check the leftovers - maybeReportThenReset(); -} - -export default util.createRule({ +import { analyzeChain } from './prefer-optional-chain-utils/analyzeChain'; +import type { ValidOperand } from './prefer-optional-chain-utils/gatherLogicalOperands'; +import { + gatherLogicalOperands, + OperandValidity, +} from './prefer-optional-chain-utils/gatherLogicalOperands'; +import type { + PreferOptionalChainMessageIds, + PreferOptionalChainOptions, +} from './prefer-optional-chain-utils/PreferOptionalChainOptions'; + +export default util.createRule< + [PreferOptionalChainOptions], + PreferOptionalChainMessageIds +>({ name: 'prefer-optional-chain', meta: { type: 'suggestion', @@ -1447,7 +140,7 @@ export default util.createRule({ } context.report({ node: parentNode, - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', suggest: [ { messageId: 'optionalChainSuggest', diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index 397988aad5a0..177c4fe1e695 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -34,7 +34,7 @@ describe('|| {}', () => { code: '(foo || {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 16, suggestions: [ @@ -50,7 +50,7 @@ describe('|| {}', () => { code: noFormat`(foo || ({})).bar;`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 18, suggestions: [ @@ -66,7 +66,7 @@ describe('|| {}', () => { code: noFormat`(await foo || {}).bar;`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 22, suggestions: [ @@ -82,7 +82,7 @@ describe('|| {}', () => { code: '(foo1?.foo2 || {}).foo3;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 24, suggestions: [ @@ -98,7 +98,7 @@ describe('|| {}', () => { code: '((() => foo())() || {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 28, suggestions: [ @@ -114,7 +114,7 @@ describe('|| {}', () => { code: 'const foo = (bar || {}).baz;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 13, endColumn: 28, suggestions: [ @@ -130,7 +130,7 @@ describe('|| {}', () => { code: '(foo.bar || {})[baz];', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 21, suggestions: [ @@ -146,7 +146,7 @@ describe('|| {}', () => { code: '((foo1 || {}).foo2 || {}).foo3;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 31, suggestions: [ @@ -157,7 +157,7 @@ describe('|| {}', () => { ], }, { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 2, endColumn: 19, suggestions: [ @@ -173,7 +173,7 @@ describe('|| {}', () => { code: '(foo || undefined || {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', suggestions: [ { messageId: 'optionalChainSuggest', @@ -187,7 +187,7 @@ describe('|| {}', () => { code: '(foo() || bar || {}).baz;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 25, suggestions: [ @@ -203,7 +203,7 @@ describe('|| {}', () => { code: '((foo1 ? foo2 : foo3) || {}).foo4;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 34, suggestions: [ @@ -223,7 +223,7 @@ describe('|| {}', () => { `, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 13, endColumn: 28, suggestions: [ @@ -247,7 +247,7 @@ describe('|| {}', () => { `, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 15, endColumn: 30, suggestions: [ @@ -267,7 +267,7 @@ describe('|| {}', () => { code: noFormat`(undefined && foo || {}).bar;`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 29, suggestions: [ @@ -283,7 +283,7 @@ describe('|| {}', () => { code: '(foo ?? {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 16, suggestions: [ @@ -299,7 +299,7 @@ describe('|| {}', () => { code: noFormat`(foo ?? ({})).bar;`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 18, suggestions: [ @@ -315,7 +315,7 @@ describe('|| {}', () => { code: noFormat`(await foo ?? {}).bar;`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 22, suggestions: [ @@ -331,7 +331,7 @@ describe('|| {}', () => { code: '(foo1?.foo2 ?? {}).foo3;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 24, suggestions: [ @@ -347,7 +347,7 @@ describe('|| {}', () => { code: '((() => foo())() ?? {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 28, suggestions: [ @@ -363,7 +363,7 @@ describe('|| {}', () => { code: 'const foo = (bar ?? {}).baz;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 13, endColumn: 28, suggestions: [ @@ -379,7 +379,7 @@ describe('|| {}', () => { code: '(foo.bar ?? {})[baz];', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 21, suggestions: [ @@ -395,7 +395,7 @@ describe('|| {}', () => { code: '((foo1 ?? {}).foo2 ?? {}).foo3;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 31, suggestions: [ @@ -406,7 +406,7 @@ describe('|| {}', () => { ], }, { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 2, endColumn: 19, suggestions: [ @@ -422,7 +422,7 @@ describe('|| {}', () => { code: '(foo ?? undefined ?? {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', suggestions: [ { messageId: 'optionalChainSuggest', @@ -436,7 +436,7 @@ describe('|| {}', () => { code: '(foo() ?? bar ?? {}).baz;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 25, suggestions: [ @@ -452,7 +452,7 @@ describe('|| {}', () => { code: '((foo1 ? foo2 : foo3) ?? {}).foo4;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 34, suggestions: [ @@ -468,7 +468,7 @@ describe('|| {}', () => { code: noFormat`if (foo) { (foo ?? {}).bar; }`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 12, endColumn: 27, suggestions: [ @@ -484,7 +484,7 @@ describe('|| {}', () => { code: noFormat`if ((foo ?? {}).bar) { foo.bar; }`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 5, endColumn: 20, suggestions: [ @@ -500,7 +500,7 @@ describe('|| {}', () => { code: noFormat`(undefined && foo ?? {}).bar;`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 29, suggestions: [ @@ -516,7 +516,7 @@ describe('|| {}', () => { code: '(a > b || {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 18, suggestions: [ @@ -532,7 +532,7 @@ describe('|| {}', () => { code: noFormat`(((typeof x) as string) || {}).bar;`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 35, suggestions: [ @@ -548,7 +548,7 @@ describe('|| {}', () => { code: '(void foo() || {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 23, suggestions: [ @@ -564,7 +564,7 @@ describe('|| {}', () => { code: '((a ? b : c) || {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 24, suggestions: [ @@ -580,7 +580,7 @@ describe('|| {}', () => { code: noFormat`((a instanceof Error) || {}).bar;`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 33, suggestions: [ @@ -596,7 +596,7 @@ describe('|| {}', () => { code: noFormat`((a << b) || {}).bar;`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 21, suggestions: [ @@ -612,7 +612,7 @@ describe('|| {}', () => { code: noFormat`((foo ** 2) || {}).bar;`, errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 23, suggestions: [ @@ -628,7 +628,7 @@ describe('|| {}', () => { code: '(foo ** 2 || {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 21, suggestions: [ @@ -644,7 +644,7 @@ describe('|| {}', () => { code: '(foo++ || {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 18, suggestions: [ @@ -660,7 +660,7 @@ describe('|| {}', () => { code: '(+foo || {}).bar;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', column: 1, endColumn: 17, suggestions: [ @@ -676,7 +676,7 @@ describe('|| {}', () => { code: '(this || {}).foo;', errors: [ { - messageId: 'optionalChainSuggest', + messageId: 'preferOptionalChain', suggestions: [ { messageId: 'optionalChainSuggest', @@ -874,7 +874,7 @@ describe('hand-crafted cases', () => { '`x` && `x`.length;', '`x${a}` && `x${a}`.length;', - // falsey unions should be ignored + // falsy unions should be ignored ` declare const x: false | { a: string }; x && x.a; From 709939d7be6a120dbe068492ca210e301bb8fe4f Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Thu, 25 May 2023 11:17:34 +0930 Subject: [PATCH 19/26] more work --- .../analyzeChain.ts | 19 +- .../rules/prefer-optional-chain/base-cases.ts | 287 ++++++++++++------ .../prefer-optional-chain.test.ts | 187 +++++++----- 3 files changed, 327 insertions(+), 166 deletions(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts index 29466693ca2d..a5e617bee028 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts @@ -96,11 +96,7 @@ const analyzeAndChainOperand: OperandAnalyzer = ( return [operand, nextOperand]; } if ( - includesType( - parserServices, - operand.comparedName, - ts.TypeFlags.Undefined, - ) + includesType(parserServices, operand.comparedName, ts.TypeFlags.Null) ) { // we know the next operand is not a `null` check and that this // operand includes `null` - which means that making this an @@ -164,11 +160,7 @@ const analyzeOrChainOperand: OperandAnalyzer = ( return [operand, nextOperand]; } if ( - includesType( - parserServices, - operand.comparedName, - ts.TypeFlags.Undefined, - ) + includesType(parserServices, operand.comparedName, ts.TypeFlags.Null) ) { // we know the next operand is not a `null` check and that this // operand includes `null` - which means that making this an @@ -521,6 +513,13 @@ export function analyzeChain( const validatedOperands = analyzeOperand(parserServices, operand, i, chain); if (!validatedOperands) { + // TODO - check if the name is a superset/equal - if it is, then it was + // likely intended to be part of the chain and something we should + // include in the report, eg + // foo == null || foo.bar; + // ^^^^^^^^^^^ valid OR chain + // ^^^^^^^ invalid OR chain logical, but still part of the chain for combination purposes + maybeReportThenReset(); continue; } diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts index 66587556f6b2..0bfeeb068c44 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts @@ -1,156 +1,271 @@ import type { InvalidTestCase } from '@typescript-eslint/utils/ts-eslint'; import type { - TMessageIds, - TOptions, -} from '../../../src/rules/prefer-optional-chain'; + PreferOptionalChainMessageIds, + PreferOptionalChainOptions, +} from '../../../src/rules/prefer-optional-chain-utils/PreferOptionalChainOptions'; -type MutateCodeFn = (c: string) => string; -type MutateOutputFn = (c: string) => string; -type BaseCaseCreator = ( - operator: '&&' | '||', - mutateCode?: MutateCodeFn, - mutateOutput?: MutateOutputFn, -) => InvalidTestCase[]; +type MutateFn = (c: string) => string; +type BaseCaseCreator = (args: { + operator: '&&' | '||'; + mutateCode?: MutateFn; + mutateOutput?: MutateFn; + mutateDeclaration?: MutateFn; + useSuggestionFixer?: true; +}) => InvalidTestCase< + PreferOptionalChainMessageIds, + [PreferOptionalChainOptions] +>[]; -export const identity: MutateCodeFn = c => c; -export const BaseCases: BaseCaseCreator = ( - operator, - mutateCode = identity, - mutateOutput = mutateCode, -) => +const RawBaseCases = (operator: '&&' | '||') => [ // chained members { - code: `foo ${operator} foo.bar`, - output: 'foo?.bar', + id: 1, + declaration: 'declare const foo: {bar: number} | null | undefined;', + chain: `foo ${operator} foo.bar;`, + outputChain: 'foo?.bar;', }, { - code: `foo.bar ${operator} foo.bar.baz`, - output: 'foo.bar?.baz', + id: 2, + declaration: + 'declare const foo: {bar: {baz: number} | null | undefined};', + chain: `foo.bar ${operator} foo.bar.baz;`, + outputChain: 'foo.bar?.baz;', }, { - code: `foo ${operator} foo()`, - output: 'foo?.()', + id: 3, + declaration: 'declare const foo: (() => number) | null | undefined;', + chain: `foo ${operator} foo();`, + outputChain: 'foo?.();', }, { - code: `foo.bar ${operator} foo.bar()`, - output: 'foo.bar?.()', + id: 4, + declaration: + 'declare const foo: {bar: (() => number) | null | undefined};', + chain: `foo.bar ${operator} foo.bar();`, + outputChain: 'foo.bar?.();', }, { - code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz`, - output: 'foo?.bar?.baz?.buzz', + id: 5, + declaration: + 'declare const foo: {bar: {baz: {buzz: number} | null | undefined} | null | undefined} | null | undefined;', + chain: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz;`, + outputChain: 'foo?.bar?.baz?.buzz;', }, { - code: `foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz`, - output: 'foo.bar?.baz?.buzz', + id: 6, + declaration: + 'declare const foo: {bar: {baz: {buzz: number} | null | undefined} | null | undefined};', + chain: `foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz;`, + outputChain: 'foo.bar?.baz?.buzz;', }, // case with a jump (i.e. a non-nullish prop) { - code: `foo ${operator} foo.bar ${operator} foo.bar.baz.buzz`, - output: 'foo?.bar?.baz.buzz', + id: 7, + declaration: + 'declare const foo: {bar: {baz: {buzz: number}} | null | undefined} | null | undefined;', + chain: `foo ${operator} foo.bar ${operator} foo.bar.baz.buzz;`, + outputChain: 'foo?.bar?.baz.buzz;', }, { - code: `foo.bar ${operator} foo.bar.baz.buzz`, - output: 'foo.bar?.baz.buzz', + id: 8, + declaration: + 'declare const foo: {bar: {baz: {buzz: number}} | null | undefined};', + chain: `foo.bar ${operator} foo.bar.baz.buzz;`, + outputChain: 'foo.bar?.baz.buzz;', }, // case where for some reason there is a doubled up expression { - code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz`, - output: 'foo?.bar?.baz?.buzz', + id: 9, + declaration: + 'declare const foo: {bar: {baz: {buzz: number} | null | undefined} | null | undefined} | null | undefined;', + chain: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz;`, + outputChain: 'foo?.bar?.baz?.buzz;', }, { - code: `foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz`, - output: 'foo.bar?.baz?.buzz', + id: 10, + declaration: + 'declare const foo: {bar: {baz: {buzz: number} | null | undefined} | null | undefined} | null | undefined;', + chain: `foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz;`, + outputChain: 'foo.bar?.baz?.buzz;', }, // chained members with element access { - code: `foo ${operator} foo[bar] ${operator} foo[bar].baz ${operator} foo[bar].baz.buzz`, - output: 'foo?.[bar]?.baz?.buzz', + id: 11, + declaration: [ + 'declare const bar: string;', + 'declare const foo: {[k: string]: {baz: {buzz: number} | null | undefined} | null | undefined} | null | undefined;', + ].join('\n'), + chain: `foo ${operator} foo[bar] ${operator} foo[bar].baz ${operator} foo[bar].baz.buzz;`, + outputChain: 'foo?.[bar]?.baz?.buzz;', }, { + id: 12, // case with a jump (i.e. a non-nullish prop) - code: `foo ${operator} foo[bar].baz ${operator} foo[bar].baz.buzz`, - output: 'foo?.[bar].baz?.buzz', + declaration: [ + 'declare const bar: string;', + 'declare const foo: {[k: string]: {baz: {buzz: number} | null | undefined} | null | undefined} | null | undefined;', + ].join('\n'), + chain: `foo ${operator} foo[bar].baz ${operator} foo[bar].baz.buzz;`, + outputChain: 'foo?.[bar].baz?.buzz;', }, // case with a property access in computed property { - code: `foo ${operator} foo[bar.baz] ${operator} foo[bar.baz].buzz`, - output: 'foo?.[bar.baz]?.buzz', - }, - // case with this keyword - { - code: `foo[this.bar] ${operator} foo[this.bar].baz`, - output: 'foo[this.bar]?.baz', + id: 13, + declaration: [ + 'declare const bar: {baz: string};', + 'declare const foo: {[k: string]: {buzz: number} | null | undefined} | null | undefined;', + ].join('\n'), + chain: `foo ${operator} foo[bar.baz] ${operator} foo[bar.baz].buzz;`, + outputChain: 'foo?.[bar.baz]?.buzz;', }, // chained calls { - code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz()`, - output: 'foo?.bar?.baz?.buzz()', + id: 14, + declaration: + 'declare const foo: {bar: {baz: {buzz: () => number} | null | undefined} | null | undefined} | null | undefined;', + chain: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz();`, + outputChain: 'foo?.bar?.baz?.buzz();', }, { - code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz ${operator} foo.bar.baz.buzz()`, - output: 'foo?.bar?.baz?.buzz?.()', + id: 15, + declaration: + 'declare const foo: {bar: {baz: {buzz: (() => number) | null | undefined} | null | undefined} | null | undefined} | null | undefined;', + chain: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz ${operator} foo.bar.baz.buzz();`, + outputChain: 'foo?.bar?.baz?.buzz?.();', }, { - code: `foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz ${operator} foo.bar.baz.buzz()`, - output: 'foo.bar?.baz?.buzz?.()', + id: 16, + declaration: + 'declare const foo: {bar: {baz: {buzz: (() => number) | null | undefined} | null | undefined} | null | undefined};', + chain: `foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz.buzz ${operator} foo.bar.baz.buzz();`, + outputChain: 'foo.bar?.baz?.buzz?.();', }, // case with a jump (i.e. a non-nullish prop) { - code: `foo ${operator} foo.bar ${operator} foo.bar.baz.buzz()`, - output: 'foo?.bar?.baz.buzz()', + id: 17, + declaration: + 'declare const foo: {bar: {baz: {buzz: () => number}} | null | undefined} | null | undefined;', + chain: `foo ${operator} foo.bar ${operator} foo.bar.baz.buzz();`, + outputChain: 'foo?.bar?.baz.buzz();', }, { - code: `foo.bar ${operator} foo.bar.baz.buzz()`, - output: 'foo.bar?.baz.buzz()', + id: 18, + declaration: + 'declare const foo: {bar: {baz: {buzz: () => number}} | null | undefined};', + chain: `foo.bar ${operator} foo.bar.baz.buzz();`, + outputChain: 'foo.bar?.baz.buzz();', }, { + id: 19, // case with a jump (i.e. a non-nullish prop) - code: `foo ${operator} foo.bar ${operator} foo.bar.baz.buzz ${operator} foo.bar.baz.buzz()`, - output: 'foo?.bar?.baz.buzz?.()', + declaration: + 'declare const foo: {bar: {baz: {buzz: (() => number) | null | undefined}} | null | undefined} | null | undefined;', + chain: `foo ${operator} foo.bar ${operator} foo.bar.baz.buzz ${operator} foo.bar.baz.buzz();`, + outputChain: 'foo?.bar?.baz.buzz?.();', }, { + id: 20, // case with a call expr inside the chain for some inefficient reason - code: `foo ${operator} foo.bar() ${operator} foo.bar().baz ${operator} foo.bar().baz.buzz ${operator} foo.bar().baz.buzz()`, - output: 'foo?.bar()?.baz?.buzz?.()', + declaration: + 'declare const foo: {bar: () => ({baz: {buzz: (() => number) | null | undefined} | null | undefined}) | null | undefined};', + chain: `foo.bar ${operator} foo.bar() ${operator} foo.bar().baz ${operator} foo.bar().baz.buzz ${operator} foo.bar().baz.buzz();`, + outputChain: 'foo.bar?.()?.baz?.buzz?.();', }, // chained calls with element access { - code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz[buzz]()`, - output: 'foo?.bar?.baz?.[buzz]()', + id: 21, + declaration: [ + 'declare const buzz: string;', + 'declare const foo: {bar: {baz: {[k: string]: () => number} | null | undefined} | null | undefined} | null | undefined;', + ].join('\n'), + chain: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz[buzz]();`, + outputChain: 'foo?.bar?.baz?.[buzz]();', }, { - code: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz[buzz] ${operator} foo.bar.baz[buzz]()`, - output: 'foo?.bar?.baz?.[buzz]?.()', + id: 22, + declaration: [ + 'declare const buzz: string;', + 'declare const foo: {bar: {baz: {[k: string]: (() => number) | null | undefined} | null | undefined} | null | undefined} | null | undefined;', + ].join('\n'), + chain: `foo ${operator} foo.bar ${operator} foo.bar.baz ${operator} foo.bar.baz[buzz] ${operator} foo.bar.baz[buzz]();`, + outputChain: 'foo?.bar?.baz?.[buzz]?.();', }, // (partially) pre-optional chained { - code: `foo ${operator} foo?.bar ${operator} foo?.bar.baz ${operator} foo?.bar.baz[buzz] ${operator} foo?.bar.baz[buzz]()`, - output: 'foo?.bar?.baz?.[buzz]?.()', + id: 23, + declaration: [ + 'declare const buzz: string;', + 'declare const foo: {bar: {baz: {[k: string]: (() => number) | null | undefined} | null | undefined} | null | undefined} | null | undefined;', + ].join('\n'), + chain: `foo ${operator} foo?.bar ${operator} foo?.bar.baz ${operator} foo?.bar.baz[buzz] ${operator} foo?.bar.baz[buzz]();`, + outputChain: 'foo?.bar?.baz?.[buzz]?.();', }, { - code: `foo ${operator} foo?.bar.baz ${operator} foo?.bar.baz[buzz]`, - output: 'foo?.bar.baz?.[buzz]', + id: 24, + declaration: [ + 'declare const buzz: string;', + 'declare const foo: {bar: {baz: {[k: string]: number} | null | undefined}} | null | undefined;', + ].join('\n'), + chain: `foo ${operator} foo?.bar.baz ${operator} foo?.bar.baz[buzz];`, + outputChain: 'foo?.bar.baz?.[buzz];', }, { - code: `foo ${operator} foo?.() ${operator} foo?.().bar`, - output: 'foo?.()?.bar', + id: 25, + declaration: + 'declare const foo: (() => ({bar: number} | null | undefined)) | null | undefined;', + chain: `foo ${operator} foo?.() ${operator} foo?.().bar;`, + outputChain: 'foo?.()?.bar;', }, { - code: `foo.bar ${operator} foo.bar?.() ${operator} foo.bar?.().baz`, - output: 'foo.bar?.()?.baz', + id: 26, + declaration: + 'declare const foo: {bar: () => ({baz: number} | null | undefined)};', + chain: `foo.bar ${operator} foo.bar?.() ${operator} foo.bar?.().baz;`, + outputChain: 'foo.bar?.()?.baz;', + }, + ] as const; + +export const identity: MutateFn = c => c; +export const BaseCases: BaseCaseCreator = ({ + operator, + mutateCode = identity, + mutateOutput = mutateCode, + mutateDeclaration = identity, + useSuggestionFixer = false, +}) => + RawBaseCases(operator).map( + ({ + id, + declaration: originalDeclaration, + chain, + outputChain, + }): InvalidTestCase< + PreferOptionalChainMessageIds, + [PreferOptionalChainOptions] + > => { + const declaration = mutateDeclaration(originalDeclaration); + const code = `// ${id}\n${declaration}\n${chain}`; + const output = `// ${id}\n${declaration}\n${outputChain}`; + + const fixOutput = mutateOutput(output); + return { + code: mutateCode(code), + output: useSuggestionFixer ? null : fixOutput, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: !useSuggestionFixer + ? null + : [ + { + messageId: 'optionalChainSuggest', + output: fixOutput, + }, + ], + }, + ], + }; }, - ].map(({ code, output }): InvalidTestCase => { - const fixOutput = mutateOutput(output); - return { - code: mutateCode(code), - output: fixOutput, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: null, - }, - ], - }; - }); + ); diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index 177c4fe1e695..a2a72e518446 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -1824,55 +1824,85 @@ describe('base cases', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], invalid: [ - ...BaseCases('&&'), + ...BaseCases({ + operator: '&&', + }), // it should ignore parts of the expression that aren't part of the expression chain - ...BaseCases('&&', c => `${c} && bing`), - ...BaseCases('&&', c => `${c} && bing.bong`), + ...BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/;$/, ' && bing;'), + }), + ...BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/;$/, ' && bing.bong;'), + }), ], }); describe('strict nullish equality checks', () => { describe('!== null', () => { ruleTester.run('prefer-optional-chain', rule, { - valid: [], - invalid: BaseCases( - '&&', - c => c.replace(/&&/g, '!== null &&'), - identity, - ), + // with the `| null | undefined` type - `!== null` doesn't cover the + // `undefined` case - so optional chaining is not a valid conversion + valid: BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/&&/g, '!== null &&'), + mutateOutput: identity, + }), + // but if the type is just `| null` - then it covers the cases and is + // a valid conversion + invalid: BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/&&/g, '!== null &&'), + mutateOutput: identity, + mutateDeclaration: c => c.replace(/\| undefined/g, ''), + useSuggestionFixer: true, + }), }); }); describe('!= null', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], - invalid: BaseCases( - '&&', - c => c.replace(/&&/g, '!= null &&'), - identity, - ), + invalid: BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/&&/g, '!= null &&'), + mutateOutput: identity, + useSuggestionFixer: true, + }), }); }); describe('!== undefined', () => { ruleTester.run('prefer-optional-chain', rule, { - valid: [], - invalid: BaseCases( - '&&', - c => c.replace(/&&/g, '!== undefined &&'), - identity, - ), + // with the `| null | undefined` type - `!== undefined` doesn't cover the + // `null` case - so optional chaining is not a valid conversion + valid: BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/&&/g, '!== undefined &&'), + mutateOutput: identity, + }), + // but if the type is just `| undefined` - then it covers the cases and is + // a valid conversion + invalid: BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/&&/g, '!== undefined &&'), + mutateOutput: identity, + mutateDeclaration: c => c.replace(/\| null/g, ''), + useSuggestionFixer: true, + }), }); }); describe('!= undefined', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], - invalid: BaseCases( - '&&', - c => c.replace(/&&/g, '!= undefined &&'), - identity, - ), + invalid: BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/&&/g, '!= undefined &&'), + mutateOutput: identity, + useSuggestionFixer: true, + }), }); }); }); @@ -1881,51 +1911,77 @@ describe('base cases', () => { describe('or', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], - invalid: BaseCases('||', identity, c => `!${c}`), + invalid: BaseCases({ + operator: '||', + mutateCode: identity, + mutateOutput: c => `!${c}`, + }), }); describe('strict nullish equality checks', () => { - describe('=== null', () => { + describe(/*.only*/ '=== null', () => { ruleTester.run('prefer-optional-chain', rule, { - valid: [], - invalid: BaseCases( - '||', - c => c.replace(/\|\|/g, '=== null ||'), - identity, - ), + // with the `| null | undefined` type - `=== null` doesn't cover the + // `undefined` case - so optional chaining is not a valid conversion + valid: BaseCases({ + operator: '||', + mutateCode: c => c.replace(/\|\|/g, '=== null ||'), + mutateOutput: identity, + }), + // but if the type is just `| null` - then it covers the cases and is + // a valid conversion + invalid: BaseCases({ + operator: '||', + mutateCode: c => c.replace(/\|\|/g, '=== null ||'), + mutateOutput: identity, + mutateDeclaration: c => c.replace(/\| undefined/g, ''), + useSuggestionFixer: true, + }), }); }); describe('== null', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], - invalid: BaseCases( - '||', - c => c.replace(/\|\|/g, '== null ||'), - identity, - ), + invalid: BaseCases({ + operator: '||', + mutateCode: c => c.replace(/\|\|/g, '== null ||'), + mutateOutput: identity, + useSuggestionFixer: true, + }), }); }); describe('=== undefined', () => { ruleTester.run('prefer-optional-chain', rule, { - valid: [], - invalid: BaseCases( - '||', - c => c.replace(/\|\|/g, '=== undefined ||'), - identity, - ), + // with the `| null | undefined` type - `=== undefined` doesn't cover the + // `null` case - so optional chaining is not a valid conversion + valid: BaseCases({ + operator: '||', + mutateCode: c => c.replace(/\|\|/g, '=== undefined ||'), + mutateOutput: identity, + }), + // but if the type is just `| undefined` - then it covers the cases and is + // a valid conversion + invalid: BaseCases({ + operator: '||', + mutateCode: c => c.replace(/\|\|/g, '=== undefined ||'), + mutateOutput: identity, + mutateDeclaration: c => c.replace(/\| null/g, ''), + useSuggestionFixer: true, + }), }); }); describe('== undefined', () => { ruleTester.run('prefer-optional-chain', rule, { valid: [], - invalid: BaseCases( - '||', - c => c.replace(/\|\|/g, '== undefined ||'), - identity, - ), + invalid: BaseCases({ + operator: '||', + mutateCode: c => c.replace(/\|\|/g, '== undefined ||'), + mutateOutput: identity, + useSuggestionFixer: true, + }), }); }); }); @@ -1936,30 +1992,21 @@ describe('base cases', () => { valid: [], invalid: [ // it should ignore whitespace in the expressions - ...BaseCases( - '&&', - c => c.replace(/\./g, '. '), + ...BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/\./g, '. '), // note - the rule will use raw text for computed expressions - so we // need to ensure that the spacing for the computed member // expressions is retained for correct fixer matching - c => c.replace(/(\[.+\])/g, m => m.replace(/\./g, '. ')), - ), - ...BaseCases( - '&&', - c => c.replace(/\./g, '.\n'), - c => c.replace(/(\[.+\])/g, m => m.replace(/\./g, '.\n')), - ), - ], - }); - }); - - describe('should skip trailing irrelevant operands sanity checks', () => { - ruleTester.run('prefer-optional-chain', rule, { - valid: [], - invalid: [ - // it should ignore parts of the expression that aren't part of the expression chain - ...BaseCases('&&', c => `${c} && bing`), - ...BaseCases('&&', c => `${c} && bing.bong`), + mutateOutput: c => + c.replace(/(\[.+\])/g, m => m.replace(/\./g, '. ')), + }), + ...BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/\./g, '.\n'), + mutateOutput: c => + c.replace(/(\[.+\])/g, m => m.replace(/\./g, '.\n')), + }), ], }); }); From a66a4a5b7695a2a81c21c561610d2c62e7dd762e Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 24 Jun 2023 13:42:54 +0930 Subject: [PATCH 20/26] finished tests --- .../src/rules/prefer-optional-chain.ts | 30 ++++++ .../rules/prefer-optional-chain/base-cases.ts | 80 +++++++++------- .../prefer-optional-chain.test.ts | 91 ++++++++++++------- 3 files changed, 132 insertions(+), 69 deletions(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 247b09155822..82d42fc2613b 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -212,3 +212,33 @@ export default util.createRule< }; }, }); + +/* +TODO(bradzacher) + +``` +declare const foo: { bar: number } | null; +foo === null || foo.bar; +foo !== null && !foo.bar; +``` + +In the first case foo.bar is seen by my code as a "truthy boolean" check - which +isn't a valid operand in an "OR" chain. +Similarly in the second case !foo.bar is seen as a "falsy boolean" check - which +isn't a valid operand in an "AND" chain. + +This theme carries on to the following styles of code: + +``` +foo && foo.bar && foo.bar.baz === 1 +// rule will currently fix to +foo?.bar && foo.bar.baz === 1 +``` + +Wherein the last operand is "invalid" when evaluating the chain. + +For now we do not handle these cases to keep the logic simpler - however we will +probably want to handle them in future. +This sort of check is easy for a human to do visually but is hard for code to do +based on the AST. +*/ diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts index 0bfeeb068c44..4ea5f6cc087b 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/base-cases.ts @@ -12,6 +12,7 @@ type BaseCaseCreator = (args: { mutateOutput?: MutateFn; mutateDeclaration?: MutateFn; useSuggestionFixer?: true; + skipIds?: number[]; }) => InvalidTestCase< PreferOptionalChainMessageIds, [PreferOptionalChainOptions] @@ -234,38 +235,49 @@ export const BaseCases: BaseCaseCreator = ({ mutateOutput = mutateCode, mutateDeclaration = identity, useSuggestionFixer = false, -}) => - RawBaseCases(operator).map( - ({ - id, - declaration: originalDeclaration, - chain, - outputChain, - }): InvalidTestCase< - PreferOptionalChainMessageIds, - [PreferOptionalChainOptions] - > => { - const declaration = mutateDeclaration(originalDeclaration); - const code = `// ${id}\n${declaration}\n${chain}`; - const output = `// ${id}\n${declaration}\n${outputChain}`; + skipIds = [], +}) => { + const skipIdsSet = new Set(skipIds); + const skipSpecifiedIds: ( + arg: ReturnType[number], + ) => boolean = + skipIds.length === 0 + ? (): boolean => true + : ({ id }): boolean => !skipIdsSet.has(id); - const fixOutput = mutateOutput(output); - return { - code: mutateCode(code), - output: useSuggestionFixer ? null : fixOutput, - errors: [ - { - messageId: 'preferOptionalChain', - suggestions: !useSuggestionFixer - ? null - : [ - { - messageId: 'optionalChainSuggest', - output: fixOutput, - }, - ], - }, - ], - }; - }, - ); + return RawBaseCases(operator) + .filter(skipSpecifiedIds) + .map( + ({ + id, + declaration: originalDeclaration, + chain, + outputChain, + }): InvalidTestCase< + PreferOptionalChainMessageIds, + [PreferOptionalChainOptions] + > => { + const declaration = mutateDeclaration(originalDeclaration); + const code = `// ${id}\n${declaration}\n${mutateCode(chain)}`; + const output = `// ${id}\n${declaration}\n${mutateOutput(outputChain)}`; + + return { + code, + output: useSuggestionFixer ? null : output, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: !useSuggestionFixer + ? null + : [ + { + messageId: 'optionalChainSuggest', + output, + }, + ], + }, + ], + }; + }, + ); +}; diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index a2a72e518446..1d1b60157042 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -1821,22 +1821,24 @@ describe('hand-crafted cases', () => { describe('base cases', () => { describe('and', () => { - ruleTester.run('prefer-optional-chain', rule, { - valid: [], - invalid: [ - ...BaseCases({ - operator: '&&', - }), - // it should ignore parts of the expression that aren't part of the expression chain - ...BaseCases({ - operator: '&&', - mutateCode: c => c.replace(/;$/, ' && bing;'), - }), - ...BaseCases({ - operator: '&&', - mutateCode: c => c.replace(/;$/, ' && bing.bong;'), - }), - ], + describe('boolean', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: [ + ...BaseCases({ + operator: '&&', + }), + // it should ignore parts of the expression that aren't part of the expression chain + ...BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/;$/, ' && bing;'), + }), + ...BaseCases({ + operator: '&&', + mutateCode: c => c.replace(/;$/, ' && bing.bong;'), + }), + ], + }); }); describe('strict nullish equality checks', () => { @@ -1909,17 +1911,19 @@ describe('base cases', () => { }); describe('or', () => { - ruleTester.run('prefer-optional-chain', rule, { - valid: [], - invalid: BaseCases({ - operator: '||', - mutateCode: identity, - mutateOutput: c => `!${c}`, - }), + describe('boolean', () => { + ruleTester.run('prefer-optional-chain', rule, { + valid: [], + invalid: BaseCases({ + operator: '||', + mutateCode: c => `!${c.replace(/\|\|/g, '|| !')}`, + mutateOutput: c => `!${c}`, + }), + }); }); describe('strict nullish equality checks', () => { - describe(/*.only*/ '=== null', () => { + describe('=== null', () => { ruleTester.run('prefer-optional-chain', rule, { // with the `| null | undefined` type - `=== null` doesn't cover the // `undefined` case - so optional chaining is not a valid conversion @@ -1932,8 +1936,13 @@ describe('base cases', () => { // a valid conversion invalid: BaseCases({ operator: '||', - mutateCode: c => c.replace(/\|\|/g, '=== null ||'), - mutateOutput: identity, + mutateCode: c => + c + .replace(/\|\|/g, '=== null ||') + // SEE TODO AT THE BOTTOM OF THE RULE + // We need to ensure the final operand is also a "valid" `||` check + .replace(/;$/, ' === null;'), + mutateOutput: c => c.replace(/;$/, ' === null;'), mutateDeclaration: c => c.replace(/\| undefined/g, ''), useSuggestionFixer: true, }), @@ -1945,9 +1954,13 @@ describe('base cases', () => { valid: [], invalid: BaseCases({ operator: '||', - mutateCode: c => c.replace(/\|\|/g, '== null ||'), - mutateOutput: identity, - useSuggestionFixer: true, + mutateCode: c => + c + .replace(/\|\|/g, '== null ||') + // SEE TODO AT THE BOTTOM OF THE RULE + // We need to ensure the final operand is also a "valid" `||` check + .replace(/;$/, ' == null;'), + mutateOutput: c => c.replace(/;$/, ' == null;'), }), }); }); @@ -1965,10 +1978,14 @@ describe('base cases', () => { // a valid conversion invalid: BaseCases({ operator: '||', - mutateCode: c => c.replace(/\|\|/g, '=== undefined ||'), - mutateOutput: identity, + mutateCode: c => + c + .replace(/\|\|/g, '=== undefined ||') + // SEE TODO AT THE BOTTOM OF THE RULE + // We need to ensure the final operand is also a "valid" `||` check + .replace(/;$/, ' === undefined;'), + mutateOutput: c => c.replace(/;$/, ' === undefined;'), mutateDeclaration: c => c.replace(/\| null/g, ''), - useSuggestionFixer: true, }), }); }); @@ -1978,9 +1995,13 @@ describe('base cases', () => { valid: [], invalid: BaseCases({ operator: '||', - mutateCode: c => c.replace(/\|\|/g, '== undefined ||'), - mutateOutput: identity, - useSuggestionFixer: true, + mutateCode: c => + c + .replace(/\|\|/g, '== undefined ||') + // SEE TODO AT THE BOTTOM OF THE RULE + // We need to ensure the final operand is also a "valid" `||` check + .replace(/;$/, ' == undefined;'), + mutateOutput: c => c.replace(/;$/, ' == undefined;'), }), }); }); From 52ca2e1aa0041565aa55d23692eb6936577d3c88 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 24 Jun 2023 13:54:34 +0930 Subject: [PATCH 21/26] few more tests to ensure coverage --- .../analyzeChain.ts | 5 +- .../prefer-optional-chain.test.ts | 100 ++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts index a5e617bee028..cd64a91519d7 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts @@ -472,7 +472,7 @@ export function analyzeChain( return; } - const analyzeOperand = ((): OperandAnalyzer | null => { + const analyzeOperand = (() => { switch (operator) { case '&&': return analyzeAndChainOperand; @@ -481,9 +481,6 @@ export function analyzeChain( return analyzeOrChainOperand; } })(); - if (!analyzeOperand) { - return; - } let subChain: ValidOperand[] = []; const maybeReportThenReset = ( diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index 1d1b60157042..b556b60f5ad2 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -1815,6 +1815,106 @@ describe('hand-crafted cases', () => { }, ], }, + + // allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing + { + code: ` + declare const foo: { bar: number } | null | undefined; + foo != undefined && foo.bar; + `, + output: ` + declare const foo: { bar: number } | null | undefined; + foo?.bar; + `, + options: [ + { + allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing: + true, + }, + ], + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: ` + declare const foo: { bar: number } | null | undefined; + foo != undefined && foo.bar; + `, + output: null, + options: [ + { + allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing: + false, + }, + ], + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: ` + declare const foo: { bar: number } | null | undefined; + foo?.bar; + `, + }, + ], + }, + ], + }, + + { + code: ` + declare const foo: { bar: number } | null | undefined; + foo && foo.bar != null; + `, + output: ` + declare const foo: { bar: number } | null | undefined; + foo?.bar != null; + `, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: ` + declare const foo: { bar: number } | undefined; + foo && typeof foo.bar !== 'undefined'; + `, + output: ` + declare const foo: { bar: number } | undefined; + typeof foo?.bar !== 'undefined'; + `, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, + { + code: ` + declare const foo: { bar: number } | undefined; + foo && 'undefined' !== typeof foo.bar; + `, + output: ` + declare const foo: { bar: number } | undefined; + 'undefined' !== typeof foo?.bar; + `, + errors: [ + { + messageId: 'preferOptionalChain', + suggestions: null, + }, + ], + }, ], }); }); From c58118c8d647e3816781d50dd74a436660591924 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 24 Jun 2023 13:55:34 +0930 Subject: [PATCH 22/26] remove name cos spelling --- packages/eslint-plugin/src/rules/prefer-optional-chain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 82d42fc2613b..80d0057ba220 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -214,7 +214,7 @@ export default util.createRule< }); /* -TODO(bradzacher) +TODO ``` declare const foo: { bar: number } | null; From 6e558a45f63ebeacd804617de0a23773fba40bce Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 24 Jun 2023 14:11:14 +0930 Subject: [PATCH 23/26] add docs for the new flag --- .../docs/rules/prefer-optional-chain.md | 23 ++++- .../prefer-optional-chain.test.ts | 86 +++++++++---------- 2 files changed, 65 insertions(+), 44 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/prefer-optional-chain.md b/packages/eslint-plugin/docs/rules/prefer-optional-chain.md index 2e5c73194681..6efb88b17ae5 100644 --- a/packages/eslint-plugin/docs/rules/prefer-optional-chain.md +++ b/packages/eslint-plugin/docs/rules/prefer-optional-chain.md @@ -62,6 +62,27 @@ foo?.a?.b?.c?.d?.e; In the context of the descriptions below a "loose boolean" operand is any operand that implicitly coerces the value to a boolean. Specifically the argument of the not operator (`!loose`) or a bare value in a logical expression (`loose && looser`). +### `allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing` + +When this option is `true`, the rule will not provide an auto-fixer for cases where the return type of the expression would change. For example for the expression `!foo || foo.bar` the return type of the expression is `true | T`, however for the equivalent optional chain `foo?.bar` the return type of the expression is `undefined | T`. Thus changing the code from a logical expression to an optional chain expression has altered the type of the expression. + +In some cases this distinction _may_ matter - which is why these fixers are considered unsafe - they may break the build! For example in the following code: + +```ts +declare const foo: { bar: boolean } | null | undefined; +declare function acceptsBoolean(arg: boolean): void; + +// ✅ typechecks succesfully as the expression only returns `boolean` +acceptsBoolean(foo != null && foo.bar); + +// ❌ typechecks UNSUCCESSFULLY as the expression returns `boolean | undefined` +acceptsBoolean(foo != null && foo.bar); +``` + +This style of code isn't super common - which means having this option set to `true` _should_ be safe in most codebases. However we default it to `false` due to its unsafe nature. We have provided this option for convenience because it increases the autofix cases covered by the rule. If you set option to `true` the onus is entirely on you and your team to ensure that each fix is correct and safe and that it does not break the build. + +When this option is `false` unsafe cases will have suggestion fixers provided instead of auto-fixers - meaning you can manually apply the fix using your IDE tooling. + ### `checkAny` When this option is `true` the rule will check operands that are typed as `any` when inspecting "loose boolean" operands. @@ -233,7 +254,7 @@ thing2 && thing2.toString(); ## When Not To Use It -If you don't mind using more explicit `&&`s, you don't need this rule. +If you don't mind using more explicit `&&`s/`||`s, you don't need this rule. ## Further Reading diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index b556b60f5ad2..852296721a74 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -1816,22 +1816,15 @@ describe('hand-crafted cases', () => { ], }, - // allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing { code: ` declare const foo: { bar: number } | null | undefined; - foo != undefined && foo.bar; + foo && foo.bar != null; `, output: ` declare const foo: { bar: number } | null | undefined; - foo?.bar; + foo?.bar != null; `, - options: [ - { - allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing: - true, - }, - ], errors: [ { messageId: 'preferOptionalChain', @@ -1841,40 +1834,28 @@ describe('hand-crafted cases', () => { }, { code: ` - declare const foo: { bar: number } | null | undefined; - foo != undefined && foo.bar; + declare const foo: { bar: number } | undefined; + foo && typeof foo.bar !== 'undefined'; + `, + output: ` + declare const foo: { bar: number } | undefined; + typeof foo?.bar !== 'undefined'; `, - output: null, - options: [ - { - allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing: - false, - }, - ], errors: [ { messageId: 'preferOptionalChain', - suggestions: [ - { - messageId: 'optionalChainSuggest', - output: ` - declare const foo: { bar: number } | null | undefined; - foo?.bar; - `, - }, - ], + suggestions: null, }, ], }, - { code: ` - declare const foo: { bar: number } | null | undefined; - foo && foo.bar != null; + declare const foo: { bar: number } | undefined; + foo && 'undefined' !== typeof foo.bar; `, output: ` - declare const foo: { bar: number } | null | undefined; - foo?.bar != null; + declare const foo: { bar: number } | undefined; + 'undefined' !== typeof foo?.bar; `, errors: [ { @@ -1883,15 +1864,23 @@ describe('hand-crafted cases', () => { }, ], }, + + // allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing { code: ` - declare const foo: { bar: number } | undefined; - foo && typeof foo.bar !== 'undefined'; + declare const foo: { bar: number } | null | undefined; + foo != undefined && foo.bar; `, output: ` - declare const foo: { bar: number } | undefined; - typeof foo?.bar !== 'undefined'; + declare const foo: { bar: number } | null | undefined; + foo?.bar; `, + options: [ + { + allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing: + true, + }, + ], errors: [ { messageId: 'preferOptionalChain', @@ -1901,17 +1890,28 @@ describe('hand-crafted cases', () => { }, { code: ` - declare const foo: { bar: number } | undefined; - foo && 'undefined' !== typeof foo.bar; - `, - output: ` - declare const foo: { bar: number } | undefined; - 'undefined' !== typeof foo?.bar; + declare const foo: { bar: number } | null | undefined; + foo != undefined && foo.bar; `, + output: null, + options: [ + { + allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing: + false, + }, + ], errors: [ { messageId: 'preferOptionalChain', - suggestions: null, + suggestions: [ + { + messageId: 'optionalChainSuggest', + output: ` + declare const foo: { bar: number } | null | undefined; + foo?.bar; + `, + }, + ], }, ], }, From 7d08e2510ffe39c917332103d241cf88fd12d259 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 24 Jun 2023 14:12:03 +0930 Subject: [PATCH 24/26] update schema snapshot --- .../tests/schema-snapshots/prefer-optional-chain.shot | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/eslint-plugin/tests/schema-snapshots/prefer-optional-chain.shot b/packages/eslint-plugin/tests/schema-snapshots/prefer-optional-chain.shot index 1dbaf95653b9..d46855fc7ca8 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/prefer-optional-chain.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/prefer-optional-chain.shot @@ -8,6 +8,10 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos { "additionalProperties": false, "properties": { + "allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing": { + "description": "Allow autofixers that will change the return type of the expression. This option is considered unsafe as it may break the build.", + "type": "boolean" + }, "checkAny": { "description": "Check operands that are typed as \`any\` when inspecting \\"loose boolean\\" operands.", "type": "boolean" @@ -46,6 +50,8 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos type Options = [ { + /** Allow autofixers that will change the return type of the expression. This option is considered unsafe as it may break the build. */ + allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing?: boolean; /** Check operands that are typed as \`any\` when inspecting "loose boolean" operands. */ checkAny?: boolean; /** Check operands that are typed as \`bigint\` when inspecting "loose boolean" operands. */ From 3e22d5fcc246f07006cab607ccd71b7440a6dcf6 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Fri, 7 Jul 2023 13:21:48 +0930 Subject: [PATCH 25/26] review comments --- .../analyzeChain.ts | 30 ++++++++++---- .../gatherLogicalOperands.ts | 14 +++---- .../src/rules/prefer-optional-chain.ts | 39 +++---------------- 3 files changed, 36 insertions(+), 47 deletions(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts index cd64a91519d7..39b045049657 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts @@ -239,6 +239,10 @@ function getFixer( // the test location of an if/while/etc statement. // but it's quite complex to do this without false-negatives, so // for now we'll just be over-eager with our matching. + // + // it's MUCH better to false-positive here and only provide a + // suggestion fixer, rather than false-negative and autofix to + // broken code. } } @@ -498,7 +502,17 @@ export function analyzeChain( } // we've reached the end of a chain of logical expressions - // we know the validated + // i.e. the current operand doesn't belong to the previous chain. + // + // we don't want to throw away the current operand otherwise we will skip it + // and that can cause us to miss chains. So instead we seed the new chain + // with the current operand + // + // eg this means we can catch cases like: + // unrelated != null && foo != null && foo.bar != null; + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ first "chain" + // ^^^^^^^^^^^ newChainSeed + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ second chain subChain = newChainSeed ? [...newChainSeed] : []; }; @@ -510,12 +524,14 @@ export function analyzeChain( const validatedOperands = analyzeOperand(parserServices, operand, i, chain); if (!validatedOperands) { - // TODO - check if the name is a superset/equal - if it is, then it was - // likely intended to be part of the chain and something we should - // include in the report, eg - // foo == null || foo.bar; - // ^^^^^^^^^^^ valid OR chain - // ^^^^^^^ invalid OR chain logical, but still part of the chain for combination purposes + // TODO - #7170 + // check if the name is a superset/equal - if it is, then it likely + // intended to be part of the chain and something we should include in the + // report, eg + // foo == null || foo.bar; + // ^^^^^^^^^^^ valid OR chain + // ^^^^^^^ invalid OR chain logical, but still part of + // the chain for combination purposes maybeReportThenReset(); continue; diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts index 5ecb72291872..39f3a96f32d0 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts @@ -125,10 +125,10 @@ export function gatherLogicalOperands( options: PreferOptionalChainOptions, ): { operands: Operand[]; - seenLogicals: Set; + newlySeenLogicals: Set; } { const result: Operand[] = []; - const { operands, seenLogicals } = flattenLogicalOperands(node); + const { operands, newlySeenLogicals } = flattenLogicalOperands(node); for (const operand of operands) { switch (operand.type) { @@ -293,7 +293,7 @@ export function gatherLogicalOperands( return { operands: result, - seenLogicals, + newlySeenLogicals, }; /* @@ -319,10 +319,10 @@ export function gatherLogicalOperands( */ function flattenLogicalOperands(node: TSESTree.LogicalExpression): { operands: TSESTree.Expression[]; - seenLogicals: Set; + newlySeenLogicals: Set; } { const operands: TSESTree.Expression[] = []; - const seenLogicals = new Set([node]); + const newlySeenLogicals = new Set([node]); const stack: TSESTree.Expression[] = [node.right, node.left]; let current: TSESTree.Expression | undefined; @@ -331,7 +331,7 @@ export function gatherLogicalOperands( current.type === AST_NODE_TYPES.LogicalExpression && current.operator === node.operator ) { - seenLogicals.add(current); + newlySeenLogicals.add(current); stack.push(current.right); stack.push(current.left); } else { @@ -341,7 +341,7 @@ export function gatherLogicalOperands( return { operands, - seenLogicals, + newlySeenLogicals, }; } diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 80d0057ba220..f88e37cb7371 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -173,10 +173,13 @@ export default util.createRule< return; } - const { operands, seenLogicals: newSeenLogicals } = - gatherLogicalOperands(node, parserServices, options); + const { operands, newlySeenLogicals } = gatherLogicalOperands( + node, + parserServices, + options, + ); - for (const logical of newSeenLogicals) { + for (const logical of newlySeenLogicals) { seenLogicals.add(logical); } @@ -212,33 +215,3 @@ export default util.createRule< }; }, }); - -/* -TODO - -``` -declare const foo: { bar: number } | null; -foo === null || foo.bar; -foo !== null && !foo.bar; -``` - -In the first case foo.bar is seen by my code as a "truthy boolean" check - which -isn't a valid operand in an "OR" chain. -Similarly in the second case !foo.bar is seen as a "falsy boolean" check - which -isn't a valid operand in an "AND" chain. - -This theme carries on to the following styles of code: - -``` -foo && foo.bar && foo.bar.baz === 1 -// rule will currently fix to -foo?.bar && foo.bar.baz === 1 -``` - -Wherein the last operand is "invalid" when evaluating the chain. - -For now we do not handle these cases to keep the logic simpler - however we will -probably want to handle them in future. -This sort of check is easy for a human to do visually but is hard for code to do -based on the AST. -*/ From 4c3689111ee6220bfa0b394cf7d45126a0f61a63 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Fri, 7 Jul 2023 13:41:00 +0930 Subject: [PATCH 26/26] configs --- packages/eslint-plugin/src/configs/stylistic.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/eslint-plugin/src/configs/stylistic.ts b/packages/eslint-plugin/src/configs/stylistic.ts index ed5ce3ded8c9..863a50eecda7 100644 --- a/packages/eslint-plugin/src/configs/stylistic.ts +++ b/packages/eslint-plugin/src/configs/stylistic.ts @@ -24,7 +24,5 @@ export = { '@typescript-eslint/prefer-for-of': 'error', '@typescript-eslint/prefer-function-type': 'error', '@typescript-eslint/prefer-namespace-keyword': 'error', - '@typescript-eslint/prefer-optional-chain': 'error', - '@typescript-eslint/sort-type-constituents': 'error', }, };