eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
375 lines (312 loc) • 10.3 kB
JavaScript
import {hasSideEffect, findVariable} from '@eslint-community/eslint-utils';
import {isBooleanLiteral, isFunction} from './ast/index.js';
import {
needsSemicolon,
isSameReference,
getParenthesizedText,
getParenthesizedRange,
shouldAddParenthesesToConditionalExpressionChild,
isParenthesized,
getPreviousNode,
getNextNode,
getLastTrailingCommentOnSameLine,
hasCommentInRange,
} from './utils/index.js';
const messageId = 'prefer-ternary';
const suggestionMessageId = 'prefer-ternary/suggestion';
function hasTernary(node, visitorKeys) {
if (!node) {
return false;
}
if (node.type === 'ConditionalExpression') {
return true;
}
// Functions and classes have their own ternary nesting context.
if (isFunction(node) || node.type === 'ClassExpression' || node.type === 'ClassDeclaration') {
return false;
}
for (const key of visitorKeys[node.type] ?? []) {
const child = node[key];
for (const childNode of Array.isArray(child) ? child : [child]) {
if (hasTernary(childNode, visitorKeys)) {
return true;
}
}
}
return false;
}
// Preserve statement/class bodies and multiline containers, while allowing ordinary wrapped expressions.
function hasComplexStructure(node, sourceCode) {
if (!node) {
return false;
}
if (node.type === 'BlockStatement' || node.type === 'ClassBody') {
return true;
}
if (
['ObjectExpression', 'ArrayExpression', 'JSXElement', 'JSXFragment', 'TemplateLiteral'].includes(node.type)
&& sourceCode.getLoc(node).start.line !== sourceCode.getLoc(node).end.line
) {
return true;
}
for (const key of sourceCode.visitorKeys[node.type] ?? []) {
const child = node[key];
for (const childNode of Array.isArray(child) ? child : [child]) {
if (hasComplexStructure(childNode, sourceCode)) {
return true;
}
}
}
return false;
}
function getNodeBody(node) {
if (node.type === 'ExpressionStatement') {
return getNodeBody(node.expression);
}
if (node.type === 'BlockStatement') {
const body = node.body.filter(({type}) => type !== 'EmptyStatement');
if (body.length === 1) {
return getNodeBody(body[0]);
}
}
return node;
}
const isSingleLineNode = (node, context) =>
context.sourceCode.getLoc(node).start.line === context.sourceCode.getLoc(node).end.line;
// Keep bare returns as explicit exits rather than introducing an undefined value branch.
const isMergeableReturnStatement = (consequent, alternate, visitorKeys) =>
consequent.type === 'ReturnStatement'
&& alternate.type === 'ReturnStatement'
&& consequent.argument !== null
&& alternate.argument !== null
&& !hasTernary(consequent.argument, visitorKeys)
&& !hasTernary(alternate.argument, visitorKeys)
&& !(isBooleanLiteral(consequent.argument) && isBooleanLiteral(alternate.argument));
const isMergeableAssignmentExpression = (consequent, alternate, visitorKeys) =>
consequent.type === 'AssignmentExpression'
&& alternate.type === 'AssignmentExpression'
&& consequent.operator === alternate.operator
&& !hasTernary(consequent.right, visitorKeys)
&& !hasTernary(alternate.right, visitorKeys)
&& isSameReference(consequent.left, alternate.left);
/**
@param {import('eslint').Rule.RuleContext} context
*/
const create = context => {
const isOnlySingleLine = context.options[0] === 'only-single-line';
const {sourceCode} = context;
const getText = node => {
let text = getParenthesizedText(node, context);
if (
!isParenthesized(node, sourceCode)
&& (
shouldAddParenthesesToConditionalExpressionChild(node)
|| (node.type === 'ArrowFunctionExpression' && node.parent.type === 'IfStatement')
)
) {
text = `(${text})`;
}
return text;
};
function merge(options, {returnFalseIfNotMergeable = false} = {}) {
const {
before = '',
consequent,
alternate,
} = options;
if (consequent.type !== alternate.type) {
return returnFalseIfNotMergeable ? false : options;
}
if (isMergeableReturnStatement(consequent, alternate, sourceCode.visitorKeys)) {
const {argument} = consequent;
return merge({
before: `${before}return `,
consequent: argument,
alternate: alternate.argument,
});
}
if (isMergeableAssignmentExpression(consequent, alternate, sourceCode.visitorKeys)) {
const {left, right, operator} = consequent;
return merge({
before: `${before}${getParenthesizedText(left, context)} ${operator} `,
consequent: right,
alternate: alternate.right,
});
}
return returnFalseIfNotMergeable ? false : options;
}
// Convert an initialized `let` followed by one conditional reassignment.
function getLetPlusIfProblem(node) {
const consequentBody = getNodeBody(node.consequent);
if (
consequentBody.type !== 'AssignmentExpression'
|| consequentBody.operator !== '='
) {
return;
}
const {left, right} = consequentBody;
if (left.type !== 'Identifier') {
return;
}
const previousNode = getPreviousNode(node, context);
if (
!previousNode
|| previousNode.type !== 'VariableDeclaration'
|| previousNode.kind !== 'let'
|| previousNode.declarations.length !== 1
) {
return;
}
const [declarator] = previousNode.declarations;
if (
declarator.id.type !== 'Identifier'
|| declarator.id.name !== left.name
|| !declarator.init
) {
return;
}
const expressions = [node.test, right, declarator.init];
if (
expressions.some(expression => hasTernary(expression, sourceCode.visitorKeys) || hasComplexStructure(expression, sourceCode))
|| (isOnlySingleLine && expressions.some(expression => !isSingleLineNode(expression, context)))
) {
return;
}
if (hasSideEffect(declarator.init, sourceCode)) {
return;
}
const scope = sourceCode.getScope(node);
const variable = findVariable(scope, left);
if (!variable) {
return;
}
const isReferenceInsideNode = (reference, targetNode) => {
const [referenceStart, referenceEnd] = sourceCode.getRange(reference.identifier);
const [nodeStart, nodeEnd] = sourceCode.getRange(targetNode);
return referenceStart >= nodeStart && referenceEnd <= nodeEnd;
};
if (variable.references.some(reference => isReferenceInsideNode(reference, node.test) || isReferenceInsideNode(reference, right))) {
return;
}
// Preserve commented decisions as statements, without reporting.
if (hasCommentInRange(context, [sourceCode.getRange(previousNode)[0], sourceCode.getRange(node)[1]])) {
return;
}
const hasOtherWrites = variable.references.some(reference => !reference.init && reference.isWrite() && !isReferenceInsideNode(reference, node));
const keyword = hasOtherWrites ? 'let' : 'const';
return {
node,
messageId,
suggest: [
{
messageId: suggestionMessageId,
* fix(fixer) {
const testText = getText(node.test);
const consequentText = getText(right);
const alternateText = getText(declarator.init);
const ternary = `${testText} ? ${consequentText} : ${alternateText}`;
const letToken = sourceCode.getFirstToken(previousNode);
yield fixer.replaceText(letToken, keyword);
yield fixer.replaceTextRange(getParenthesizedRange(declarator.init, context), ternary);
const [, declarationEnd] = sourceCode.getRange(previousNode);
const [, ifEnd] = sourceCode.getRange(node);
const nextToken = sourceCode.getTokenAfter(node);
const addSemicolon = nextToken && needsSemicolon(sourceCode.getLastToken(previousNode), context, nextToken.value);
yield fixer.replaceTextRange([declarationEnd, ifEnd], addSemicolon ? ';' : '');
},
},
],
};
}
function getIfBranchesProblem(node, alternateNode = node.alternate) {
if (
(node.parent.type === 'IfStatement' && node.parent.alternate === node)
|| hasTernary(node.test, sourceCode.visitorKeys)
) {
return;
}
const consequent = getNodeBody(node.consequent);
const alternate = getNodeBody(alternateNode);
if (
isOnlySingleLine
&& [consequent, alternate, node.test].some(node => !isSingleLineNode(node, context))
) {
return;
}
const result = merge({consequent, alternate}, {
returnFalseIfNotMergeable: true,
});
if (!result || [node.test, result.consequent, result.alternate].some(expression => hasComplexStructure(expression, sourceCode))) {
return;
}
const isFlatReturn = !node.alternate;
const replacementRange = isFlatReturn
? [sourceCode.getRange(node)[0], sourceCode.getRange(alternateNode)[1]]
: sourceCode.getRange(node);
// Preserve commented decisions as statements, without reporting.
if (
hasCommentInRange(context, replacementRange)
|| (isFlatReturn && getLastTrailingCommentOnSameLine(context, alternateNode))
) {
return;
}
return {
node,
messageId,
* fix(fixer) {
const testText = getText(node.test);
const consequentText = getText(result.consequent);
const alternateText = getText(result.alternate);
const {before} = result;
let fixed = `${before}${testText} ? ${consequentText} : ${alternateText};`;
const tokenBefore = sourceCode.getTokenBefore(node);
const shouldAddSemicolonBefore = needsSemicolon(tokenBefore, context, fixed);
if (shouldAddSemicolonBefore) {
fixed = `;${fixed}`;
}
yield fixer.replaceTextRange(replacementRange, fixed);
},
};
}
context.on('IfStatement', node => {
if (!node.alternate) {
const nextNode = getNextNode(node, context);
if (nextNode?.type === 'ReturnStatement') {
return getIfBranchesProblem(node, nextNode) ?? getLetPlusIfProblem(node);
}
return getLetPlusIfProblem(node);
}
return getIfBranchesProblem(node);
});
};
const schema = [
{
enum: ['always', 'only-single-line'],
description: 'Whether to always prefer ternary, or only for single-line expressions.',
},
];
/**
@type {import('eslint').Rule.RuleModule}
*/
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prefer ternary expressions over simple `if` statements that return or assign values.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
schema,
defaultOptions: ['always'],
messages: {
[messageId]: 'This `if` statement can be replaced by a ternary expression.',
[suggestionMessageId]: 'Use a ternary expression.',
},
languages: [
'js/js',
],
},
};
export default config;