eslint-plugin-unicorn
Version:
More than 100 powerful ESLint rules
283 lines (239 loc) • 7.28 kB
JavaScript
import {isCommentToken} from '@eslint-community/eslint-utils';
import {
isParenthesized,
getParenthesizedText,
getParenthesizedRange,
shouldAddParenthesesToLogicalExpressionChild,
} from './utils/index.js';
const MESSAGE_ID = 'prefer-simple-condition-first';
const MESSAGE_ID_SUGGESTION = 'prefer-simple-condition-first/suggestion';
const messages = {
[MESSAGE_ID]: 'Prefer simple condition first in `{{operator}}` expression.',
[MESSAGE_ID_SUGGESTION]: 'Swap conditions.',
};
/**
Check if a node is a "simple" condition:
1. Bare identifier (`foo`)
2. A binary `===`/`!==` where each operand is an identifier, a literal, or a signed
numeric literal (`-1`, `+0`), and at least one operand is an identifier.
*/
function isSimpleOperand(node) {
if (node.type === 'Identifier' || node.type === 'Literal') {
return true;
}
// Negative/positive numeric literals: `-1`, `+0`
return node.type === 'UnaryExpression'
&& (node.operator === '-' || node.operator === '+')
&& node.argument.type === 'Literal'
&& typeof node.argument.value === 'number';
}
function isSimple(node) {
if (node.type === 'Identifier') {
return true;
}
if (
node.type === 'UnaryExpression'
&& node.operator === '!'
) {
return isSimple(node.argument);
}
if (
node.type === 'BinaryExpression'
&& (node.operator === '===' || node.operator === '!==')
) {
return isSimpleOperand(node.left) && isSimpleOperand(node.right)
&& (node.left.type === 'Identifier' || node.right.type === 'Identifier');
}
// A chain of all-simple conditions is considered simple to prevent fix oscillation
if (node.type === 'LogicalExpression') {
return isSimple(node.left) && isSimple(node.right);
}
return false;
}
const sideEffectTypes = new Set([
'AssignmentExpression',
'UpdateExpression',
'TaggedTemplateExpression',
'AwaitExpression',
'YieldExpression',
'ImportExpression',
]);
/**
Check if an AST subtree contains side effects or throwing potential
(assignments, updates, member access, tagged templates, in/instanceof, await, yield, dynamic import).
These patterns are not flagged, since reordering would change program behavior.
*/
function hasSideEffectsOrThrows(node) {
if (!node || typeof node !== 'object') {
return false;
}
if (
sideEffectTypes.has(node.type)
// Property reads can throw or trigger getters, including with optional chaining.
|| node.type === 'MemberExpression'
// `in` and `instanceof` throw if the right operand is not an object/constructor
|| (node.type === 'BinaryExpression' && (node.operator === 'in' || node.operator === 'instanceof'))
) {
return true;
}
for (const key of Object.keys(node)) {
if (key === 'parent') {
continue;
}
const value = node[key];
if (Array.isArray(value)) {
if (value.some(child => hasSideEffectsOrThrows(child))) {
return true;
}
} else if (value && typeof value.type === 'string' && hasSideEffectsOrThrows(value)) {
return true;
}
}
return false;
}
/**
Check if an AST subtree contains call or new expressions.
*/
function hasCallOrNew(node) {
if (!node || typeof node !== 'object') {
return false;
}
if (node.type === 'CallExpression' || node.type === 'NewExpression') {
return true;
}
for (const key of Object.keys(node)) {
if (key === 'parent') {
continue;
}
const value = node[key];
if (Array.isArray(value)) {
if (value.some(child => hasCallOrNew(child))) {
return true;
}
} else if (value && typeof value.type === 'string' && hasCallOrNew(value)) {
return true;
}
}
return false;
}
function getSwapText(node, context, {operator, property}) {
const isNodeParenthesized = isParenthesized(node, context);
let text = isNodeParenthesized
? getParenthesizedText(node, context)
: context.sourceCode.getText(node);
if (
!isNodeParenthesized
&& shouldAddParenthesesToLogicalExpressionChild(node, {operator, property})
) {
text = `(${text})`;
}
return text;
}
/**
Check if a LogicalExpression is used in a boolean context where the
produced value is only tested for truthiness, not consumed as a value.
*/
function isBooleanContext(node) {
const {parent} = node;
if (!parent) {
return false;
}
if (
(parent.type === 'IfStatement' && parent.test === node)
|| (parent.type === 'WhileStatement' && parent.test === node)
|| (parent.type === 'DoWhileStatement' && parent.test === node)
|| (parent.type === 'ForStatement' && parent.test === node)
|| (parent.type === 'ConditionalExpression' && parent.test === node)
|| (parent.type === 'UnaryExpression' && parent.operator === '!')
) {
return true;
}
// A LogicalExpression nested inside another LogicalExpression inherits its context
if (parent.type === 'LogicalExpression') {
return isBooleanContext(parent);
}
return false;
}
function hasCommentsBetweenOperands(node, sourceCode) {
return sourceCode.getTokensBetween(node.left, node.right, {includeComments: true})
.some(token => isCommentToken(token));
}
function isReorderableLeftOperand(node) {
return node.type === 'ConditionalExpression';
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {sourceCode} = context;
context.on('LogicalExpression', node => {
if (node.operator !== '&&' && node.operator !== '||') {
return;
}
if (!isSimple(node.right) || isSimple(node.left)) {
return;
}
if (!isReorderableLeftOperand(node.left)) {
return;
}
// Only flag in boolean contexts — reordering in value-producing contexts changes the result
if (!isBooleanContext(node)) {
return;
}
// Skip expressions with side effects or throwing potential entirely
if (hasSideEffectsOrThrows(node.left)) {
return;
}
// Calls and `new` are lazy under short-circuiting, so swapping is not semantics-preserving.
if (hasCallOrNew(node.left)) {
return;
}
const rightText = getSwapText(node.right, context, {operator: node.operator, property: 'left'});
const leftText = getSwapText(node.left, context, {operator: node.operator, property: 'right'});
const hasCommentsBetween = hasCommentsBetweenOperands(node, sourceCode);
const fix = hasCommentsBetween
? undefined
: fixer => fixer.replaceTextRange(
[getParenthesizedRange(node.left, context)[0], getParenthesizedRange(node.right, context)[1]],
`${rightText} ${node.operator} ${leftText}`,
);
// Use suggestion (not auto-fix) for chains to avoid fix oscillation.
const isChain = node.left.type === 'LogicalExpression' && node.left.operator === node.operator;
if (isChain) {
return {
node,
loc: sourceCode.getLoc(node.right),
messageId: MESSAGE_ID,
data: {operator: node.operator},
...(fix && {
suggest: [
{
messageId: MESSAGE_ID_SUGGESTION,
fix,
},
],
}),
};
}
return {
node,
loc: sourceCode.getLoc(node.right),
messageId: MESSAGE_ID,
data: {operator: node.operator},
...(fix && {fix}),
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prefer simple conditions first in logical expressions.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
messages,
},
};
export default config;