UNPKG

eslint-plugin-unicorn

Version:
550 lines (458 loc) • 14.1 kB
import {findVariable} from '@eslint-community/eslint-utils'; import {isMethodCall} from './ast/index.js'; import { addParenthesesToReturnOrThrowExpression, fixSpaceAroundKeyword, } from './fix/index.js'; import { getNegatedExpressionText, getParenthesizedText, isBoolean, isBooleanContext, isControlFlowTest, isOnSameLine, isParenthesized, isTypeScriptExpressionWrapper, needsSemicolon, shouldAddParenthesesToLogicalExpressionChild, unwrapTypeScriptExpression, } from './utils/index.js'; import { containsOptionalChain, getBinaryExpressionWithReplacedOperatorText, isSame, negatedEqualityOperators, negatedLogicalOperators, } from './utils/comparison.js'; /** @import * as ESLint from 'eslint'; */ const MESSAGE_ID = 'prefer-simplified-conditions'; const messages = { [MESSAGE_ID]: 'Prefer a simplified condition.', }; const simpleNodeTypes = new Set([ 'Identifier', 'Literal', 'ThisExpression', ]); const safeRepeatableBooleanStaticMethods = [ ['Array', 'isArray'], ['ArrayBuffer', 'isView'], ['Error', 'isError'], ['Number', 'isFinite'], ['Number', 'isInteger'], ['Number', 'isNaN'], ['Number', 'isSafeInteger'], ]; const isNegation = node => node.type === 'UnaryExpression' && node.operator === '!' && node.prefix; const isInsideNegatedLogicalExpression = node => node.parent.type === 'LogicalExpression' && isNegation(node.parent.parent); const isEqualityComparison = node => node.type === 'BinaryExpression' && negatedEqualityOperators.has(node.operator); const isNegativeEqualityComparison = node => node.type === 'BinaryExpression' && (node.operator === '!==' || node.operator === '!='); function isSimpleAbsorptionReplacement(node) { node = unwrapTypeScriptExpression(node); return simpleNodeTypes.has(node.type); } function isIdentifierSafeToDrop(node, context) { const variable = findVariable(context.sourceCode.getScope(node), node); if (!variable || context.sourceCode.isGlobalReference(node)) { return false; } const definition = variable.defs[0]; if (!definition) { return false; } if ( definition.type === 'Parameter' || definition.type === 'FunctionName' ) { return true; } if (definition.type !== 'Variable') { return false; } return definition.parent.kind === 'var'; } function isSafeToDropAbsorptionOperand(node, context) { node = unwrapTypeScriptExpression(node); if (node.type === 'Literal') { return true; } if (node.type === 'Identifier') { return isIdentifierSafeToDrop(node, context); } if (isNegation(node)) { return isSafeToDropAbsorptionOperand(node.argument, context); } return false; } function getNegationWeight(node) { node = unwrapTypeScriptExpression(node); if (isNegation(node)) { return 1 + getNegationWeight(node.argument); } if (isNegativeEqualityComparison(node)) { return 1 + getNegationWeight(node.left) + getNegationWeight(node.right); } if (node.type === 'BinaryExpression') { return getNegationWeight(node.left) + getNegationWeight(node.right); } if (node.type === 'LogicalExpression') { return getNegationWeight(node.left) + getNegationWeight(node.right); } return 0; } function getNegatedExpressionWeight(node, context, canUseTruthiness) { node = unwrapTypeScriptExpression(node); if (isNegation(node)) { return canUseTruthiness || isBoolean(node.argument, context) ? getNegationWeight(node.argument) : 1 + getNegationWeight(node); } if (isEqualityComparison(node)) { const operatorWeight = (node.operator === '===' || node.operator === '==') ? 1 : 0; return operatorWeight + getNegationWeight(node.left) + getNegationWeight(node.right); } return 1 + getNegationWeight(node); } function shouldApplyDeMorgan(node, context) { if (context.options[0].negatedConditions === 'expand') { return true; } const {argument} = node; const canUseTruthiness = isControlFlowTest(node); // Default contract: De Morgan is only a simplification when it reduces explicit negation noise. // Plain expansions like `!(a && b)` -> `!a || !b` are often not clearer, especially for range checks like `!(min <= value && value <= max)`. // Keep the default narrower than `no-negated-comparison`, which is responsible for comparison-only negation rewrites. const originalWeight = 1 + getNegationWeight(argument); const replacementWeight = getNegatedExpressionWeight(argument.left, context, canUseTruthiness) + getNegatedExpressionWeight(argument.right, context, canUseTruthiness); return replacementWeight < originalWeight; } const canDropAbsorptionOperand = (node, replacement, removable, context) => isSafeToDropAbsorptionOperand(removable, context) && ( isBooleanContext(node, context) || (isBoolean(replacement, context) && isBoolean(removable, context)) ); // The returned text never needs parentheses as an operand of `&&`/`||`: a source operand of `!` that binds looser than `!` is always parenthesized in the source, and every generated text is a unary or equality expression. function getNegatedOperandText(node, context, canUseTruthiness) { if (isNegation(node)) { if (canUseTruthiness || isBoolean(node.argument, context)) { return getParenthesizedText(node.argument, context); } return `!${getParenthesizedText(node, context)}`; } if (isEqualityComparison(node)) { return getBinaryExpressionWithReplacedOperatorText( node, context, negatedEqualityOperators.get(node.operator), ); } return getNegatedExpressionText(node, context); } function shouldAddParenthesesToLogicalReplacement(node, operator, context) { if (isParenthesized(node, context)) { return false; } const {parent} = node; if (parent.type === 'LogicalExpression') { return shouldAddParenthesesToLogicalExpressionChild( { type: 'LogicalExpression', operator, }, { operator: parent.operator, property: parent.left === node ? 'left' : 'right', }, ); } if (isTypeScriptExpressionWrapper(parent)) { return true; } // An unparenthesized `!` or logical expression can not be a member object, a callee, or a tag. return ['UnaryExpression', 'AwaitExpression', 'BinaryExpression'].includes(parent.type); } function getLogicalReplacementText(node, operator, replacement, context) { if (shouldAddParenthesesToLogicalReplacement(node, operator, context)) { replacement = `(${replacement})`; } return needsSemicolon(context.sourceCode.getTokenBefore(node), context, replacement) ? `;${replacement}` : replacement; } function getDeMorganReplacementText(node, context) { const {argument} = node; const operator = negatedLogicalOperators.get(argument.operator); const canUseTruthiness = isControlFlowTest(node); const replacement = [ getNegatedOperandText(argument.left, context, canUseTruthiness), operator, getNegatedOperandText(argument.right, context, canUseTruthiness), ].join(' '); return getLogicalReplacementText(node, operator, replacement, context); } function needsReturnOrThrowParentheses(node, context) { const {parent} = node; if ( parent.type !== 'ReturnStatement' && parent.type !== 'ThrowStatement' ) { return false; } const {sourceCode} = context; const bangToken = sourceCode.getFirstToken(node); const tokenAfterBang = sourceCode.getTokenAfter(bangToken); return parent.argument === node && !isOnSameLine(bangToken, tokenAfterBang, context) && !isParenthesized(node, context); } function * fixDeMorgan(fixer, node, context) { const shouldAddReturnOrThrowParentheses = needsReturnOrThrowParentheses(node, context); if (!shouldAddReturnOrThrowParentheses) { yield fixSpaceAroundKeyword(fixer, node, context); } yield fixer.replaceText(node, getDeMorganReplacementText(node, context)); if (shouldAddReturnOrThrowParentheses) { yield addParenthesesToReturnOrThrowExpression(fixer, node.parent, context); } } function * fixLogicalExpression(fixer, node, replacement, context) { yield fixSpaceAroundKeyword(fixer, node, context); yield fixer.replaceText(node, replacement); } function isSimpleRepeatableExpression(node) { node = unwrapTypeScriptExpression(node); if (simpleNodeTypes.has(node.type)) { return true; } if (isNegation(node)) { return isSimpleRepeatableExpression(node.argument); } if ( node.type === 'BinaryExpression' && (node.operator === '===' || node.operator === '!==') ) { return isSimpleRepeatableExpression(node.left) && isSimpleRepeatableExpression(node.right); } return false; } function isSafeRepeatableBooleanStaticCall(node, context) { for (const [object, method] of safeRepeatableBooleanStaticMethods) { if ( isMethodCall(node, { object, method, argumentsLength: 1, optionalCall: false, optionalMember: false, }) && context.sourceCode.isGlobalReference(node.callee.object) && node.arguments.every(argument => isSimpleRepeatableExpression(argument)) ) { return true; } } return false; } function isSafeFactoringOperand(node, context) { node = unwrapTypeScriptExpression(node); if (containsOptionalChain(node)) { return false; } return isSimpleRepeatableExpression(node) || isSafeRepeatableBooleanStaticCall(node, context); } function canFactor(node, operands, context) { return operands.every(operand => isSafeFactoringOperand(operand, context)) && ( isBooleanContext(node, context) || operands.every(operand => isBoolean(operand, context)) ); } const isSameCondition = (left, right, context) => isSame(left, right) || ( isSafeFactoringOperand(left, context) && isSafeFactoringOperand(right, context) && context.sourceCode.getText(left) === context.sourceCode.getText(right) ); function getLeadingCommonFactoringTerms(node, context) { const innerOperator = negatedLogicalOperators.get(node.operator); if ( !innerOperator || node.left.type !== 'LogicalExpression' || node.right.type !== 'LogicalExpression' || node.left.operator !== innerOperator || node.right.operator !== innerOperator ) { return; } if (!isSameCondition(node.left.left, node.right.left, context)) { return; } if (isSameCondition(node.left.right, node.right.right, context)) { return; } const commonTerm = { common: node.left.left, leftOther: node.left.right, rightOther: node.right.right, }; const operands = [ commonTerm.common, commonTerm.leftOther, commonTerm.rightOther, ]; if (!canFactor(node, operands, context)) { return; } return commonTerm; } function getAbsorptionTerm(node, context) { const innerOperator = negatedLogicalOperators.get(node.operator); if (!innerOperator) { return; } if ( node.right.type === 'LogicalExpression' && node.right.operator === innerOperator && isSimpleAbsorptionReplacement(node.left) ) { if (isSameCondition(node.left, node.right.left, context)) { return node.left; } if ( isSameCondition(node.left, node.right.right, context) && canDropAbsorptionOperand(node, node.left, node.right.left, context) ) { return node.left; } } if ( node.left.type === 'LogicalExpression' && node.left.operator === innerOperator && isSimpleAbsorptionReplacement(node.right) ) { if ( isSameCondition(node.left.left, node.right, context) && canDropAbsorptionOperand(node, node.right, node.left.right, context) ) { return node.right; } if ( isSameCondition(node.left.right, node.right, context) && canDropAbsorptionOperand(node, node.right, node.left.left, context) ) { return node.right; } } } function getFactoringReplacementText(node, factoringTerms, context) { // Factoring operands are simple repeatable expressions, which never need parentheses as an operand of `&&`/`||`. const commonText = getParenthesizedText(factoringTerms.common, context); const leftOtherText = getParenthesizedText(factoringTerms.leftOther, context); const rightOtherText = getParenthesizedText(factoringTerms.rightOther, context); const replacement = `${commonText} ${node.left.operator} (${leftOtherText} ${node.operator} ${rightOtherText})`; return getLogicalReplacementText(node, node.left.operator, replacement, context); } /** @param {ESLint.Rule.RuleContext} context */ const create = context => { const {sourceCode} = context; const getProblem = (node, fix) => { const problem = { node, messageId: MESSAGE_ID, }; if (sourceCode.getCommentsInside(node).length > 0) { return problem; } return { ...problem, fix, }; }; context.on('UnaryExpression', node => { if ( !isNegation(node) || isNegation(node.parent) || isInsideNegatedLogicalExpression(node) || node.argument.type !== 'LogicalExpression' || !negatedLogicalOperators.has(node.argument.operator) || !shouldApplyDeMorgan(node, context) ) { return; } return getProblem(node, fixer => fixDeMorgan(fixer, node, context)); }); context.on('LogicalExpression', node => { if (isNegation(node.parent)) { return; } const absorptionTerm = getAbsorptionTerm(node, context); if (absorptionTerm) { return getProblem( node, fixer => fixLogicalExpression( fixer, node, getLogicalReplacementText(node, node.operator, getParenthesizedText(absorptionTerm, context), context), context, ), ); } const factoringTerms = getLeadingCommonFactoringTerms(node, context); if (!factoringTerms) { return; } return getProblem( node, fixer => fixLogicalExpression(fixer, node, getFactoringReplacementText(node, factoringTerms, context), context), ); }); }; /** @type {ESLint.Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Prefer simplified conditions.', recommended: 'unopinionated', }, fixable: 'code', schema: [ { type: 'object', additionalProperties: false, properties: { negatedConditions: { enum: ['simplify', 'expand'], description: 'Whether to expand negated logical conditions only when it reduces negations or regardless of negation count.', }, }, }, ], defaultOptions: [{negatedConditions: 'simplify'}], messages, languages: [ 'js/js', ], }, }; export default config;