eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
596 lines (504 loc) • 14.4 kB
JavaScript
import {findVariable} from '@eslint-community/eslint-utils';
import {
isBooleanLiteral,
isMemberExpression,
isNullLiteral,
isUndefined,
} from './ast/index.js';
import {
isParenthesized,
getParenthesizedText,
getMemberAccessOperatorRange,
hasCommentInRange,
isSameReference,
isBoolean,
shouldAddParenthesesToLogicalExpressionChild,
shouldAddParenthesesToUnaryExpressionArgument,
needsSemicolon,
isTypeScriptFile,
isTypeScriptExpressionWrapper,
unwrapTypeScriptExpression,
} from './utils/index.js';
const MESSAGE_ID_ERROR = 'prefer-logical-operator-over-ternary/error';
const MESSAGE_ID_OPTIONAL_CHAIN_ERROR = 'prefer-logical-operator-over-ternary/optional-chain-error';
const MESSAGE_ID_SUGGESTION = 'prefer-logical-operator-over-ternary/suggestion';
const MESSAGE_ID_OPTIONAL_CHAIN_SUGGESTION = 'prefer-logical-operator-over-ternary/optional-chain-suggestion';
const messages = {
[MESSAGE_ID_ERROR]: 'Prefer using a logical operator over a ternary.',
[MESSAGE_ID_OPTIONAL_CHAIN_ERROR]: 'Prefer using optional chaining over a ternary.',
[MESSAGE_ID_SUGGESTION]: 'Switch to `{{operator}}` operator.',
[MESSAGE_ID_OPTIONAL_CHAIN_SUGGESTION]: 'Switch to optional chaining.',
};
const nullishOperators = new Set(['==', '===']);
const nonNullishOperators = new Set(['!=', '!==']);
function isSameNode(left, right, sourceCode) {
if (isSameReference(left, right)) {
return true;
}
if (left.type !== right.type) {
return false;
}
switch (left.type) {
case 'AwaitExpression': {
return isSameNode(left.argument, right.argument, sourceCode);
}
case 'LogicalExpression': {
return (
left.operator === right.operator
&& isSameNode(left.left, right.left, sourceCode)
&& isSameNode(left.right, right.right, sourceCode)
);
}
case 'UnaryExpression': {
return (
left.operator === right.operator
&& left.prefix === right.prefix
&& isSameNode(left.argument, right.argument, sourceCode)
);
}
case 'UpdateExpression': {
return false;
}
// No default
}
return sourceCode.getText(left) === sourceCode.getText(right);
}
function fix({
fixer,
context,
conditionalExpression,
left,
right,
operator,
negateLeft = false,
}) {
const {sourceCode} = context;
let text = [left, right].map((node, index) => {
const isNodeParenthesized = isParenthesized(node, context);
let text = isNodeParenthesized ? getParenthesizedText(node, context) : sourceCode.getText(node);
const negate = index === 0 && negateLeft;
if (
!isNodeParenthesized
&& (negate
? shouldAddParenthesesToUnaryExpressionArgument(node, '!')
: shouldAddParenthesesToLogicalExpressionChild(node, {operator, property: index === 0 ? 'left' : 'right'}))
) {
text = `(${text})`;
}
return negate ? `!${text}` : text;
}).join(` ${operator} `);
// According to https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence#table
// There should be no cases that need to add parentheses when switching ternary to logical expression
// ASI
if (needsSemicolon(sourceCode.getTokenBefore(conditionalExpression), context, text)) {
text = `;${text}`;
}
return fixer.replaceText(conditionalExpression, text);
}
function getBooleanLiteralTypeValue(node) {
if (node?.type === 'TSTypeAnnotation') {
node = node.typeAnnotation;
}
return node?.type === 'TSLiteralType' && isBooleanLiteral(node.literal)
? node.literal.value
: undefined;
}
const isConstAssertion = node => node.type === 'TSTypeReference'
&& node.typeName.type === 'Identifier'
&& node.typeName.name === 'const';
function getConstantVariableDefinition(node, context) {
const scope = context.sourceCode.getScope(node);
const variable = findVariable(scope, node);
const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined;
if (
definition?.type !== 'Variable'
|| definition.parent.kind !== 'const'
|| definition.node.id !== definition.name
|| !definition.node.init
|| scope.variableScope !== variable.scope.variableScope
|| variable.scope.type === 'switch'
|| context.sourceCode.getRange(definition.node)[1] > context.sourceCode.getRange(node)[0]
) {
return;
}
return definition;
}
function unwrapConstantAliases(node, context) {
while (node.type === 'Identifier') {
const definition = getConstantVariableDefinition(node, context);
if (!definition) {
break;
}
node = definition.node.init;
}
return node;
}
function getBooleanConstantValue(node, context) {
const literalTypeAnnotations = [];
while (node) {
if (isBooleanLiteral(node)) {
const {value} = node;
if (literalTypeAnnotations.some(typeAnnotation => getBooleanLiteralTypeValue(typeAnnotation) !== value)) {
return;
}
return value;
}
if (node.type === 'TSNonNullExpression' || node.type === 'TSSatisfiesExpression') {
node = node.expression;
continue;
}
if (node.type === 'TSAsExpression' || node.type === 'TSTypeAssertion') {
if (!isConstAssertion(node.typeAnnotation)) {
literalTypeAnnotations.push(node.typeAnnotation);
}
node = node.expression;
continue;
}
if (node.type !== 'Identifier') {
return;
}
const definition = getConstantVariableDefinition(node, context);
if (!definition) {
return;
}
if (definition.name.typeAnnotation) {
literalTypeAnnotations.push(definition.name.typeAnnotation);
}
node = definition.node.init;
}
}
function canFixBooleanTernary(context) {
const {parserServices} = context.sourceCode;
return !parserServices?.esTreeNodeToTSNodeMap && !isTypeScriptFile(context.physicalFilename);
}
function isBooleanTernaryTest(node, context) {
try {
return isBoolean(node, context);
} catch (error) {
// Treat pathological recursive inference as unknown instead of crashing linting.
if (error instanceof RangeError) {
return false;
}
throw error;
}
}
function getBooleanTernaryProblem(conditionalExpression, context) {
const {sourceCode} = context;
if (sourceCode.getAncestors(conditionalExpression).some(node => node.type === 'WithStatement')) {
return;
}
const {test, consequent, alternate} = conditionalExpression;
const consequentValue = getBooleanConstantValue(consequent, context);
const alternateValue = getBooleanConstantValue(alternate, context);
const isConsequentBooleanConstant = consequentValue !== undefined;
if (isConsequentBooleanConstant === (alternateValue !== undefined)) {
return;
}
const booleanValue = consequentValue ?? alternateValue;
const right = isConsequentBooleanConstant ? alternate : consequent;
const negateLeft = consequentValue === false || alternateValue === true;
const canFix = canFixBooleanTernary(context);
if (!negateLeft) {
const booleanTest = canFix ? unwrapConstantAliases(test, context) : test;
if (!isBooleanTernaryTest(booleanTest, context)) {
return;
}
}
const problem = {
node: conditionalExpression,
messageId: MESSAGE_ID_ERROR,
};
if (
sourceCode.getCommentsInside(conditionalExpression).length === 0
&& canFix
) {
problem.fix = fixer => fix({
fixer,
context,
conditionalExpression,
left: test,
right,
operator: booleanValue ? '||' : '&&',
negateLeft,
});
}
return problem;
}
function getOptionalChainText(memberExpression, context) {
const {sourceCode} = context;
const range = getMemberAccessOperatorRange(memberExpression, context);
if (hasCommentInRange(context, range)) {
return;
}
const [nodeStart, nodeEnd] = sourceCode.getRange(memberExpression);
const [operatorStart, operatorEnd] = range;
return sourceCode.text.slice(nodeStart, operatorStart)
+ (memberExpression.computed ? '?.[' : '?.')
+ sourceCode.text.slice(operatorEnd, nodeEnd);
}
function getProblem({
context,
conditionalExpression,
left,
right,
operators = ['??', '||'],
}) {
// The suggestion rebuilds the expression from `left`/`right` only, so it would drop any comment elsewhere in the ternary. Report without a suggestion in that case.
if (context.sourceCode.getCommentsInside(conditionalExpression).length > 0) {
return {
node: conditionalExpression,
messageId: MESSAGE_ID_ERROR,
};
}
return {
node: conditionalExpression,
messageId: MESSAGE_ID_ERROR,
suggest: operators.map(operator => ({
messageId: MESSAGE_ID_SUGGESTION,
data: {operator},
fix: fixer => fix({
fixer,
context,
conditionalExpression,
left,
right,
operator,
}),
})),
};
}
function getNullishKind(node) {
if (isNullLiteral(node)) {
return 'null';
}
if (isUndefined(node)) {
return 'undefined';
}
}
function getNullishBinaryCheck(node) {
if (
node.type !== 'BinaryExpression'
|| (!nullishOperators.has(node.operator) && !nonNullishOperators.has(node.operator))
) {
return;
}
const leftKind = getNullishKind(node.left);
const rightKind = getNullishKind(node.right);
if (Boolean(leftKind) === Boolean(rightKind)) {
return;
}
const isTrueWhenNullish = nullishOperators.has(node.operator);
const reference = leftKind ? node.right : node.left;
const kind = node.operator.length === 2 ? 'nullish' : (leftKind ?? rightKind);
return {
reference,
kind,
isTrueWhenNullish,
};
}
const checksNullAndUndefined = (left, right) =>
(left.kind === 'null' && right.kind === 'undefined')
|| (left.kind === 'undefined' && right.kind === 'null');
function getNullishTest(node, sourceCode) {
const binaryCheck = getNullishBinaryCheck(node);
if (binaryCheck?.kind === 'nullish') {
return binaryCheck;
}
if (node.type !== 'LogicalExpression') {
return;
}
const left = getNullishBinaryCheck(node.left);
const right = getNullishBinaryCheck(node.right);
if (
!left
|| !right
|| !isSameNode(left.reference, right.reference, sourceCode)
) {
return;
}
if (
node.operator === '||'
&& left.isTrueWhenNullish
&& right.isTrueWhenNullish
&& checksNullAndUndefined(left, right)
) {
return {
reference: left.reference,
isTrueWhenNullish: true,
};
}
if (
node.operator === '&&'
&& !left.isTrueWhenNullish
&& !right.isTrueWhenNullish
&& checksNullAndUndefined(left, right)
) {
return {
reference: left.reference,
isTrueWhenNullish: false,
};
}
}
function isUnsafeOptionalChainReplacementContext(conditionalExpression) {
let node = conditionalExpression;
let {parent} = node;
while (
isTypeScriptExpressionWrapper(parent)
&& parent.expression === node
) {
node = parent;
parent = node.parent;
}
return (
(
parent.type === 'UnaryExpression'
&& parent.operator === 'delete'
&& parent.argument === node
)
|| (
parent.type === 'CallExpression'
&& parent.callee === node
)
|| (
parent.type === 'TaggedTemplateExpression'
&& parent.tag === node
)
);
}
function getNullishTernaryProblem(conditionalExpression, context) {
const {test, consequent, alternate} = conditionalExpression;
const nullishTest = getNullishTest(test, context.sourceCode);
if (
!nullishTest
|| context.sourceCode.getCommentsInside(conditionalExpression).length > 0
) {
return;
}
const {reference} = nullishTest;
if (
nullishTest.isTrueWhenNullish
&& isSameNode(reference, alternate, context.sourceCode)
) {
return getProblem({
context,
conditionalExpression,
left: alternate,
right: consequent,
operators: ['??'],
});
}
if (
!nullishTest.isTrueWhenNullish
&& isSameNode(reference, consequent, context.sourceCode)
) {
return getProblem({
context,
conditionalExpression,
left: consequent,
right: alternate,
operators: ['??'],
});
}
const nullishBranch = nullishTest.isTrueWhenNullish ? consequent : alternate;
const nonNullishBranch = nullishTest.isTrueWhenNullish ? alternate : consequent;
if (
!isUndefined(nullishBranch)
|| !isMemberExpression(nonNullishBranch)
|| nonNullishBranch.optional
|| !isSameReference(unwrapTypeScriptExpression(reference), unwrapTypeScriptExpression(nonNullishBranch.object))
|| isUnsafeOptionalChainReplacementContext(conditionalExpression)
) {
return;
}
const optionalChainText = getOptionalChainText(nonNullishBranch, context);
if (!optionalChainText) {
return;
}
return {
node: conditionalExpression,
messageId: MESSAGE_ID_OPTIONAL_CHAIN_ERROR,
suggest: [
{
messageId: MESSAGE_ID_OPTIONAL_CHAIN_SUGGESTION,
fix(fixer) {
let text = optionalChainText;
if (needsSemicolon(context.sourceCode.getTokenBefore(conditionalExpression), context, text)) {
text = `;${text}`;
}
return fixer.replaceText(conditionalExpression, text);
},
},
],
};
}
/**
@param {import('eslint').Rule.RuleContext} context
*/
const create = context => {
const {sourceCode} = context;
context.on('ConditionalExpression', conditionalExpression => {
const {test, consequent, alternate} = conditionalExpression;
const hasTwoBooleanLiteralBranches = isBooleanLiteral(consequent) && isBooleanLiteral(alternate);
if (
hasTwoBooleanLiteralBranches
|| (
sourceCode.getAncestors(conditionalExpression).every(node => node.type !== 'WithStatement')
&& getBooleanConstantValue(consequent, context) !== undefined
&& getBooleanConstantValue(alternate, context) !== undefined
)
) {
return;
}
const booleanTernaryProblem = getBooleanTernaryProblem(conditionalExpression, context);
if (booleanTernaryProblem) {
return booleanTernaryProblem;
}
const nullishTernaryProblem = getNullishTernaryProblem(conditionalExpression, context);
if (nullishTernaryProblem) {
return nullishTernaryProblem;
}
// `foo ? foo : bar`
if (isSameNode(test, consequent, sourceCode)) {
return getProblem({
context,
conditionalExpression,
left: test,
right: alternate,
});
}
// `!bar ? foo : bar`
if (
test.type === 'UnaryExpression'
&& test.operator === '!'
&& test.prefix
&& isSameNode(test.argument, alternate, sourceCode)
) {
return getProblem({
context,
conditionalExpression,
left: test.argument,
right: consequent,
});
}
});
};
/**
@type {import('eslint').Rule.RuleModule}
*/
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prefer using a logical operator over a ternary.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
messages,
languages: [
'js/js',
],
},
};
export default config;