417 lines
19 KiB
Plaintext
417 lines
19 KiB
Plaintext
"use strict";
|
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
}
|
|
Object.defineProperty(o, k2, desc);
|
|
}) : (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
o[k2] = m[k];
|
|
}));
|
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
}) : function(o, v) {
|
|
o["default"] = v;
|
|
});
|
|
var __importStar = (this && this.__importStar) || function (mod) {
|
|
if (mod && mod.__esModule) return mod;
|
|
var result = {};
|
|
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
__setModuleDefault(result, mod);
|
|
return result;
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.analyzeChain = void 0;
|
|
const utils_1 = require("@typescript-eslint/utils");
|
|
const ts_api_utils_1 = require("ts-api-utils");
|
|
const ts = __importStar(require("typescript"));
|
|
const util_1 = require("../../util");
|
|
const compareNodes_1 = require("./compareNodes");
|
|
function includesType(parserServices, node, typeFlagIn) {
|
|
const typeFlag = typeFlagIn | ts.TypeFlags.Any | ts.TypeFlags.Unknown;
|
|
const types = (0, ts_api_utils_1.unionTypeParts)(parserServices.getTypeAtLocation(node));
|
|
for (const type of types) {
|
|
if ((0, util_1.isTypeFlagSet)(type, typeFlag)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
const analyzeAndChainOperand = (parserServices, operand, index, chain) => {
|
|
switch (operand.comparisonType) {
|
|
case "Boolean" /* NullishComparisonType.Boolean */:
|
|
case "NotEqualNullOrUndefined" /* NullishComparisonType.NotEqualNullOrUndefined */:
|
|
return [operand];
|
|
case "NotStrictEqualNull" /* NullishComparisonType.NotStrictEqualNull */: {
|
|
// handle `x !== null && x !== undefined`
|
|
const nextOperand = chain[index + 1];
|
|
if (nextOperand?.comparisonType ===
|
|
"NotStrictEqualUndefined" /* NullishComparisonType.NotStrictEqualUndefined */ &&
|
|
(0, compareNodes_1.compareNodes)(operand.comparedName, nextOperand.comparedName) ===
|
|
"Equal" /* 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 "NotStrictEqualUndefined" /* NullishComparisonType.NotStrictEqualUndefined */: {
|
|
// handle `x !== undefined && x !== null`
|
|
const nextOperand = chain[index + 1];
|
|
if (nextOperand?.comparisonType ===
|
|
"NotStrictEqualNull" /* NullishComparisonType.NotStrictEqualNull */ &&
|
|
(0, compareNodes_1.compareNodes)(operand.comparedName, nextOperand.comparedName) ===
|
|
"Equal" /* NodeComparisonResult.Equal */) {
|
|
return [operand, nextOperand];
|
|
}
|
|
if (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
|
|
// optional chain would change the runtime behavior of the expression
|
|
return null;
|
|
}
|
|
return [operand];
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
const analyzeOrChainOperand = (parserServices, operand, index, chain) => {
|
|
switch (operand.comparisonType) {
|
|
case "NotBoolean" /* NullishComparisonType.NotBoolean */:
|
|
case "EqualNullOrUndefined" /* NullishComparisonType.EqualNullOrUndefined */:
|
|
return [operand];
|
|
case "StrictEqualNull" /* NullishComparisonType.StrictEqualNull */: {
|
|
// handle `x === null || x === undefined`
|
|
const nextOperand = chain[index + 1];
|
|
if (nextOperand?.comparisonType ===
|
|
"StrictEqualUndefined" /* NullishComparisonType.StrictEqualUndefined */ &&
|
|
(0, compareNodes_1.compareNodes)(operand.comparedName, nextOperand.comparedName) ===
|
|
"Equal" /* 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 "StrictEqualUndefined" /* NullishComparisonType.StrictEqualUndefined */: {
|
|
// handle `x === undefined || x === null`
|
|
const nextOperand = chain[index + 1];
|
|
if (nextOperand?.comparisonType === "StrictEqualNull" /* NullishComparisonType.StrictEqualNull */ &&
|
|
(0, compareNodes_1.compareNodes)(operand.comparedName, nextOperand.comparedName) ===
|
|
"Equal" /* NodeComparisonResult.Equal */) {
|
|
return [operand, nextOperand];
|
|
}
|
|
if (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
|
|
// optional chain would change the runtime behavior of the expression
|
|
return null;
|
|
}
|
|
return [operand];
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
function getFixer(sourceCode, parserServices, operator, options, chain) {
|
|
const lastOperand = chain[chain.length - 1];
|
|
let useSuggestionFixer;
|
|
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 ===
|
|
"EqualNullOrUndefined" /* NullishComparisonType.EqualNullOrUndefined */ ||
|
|
lastOperand.comparisonType ===
|
|
"NotEqualNullOrUndefined" /* NullishComparisonType.NotEqualNullOrUndefined */ ||
|
|
lastOperand.comparisonType ===
|
|
"StrictEqualUndefined" /* NullishComparisonType.StrictEqualUndefined */ ||
|
|
lastOperand.comparisonType ===
|
|
"NotStrictEqualUndefined" /* NullishComparisonType.NotStrictEqualUndefined */ ||
|
|
(operator === '||' &&
|
|
lastOperand.comparisonType === "NotBoolean" /* 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.
|
|
//
|
|
// it's MUCH better to false-positive here and only provide a
|
|
// suggestion fixer, rather than false-negative and autofix to
|
|
// broken code.
|
|
}
|
|
}
|
|
// 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_1.OperatorPrecedence.Invalid &&
|
|
part.precedence < util_1.OperatorPrecedence.Member) {
|
|
str += `(${part.text})`;
|
|
}
|
|
else {
|
|
str += part.text;
|
|
}
|
|
return str;
|
|
})
|
|
.join('');
|
|
if (lastOperand.node.type === utils_1.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 === utils_1.AST_NODE_TYPES.UnaryExpression
|
|
? lastOperand.node.right.operator + ' '
|
|
: '';
|
|
return {
|
|
left: sourceCode.getText(lastOperand.node.left),
|
|
right: unaryOperator + newCode,
|
|
};
|
|
}
|
|
const unaryOperator = lastOperand.node.left.type === utils_1.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 === "NotBoolean" /* NullishComparisonType.NotBoolean */) {
|
|
newCode = `!${newCode}`;
|
|
}
|
|
const fix = fixer => fixer.replaceTextRange([chain[0].node.range[0], lastOperand.node.range[1]], newCode);
|
|
return useSuggestionFixer
|
|
? { suggest: [{ fix, messageId: 'optionalChainSuggest' }] }
|
|
: { fix };
|
|
function flattenChainExpression(sourceCode, node) {
|
|
switch (node.type) {
|
|
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
return flattenChainExpression(sourceCode, node.expression);
|
|
case utils_1.AST_NODE_TYPES.CallExpression: {
|
|
const argumentsText = (() => {
|
|
const closingParenToken = (0, util_1.nullThrows)(sourceCode.getLastToken(node), util_1.NullThrowsReasons.MissingToken('closing parenthesis', node.type));
|
|
const openingParenToken = (0, util_1.nullThrows)(sourceCode.getFirstTokenBetween(node.typeArguments ?? node.callee, closingParenToken, util_1.isOpeningParenToken), util_1.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_1.OperatorPrecedence.Invalid,
|
|
requiresDot: false,
|
|
text: typeArgumentsText + argumentsText,
|
|
},
|
|
];
|
|
}
|
|
case utils_1.AST_NODE_TYPES.MemberExpression: {
|
|
const propertyText = sourceCode.getText(node.property);
|
|
return [
|
|
...flattenChainExpression(sourceCode, node.object),
|
|
{
|
|
nonNull: node.object.type === utils_1.AST_NODE_TYPES.TSNonNullExpression,
|
|
optional: node.optional,
|
|
precedence: node.computed
|
|
? // computed is already wrapped in [] so no need to wrap in () as well
|
|
util_1.OperatorPrecedence.Invalid
|
|
: (0, util_1.getOperatorPrecedenceForNode)(node.property),
|
|
requiresDot: !node.computed,
|
|
text: node.computed ? `[${propertyText}]` : propertyText,
|
|
},
|
|
];
|
|
}
|
|
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
return flattenChainExpression(sourceCode, node.expression);
|
|
default:
|
|
return [
|
|
{
|
|
nonNull: false,
|
|
optional: false,
|
|
precedence: (0, util_1.getOperatorPrecedenceForNode)(node),
|
|
requiresDot: false,
|
|
text: sourceCode.getText(node),
|
|
},
|
|
];
|
|
}
|
|
}
|
|
}
|
|
function analyzeChain(context, parserServices, options, operator, chain) {
|
|
// 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 = (() => {
|
|
switch (operator) {
|
|
case '&&':
|
|
return analyzeAndChainOperand;
|
|
case '||':
|
|
return analyzeOrChainOperand;
|
|
}
|
|
})();
|
|
let subChain = [];
|
|
const maybeReportThenReset = (newChainSeed) => {
|
|
if (subChain.length > 1) {
|
|
context.report({
|
|
messageId: 'preferOptionalChain',
|
|
loc: {
|
|
start: subChain[0].node.loc.start,
|
|
end: subChain[subChain.length - 1].node.loc.end,
|
|
},
|
|
...getFixer(context.sourceCode, parserServices, operator, options, subChain),
|
|
});
|
|
}
|
|
// we've reached the end of a chain of logical expressions
|
|
// 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] : [];
|
|
};
|
|
for (let i = 0; i < chain.length; i += 1) {
|
|
const lastOperand = subChain[subChain.length - 1];
|
|
const operand = chain[i];
|
|
const validatedOperands = analyzeOperand(parserServices, operand, i, chain);
|
|
if (!validatedOperands) {
|
|
// 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;
|
|
}
|
|
// 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 = (0, compareNodes_1.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 === "Subset" /* NodeComparisonResult.Subset */) {
|
|
// the operands are comparable, so we can continue searching
|
|
subChain.push(currentOperand);
|
|
}
|
|
else if (comparisonResult === "Invalid" /* NodeComparisonResult.Invalid */) {
|
|
maybeReportThenReset(validatedOperands);
|
|
}
|
|
else {
|
|
// 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();
|
|
}
|
|
exports.analyzeChain = analyzeChain;
|
|
//# sourceMappingURL=analyzeChain.js.map |