routie dev init since i didn't adhere to any proper guidance up until now
This commit is contained in:
Generated
Vendored
+282
@@ -0,0 +1,282 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user