Skip to content

Commit 73003bf

Browse files
authored
fix(eslint-plugin): [prefer-optional-chain] include mixed "nullish comparison style" chains in checks (#11533)
1 parent 8b05208 commit 73003bf

File tree

7 files changed

+2075
-157
lines changed

7 files changed

+2075
-157
lines changed

packages/eslint-plugin/src/rules/no-base-to-string.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ export default createRule<Options, MessageIds>({
317317

318318
const declarations = toString.getDeclarations();
319319

320+
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
320321
if (declarations == null || declarations.length !== 1) {
321322
// If there are multiple declarations, at least one of them must not be
322323
// the default object toString.

packages/eslint-plugin/src/rules/prefer-includes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export default createRule({
3939

4040
function isNumber(node: TSESTree.Node, value: number): boolean {
4141
const evaluated = getStaticValue(node, globalScope);
42-
return evaluated != null && evaluated.value === value;
42+
return evaluated?.value === value;
4343
}
4444

4545
function isPositiveCheck(node: TSESTree.BinaryExpression): boolean {

packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts

Lines changed: 182 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import type {
1010
} from '@typescript-eslint/utils/ts-eslint';
1111

1212
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
13-
import { unionConstituents } from 'ts-api-utils';
13+
import { isFalsyType, unionConstituents } from 'ts-api-utils';
1414
import * as ts from 'typescript';
1515

16-
import type { ValidOperand } from './gatherLogicalOperands';
16+
import type { LastChainOperand, ValidOperand } from './gatherLogicalOperands';
1717
import type {
1818
PreferOptionalChainMessageIds,
1919
PreferOptionalChainOptions,
@@ -31,7 +31,7 @@ import {
3131
} from '../../util';
3232
import { checkNullishAndReport } from './checkNullishAndReport';
3333
import { compareNodes, NodeComparisonResult } from './compareNodes';
34-
import { NullishComparisonType } from './gatherLogicalOperands';
34+
import { ComparisonType, NullishComparisonType } from './gatherLogicalOperands';
3535

3636
function includesType(
3737
parserServices: ParserServicesWithTypeInformation,
@@ -48,6 +48,109 @@ function includesType(
4848
return false;
4949
}
5050

51+
function isAlwaysTruthyOperand(
52+
comparedName: TSESTree.Node,
53+
nullishComparisonType: NullishComparisonType,
54+
parserServices: ParserServicesWithTypeInformation,
55+
): boolean {
56+
const ANY_UNKNOWN_FLAGS = ts.TypeFlags.Any | ts.TypeFlags.Unknown;
57+
const comparedNameType = parserServices.getTypeAtLocation(comparedName);
58+
59+
if (isTypeFlagSet(comparedNameType, ANY_UNKNOWN_FLAGS)) {
60+
return false;
61+
}
62+
switch (nullishComparisonType) {
63+
case NullishComparisonType.Boolean:
64+
case NullishComparisonType.NotBoolean: {
65+
const types = unionConstituents(comparedNameType);
66+
return types.every(type => !isFalsyType(type));
67+
}
68+
case NullishComparisonType.NotStrictEqualUndefined:
69+
case NullishComparisonType.NotStrictEqualNull:
70+
case NullishComparisonType.StrictEqualNull:
71+
case NullishComparisonType.StrictEqualUndefined:
72+
return !isTypeFlagSet(
73+
comparedNameType,
74+
ts.TypeFlags.Null | ts.TypeFlags.Undefined,
75+
);
76+
case NullishComparisonType.NotEqualNullOrUndefined:
77+
case NullishComparisonType.EqualNullOrUndefined:
78+
return !isTypeFlagSet(
79+
comparedNameType,
80+
ts.TypeFlags.Null | ts.TypeFlags.Undefined,
81+
);
82+
}
83+
}
84+
85+
function isValidAndLastChainOperand(
86+
ComparisonValueType: TSESTree.Node,
87+
comparisonType: ComparisonType,
88+
parserServices: ParserServicesWithTypeInformation,
89+
) {
90+
const type = parserServices.getTypeAtLocation(ComparisonValueType);
91+
const ANY_UNKNOWN_FLAGS = ts.TypeFlags.Any | ts.TypeFlags.Unknown;
92+
93+
const types = unionConstituents(type);
94+
switch (comparisonType) {
95+
case ComparisonType.Equal: {
96+
const isNullish = types.some(t =>
97+
isTypeFlagSet(
98+
t,
99+
ANY_UNKNOWN_FLAGS | ts.TypeFlags.Null | ts.TypeFlags.Undefined,
100+
),
101+
);
102+
return !isNullish;
103+
}
104+
case ComparisonType.StrictEqual: {
105+
const isUndefined = types.some(t =>
106+
isTypeFlagSet(t, ANY_UNKNOWN_FLAGS | ts.TypeFlags.Undefined),
107+
);
108+
return !isUndefined;
109+
}
110+
case ComparisonType.NotStrictEqual: {
111+
return types.every(t => isTypeFlagSet(t, ts.TypeFlags.Undefined));
112+
}
113+
case ComparisonType.NotEqual: {
114+
return types.every(t =>
115+
isTypeFlagSet(t, ts.TypeFlags.Undefined | ts.TypeFlags.Null),
116+
);
117+
}
118+
}
119+
}
120+
function isValidOrLastChainOperand(
121+
ComparisonValueType: TSESTree.Node,
122+
comparisonType: ComparisonType,
123+
parserServices: ParserServicesWithTypeInformation,
124+
) {
125+
const type = parserServices.getTypeAtLocation(ComparisonValueType);
126+
const ANY_UNKNOWN_FLAGS = ts.TypeFlags.Any | ts.TypeFlags.Unknown;
127+
128+
const types = unionConstituents(type);
129+
switch (comparisonType) {
130+
case ComparisonType.NotEqual: {
131+
const isNullish = types.some(t =>
132+
isTypeFlagSet(
133+
t,
134+
ANY_UNKNOWN_FLAGS | ts.TypeFlags.Null | ts.TypeFlags.Undefined,
135+
),
136+
);
137+
return !isNullish;
138+
}
139+
case ComparisonType.NotStrictEqual: {
140+
const isUndefined = types.some(t =>
141+
isTypeFlagSet(t, ANY_UNKNOWN_FLAGS | ts.TypeFlags.Undefined),
142+
);
143+
return !isUndefined;
144+
}
145+
case ComparisonType.Equal:
146+
return types.every(t =>
147+
isTypeFlagSet(t, ts.TypeFlags.Undefined | ts.TypeFlags.Null),
148+
);
149+
case ComparisonType.StrictEqual:
150+
return types.every(t => isTypeFlagSet(t, ts.TypeFlags.Undefined));
151+
}
152+
}
153+
51154
// I hate that these functions are identical aside from the enum values used
52155
// I can't think of a good way to reuse the code here in a way that will preserve
53156
// the type safety and simplicity.
@@ -65,18 +168,7 @@ const analyzeAndChainOperand: OperandAnalyzer = (
65168
chain,
66169
) => {
67170
switch (operand.comparisonType) {
68-
case NullishComparisonType.Boolean: {
69-
const nextOperand = chain.at(index + 1);
70-
if (
71-
nextOperand?.comparisonType ===
72-
NullishComparisonType.NotStrictEqualNull &&
73-
operand.comparedName.type === AST_NODE_TYPES.Identifier
74-
) {
75-
return null;
76-
}
77-
return [operand];
78-
}
79-
171+
case NullishComparisonType.Boolean:
80172
case NullishComparisonType.NotEqualNullOrUndefined:
81173
return [operand];
82174

@@ -92,7 +184,8 @@ const analyzeAndChainOperand: OperandAnalyzer = (
92184
return [operand, nextOperand];
93185
}
94186
if (
95-
includesType(
187+
nextOperand &&
188+
!includesType(
96189
parserServices,
97190
operand.comparedName,
98191
ts.TypeFlags.Undefined,
@@ -101,10 +194,9 @@ const analyzeAndChainOperand: OperandAnalyzer = (
101194
// we know the next operand is not an `undefined` check and that this
102195
// operand includes `undefined` - which means that making this an
103196
// optional chain would change the runtime behavior of the expression
104-
return null;
197+
return [operand];
105198
}
106-
107-
return [operand];
199+
return null;
108200
}
109201

110202
case NullishComparisonType.NotStrictEqualUndefined: {
@@ -156,6 +248,7 @@ const analyzeOrChainOperand: OperandAnalyzer = (
156248
) {
157249
return [operand, nextOperand];
158250
}
251+
159252
if (
160253
includesType(
161254
parserServices,
@@ -168,7 +261,6 @@ const analyzeOrChainOperand: OperandAnalyzer = (
168261
// optional chain would change the runtime behavior of the expression
169262
return null;
170263
}
171-
172264
return [operand];
173265
}
174266

@@ -207,7 +299,7 @@ const analyzeOrChainOperand: OperandAnalyzer = (
207299
* @returns The range to report.
208300
*/
209301
function getReportRange(
210-
chain: ValidOperand[],
302+
chain: { node: TSESTree.Expression }[],
211303
boundary: TSESTree.Range,
212304
sourceCode: SourceCode,
213305
): TSESTree.Range {
@@ -247,8 +339,10 @@ function getReportDescriptor(
247339
node: TSESTree.Node,
248340
operator: '&&' | '||',
249341
options: PreferOptionalChainOptions,
250-
chain: ValidOperand[],
342+
subChain: ValidOperand[],
343+
lastChain: (LastChainOperand | ValidOperand) | undefined,
251344
): ReportDescriptor<PreferOptionalChainMessageIds> {
345+
const chain = lastChain ? [...subChain, lastChain] : subChain;
252346
const lastOperand = chain[chain.length - 1];
253347

254348
let useSuggestionFixer: boolean;
@@ -264,6 +358,7 @@ function getReportDescriptor(
264358
// `undefined`, or else we're going to change the final type - which is
265359
// unsafe and might cause downstream type errors.
266360
else if (
361+
lastChain ||
267362
lastOperand.comparisonType === NullishComparisonType.EqualNullOrUndefined ||
268363
lastOperand.comparisonType ===
269364
NullishComparisonType.NotEqualNullOrUndefined ||
@@ -521,10 +616,11 @@ export function analyzeChain(
521616
node: TSESTree.Node,
522617
operator: TSESTree.LogicalExpression['operator'],
523618
chain: ValidOperand[],
619+
lastChainOperand?: LastChainOperand,
524620
): void {
525621
// need at least 2 operands in a chain for it to be a chain
526622
if (
527-
chain.length <= 1 ||
623+
chain.length + (lastChainOperand ? 1 : 0) <= 1 ||
528624
/* istanbul ignore next -- previous checks make this unreachable, but keep it for exhaustiveness check */
529625
operator === '??'
530626
) {
@@ -544,23 +640,28 @@ export function analyzeChain(
544640
// Things like x !== null && x !== undefined have two nodes, but they are
545641
// one logical unit here, so we'll allow them to be grouped.
546642
let subChain: (readonly ValidOperand[] | ValidOperand)[] = [];
643+
let lastChain: LastChainOperand | ValidOperand | undefined = undefined;
547644
const maybeReportThenReset = (
548645
newChainSeed?: readonly [ValidOperand, ...ValidOperand[]],
549646
): void => {
550-
if (subChain.length > 1) {
647+
if (subChain.length + (lastChain ? 1 : 0) > 1) {
551648
const subChainFlat = subChain.flat();
649+
const maybeNullishNodes = lastChain
650+
? subChainFlat.map(({ node }) => node)
651+
: subChainFlat.slice(0, -1).map(({ node }) => node);
552652
checkNullishAndReport(
553653
context,
554654
parserServices,
555655
options,
556-
subChainFlat.slice(0, -1).map(({ node }) => node),
656+
maybeNullishNodes,
557657
getReportDescriptor(
558658
context.sourceCode,
559659
parserServices,
560660
node,
561661
operator,
562662
options,
563663
subChainFlat,
664+
lastChain,
564665
),
565666
);
566667
}
@@ -578,6 +679,7 @@ export function analyzeChain(
578679
// ^^^^^^^^^^^ newChainSeed
579680
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ second chain
580681
subChain = newChainSeed ? [newChainSeed] : [];
682+
lastChain = undefined;
581683
};
582684

583685
for (let i = 0; i < chain.length; i += 1) {
@@ -595,6 +697,35 @@ export function analyzeChain(
595697
// ^^^^^^^ invalid OR chain logical, but still part of
596698
// the chain for combination purposes
597699

700+
if (lastOperand) {
701+
const comparisonResult = compareNodes(
702+
lastOperand.comparedName,
703+
operand.comparedName,
704+
);
705+
switch (operand.comparisonType) {
706+
case NullishComparisonType.StrictEqualUndefined:
707+
case NullishComparisonType.NotStrictEqualUndefined: {
708+
if (comparisonResult === NodeComparisonResult.Subset) {
709+
lastChain = operand;
710+
}
711+
break;
712+
}
713+
case NullishComparisonType.StrictEqualNull:
714+
case NullishComparisonType.NotStrictEqualNull: {
715+
if (
716+
comparisonResult === NodeComparisonResult.Subset &&
717+
isAlwaysTruthyOperand(
718+
lastOperand.comparedName,
719+
lastOperand.comparisonType,
720+
parserServices,
721+
)
722+
) {
723+
lastChain = operand;
724+
}
725+
break;
726+
}
727+
}
728+
}
598729
maybeReportThenReset();
599730
continue;
600731
}
@@ -624,7 +755,33 @@ export function analyzeChain(
624755
subChain.push(currentOperand);
625756
}
626757
}
758+
const lastOperand = subChain.flat().at(-1);
627759

760+
if (lastOperand && lastChainOperand) {
761+
const comparisonResult = compareNodes(
762+
lastOperand.comparedName,
763+
lastChainOperand.comparedName,
764+
);
765+
const isValidLastChainOperand =
766+
operator === '&&'
767+
? isValidAndLastChainOperand
768+
: isValidOrLastChainOperand;
769+
if (
770+
comparisonResult === NodeComparisonResult.Subset &&
771+
(isAlwaysTruthyOperand(
772+
lastOperand.comparedName,
773+
lastOperand.comparisonType,
774+
parserServices,
775+
) ||
776+
isValidLastChainOperand(
777+
lastChainOperand.comparisonValue,
778+
lastChainOperand.comparisonType,
779+
parserServices,
780+
))
781+
) {
782+
lastChain = lastChainOperand;
783+
}
784+
}
628785
// check the leftovers
629786
maybeReportThenReset();
630787
}

0 commit comments

Comments
 (0)