| /** |
| * @fileoverview Rule to flag constant comparisons and logical expressions that always/never short circuit |
| * @author Jordan Eldredge <https://jordaneldredge.com> |
| */ |
| |
| "use strict"; |
| |
| const { |
| isNullLiteral, |
| isConstant, |
| isReferenceToGlobalVariable, |
| isLogicalAssignmentOperator, |
| ECMASCRIPT_GLOBALS, |
| } = require("./utils/ast-utils"); |
| |
| const NUMERIC_OR_STRING_BINARY_OPERATORS = new Set([ |
| "+", |
| "-", |
| "*", |
| "/", |
| "%", |
| "|", |
| "^", |
| "&", |
| "**", |
| "<<", |
| ">>", |
| ">>>", |
| ]); |
| |
| //------------------------------------------------------------------------------ |
| // Helpers |
| //------------------------------------------------------------------------------ |
| |
| /** |
| * Checks whether or not a node is `null` or `undefined`. Similar to the one |
| * found in ast-utils.js, but this one correctly handles the edge case that |
| * `undefined` has been redefined. |
| * @param {Scope} scope Scope in which the expression was found. |
| * @param {ASTNode} node A node to check. |
| * @returns {boolean} Whether or not the node is a `null` or `undefined`. |
| * @public |
| */ |
| function isNullOrUndefined(scope, node) { |
| return ( |
| isNullLiteral(node) || |
| (node.type === "Identifier" && |
| node.name === "undefined" && |
| isReferenceToGlobalVariable(scope, node)) || |
| (node.type === "UnaryExpression" && node.operator === "void") |
| ); |
| } |
| |
| /** |
| * Test if an AST node has a statically knowable constant nullishness. Meaning, |
| * it will always resolve to a constant value of either: `null`, `undefined` |
| * or not `null` _or_ `undefined`. An expression that can vary between those |
| * three states at runtime would return `false`. |
| * @param {Scope} scope The scope in which the node was found. |
| * @param {ASTNode} node The AST node being tested. |
| * @param {boolean} nonNullish if `true` then nullish values are not considered constant. |
| * @returns {boolean} Does `node` have constant nullishness? |
| */ |
| function hasConstantNullishness(scope, node, nonNullish) { |
| if (nonNullish && isNullOrUndefined(scope, node)) { |
| return false; |
| } |
| |
| switch (node.type) { |
| case "ObjectExpression": // Objects are never nullish |
| case "ArrayExpression": // Arrays are never nullish |
| case "ArrowFunctionExpression": // Functions never nullish |
| case "FunctionExpression": // Functions are never nullish |
| case "ClassExpression": // Classes are never nullish |
| case "NewExpression": // Objects are never nullish |
| case "Literal": // Nullish, or non-nullish, literals never change |
| case "TemplateLiteral": // A string is never nullish |
| case "UpdateExpression": // Numbers are never nullish |
| case "BinaryExpression": // Numbers, strings, or booleans are never nullish |
| return true; |
| case "CallExpression": { |
| if (node.callee.type !== "Identifier") { |
| return false; |
| } |
| const functionName = node.callee.name; |
| |
| return ( |
| (functionName === "Boolean" || |
| functionName === "String" || |
| functionName === "Number") && |
| isReferenceToGlobalVariable(scope, node.callee) |
| ); |
| } |
| case "LogicalExpression": { |
| return ( |
| node.operator === "??" && |
| hasConstantNullishness(scope, node.right, true) |
| ); |
| } |
| case "AssignmentExpression": |
| if (node.operator === "=") { |
| return hasConstantNullishness(scope, node.right, nonNullish); |
| } |
| |
| /* |
| * Handling short-circuiting assignment operators would require |
| * walking the scope. We won't attempt that (for now...) / |
| */ |
| if (isLogicalAssignmentOperator(node.operator)) { |
| return false; |
| } |
| |
| /* |
| * The remaining assignment expressions all result in a numeric or |
| * string (non-nullish) value: |
| * "+=", "-=", "*=", "/=", "%=", "<<=", ">>=", ">>>=", "|=", "^=", "&=" |
| */ |
| |
| return true; |
| case "UnaryExpression": |
| /* |
| * "void" Always returns `undefined` |
| * "typeof" All types are strings, and thus non-nullish |
| * "!" Boolean is never nullish |
| * "delete" Returns a boolean, which is never nullish |
| * Math operators always return numbers or strings, neither of which |
| * are non-nullish "+", "-", "~" |
| */ |
| |
| return true; |
| case "SequenceExpression": { |
| const last = node.expressions.at(-1); |
| |
| return hasConstantNullishness(scope, last, nonNullish); |
| } |
| case "Identifier": |
| return ( |
| node.name === "undefined" && |
| isReferenceToGlobalVariable(scope, node) |
| ); |
| case "JSXElement": // ESLint has a policy of not assuming any specific JSX behavior. |
| case "JSXFragment": |
| return false; |
| default: |
| return false; |
| } |
| } |
| |
| /** |
| * Test if an AST node is a boolean value that never changes. Specifically we |
| * test for: |
| * 1. Literal booleans (`true` or `false`) |
| * 2. Unary `!` expressions with a constant value |
| * 3. Constant booleans created via the `Boolean` global function |
| * @param {Scope} scope The scope in which the node was found. |
| * @param {ASTNode} node The node to test |
| * @returns {boolean} Is `node` guaranteed to be a boolean? |
| */ |
| function isStaticBoolean(scope, node) { |
| switch (node.type) { |
| case "Literal": |
| return typeof node.value === "boolean"; |
| case "CallExpression": |
| return ( |
| node.callee.type === "Identifier" && |
| node.callee.name === "Boolean" && |
| isReferenceToGlobalVariable(scope, node.callee) && |
| (node.arguments.length === 0 || |
| isConstant(scope, node.arguments[0], true)) |
| ); |
| case "UnaryExpression": |
| return ( |
| node.operator === "!" && isConstant(scope, node.argument, true) |
| ); |
| default: |
| return false; |
| } |
| } |
| |
| /** |
| * Test if an AST node will always give the same result when compared to a |
| * boolean value. Note that comparison to boolean values is different than |
| * truthiness. |
| * https://262.ecma-international.org/5.1/#sec-11.9.3 |
| * |
| * JavaScript `==` operator works by converting the boolean to `1` (true) or |
| * `+0` (false) and then checks the values `==` equality to that number. |
| * @param {Scope} scope The scope in which node was found. |
| * @param {ASTNode} node The node to test. |
| * @returns {boolean} Will `node` always coerce to the same boolean value? |
| */ |
| function hasConstantLooseBooleanComparison(scope, node) { |
| switch (node.type) { |
| case "ObjectExpression": |
| case "ClassExpression": |
| /** |
| * In theory objects like: |
| * |
| * `{toString: () => a}` |
| * `{valueOf: () => a}` |
| * |
| * Or a classes like: |
| * |
| * `class { static toString() { return a } }` |
| * `class { static valueOf() { return a } }` |
| * |
| * Are not constant verifiably when `inBooleanPosition` is |
| * false, but it's an edge case we've opted not to handle. |
| */ |
| return true; |
| case "ArrayExpression": { |
| const nonSpreadElements = node.elements.filter( |
| e => |
| // Elements can be `null` in sparse arrays: `[,,]`; |
| e !== null && e.type !== "SpreadElement", |
| ); |
| |
| /* |
| * Possible future direction if needed: We could check if the |
| * single value would result in variable boolean comparison. |
| * For now we will err on the side of caution since `[x]` could |
| * evaluate to `[0]` or `[1]`. |
| */ |
| return node.elements.length === 0 || nonSpreadElements.length > 1; |
| } |
| case "ArrowFunctionExpression": |
| case "FunctionExpression": |
| return true; |
| case "UnaryExpression": |
| if ( |
| node.operator === "void" || // Always returns `undefined` |
| node.operator === "typeof" // All `typeof` strings, when coerced to number, are not 0 or 1. |
| ) { |
| return true; |
| } |
| if (node.operator === "!") { |
| return isConstant(scope, node.argument, true); |
| } |
| |
| /* |
| * We won't try to reason about +, -, ~, or delete |
| * In theory, for the mathematical operators, we could look at the |
| * argument and try to determine if it coerces to a constant numeric |
| * value. |
| */ |
| return false; |
| case "NewExpression": // Objects might have custom `.valueOf` or `.toString`. |
| return false; |
| case "CallExpression": { |
| if ( |
| node.callee.type === "Identifier" && |
| node.callee.name === "Boolean" && |
| isReferenceToGlobalVariable(scope, node.callee) |
| ) { |
| return ( |
| node.arguments.length === 0 || |
| isConstant(scope, node.arguments[0], true) |
| ); |
| } |
| return false; |
| } |
| case "Literal": // True or false, literals never change |
| return true; |
| case "Identifier": |
| return ( |
| node.name === "undefined" && |
| isReferenceToGlobalVariable(scope, node) |
| ); |
| case "TemplateLiteral": |
| /* |
| * In theory we could try to check if the quasi are sufficient to |
| * prove that the expression will always be true, but it would be |
| * tricky to get right. For example: `000.${foo}000` |
| */ |
| return node.expressions.length === 0; |
| case "AssignmentExpression": |
| if (node.operator === "=") { |
| return hasConstantLooseBooleanComparison(scope, node.right); |
| } |
| |
| /* |
| * Handling short-circuiting assignment operators would require |
| * walking the scope. We won't attempt that (for now...) |
| * |
| * The remaining assignment expressions all result in a numeric or |
| * string (non-nullish) values which could be truthy or falsy: |
| * "+=", "-=", "*=", "/=", "%=", "<<=", ">>=", ">>>=", "|=", "^=", "&=" |
| */ |
| return false; |
| case "SequenceExpression": { |
| const last = node.expressions.at(-1); |
| |
| return hasConstantLooseBooleanComparison(scope, last); |
| } |
| case "JSXElement": // ESLint has a policy of not assuming any specific JSX behavior. |
| case "JSXFragment": |
| return false; |
| default: |
| return false; |
| } |
| } |
| |
| /** |
| * Test if an AST node will always give the same result when _strictly_ compared |
| * to a boolean value. This can happen if the expression can never be boolean, or |
| * if it is always the same boolean value. |
| * @param {Scope} scope The scope in which the node was found. |
| * @param {ASTNode} node The node to test |
| * @returns {boolean} Will `node` always give the same result when compared to a |
| * static boolean value? |
| */ |
| function hasConstantStrictBooleanComparison(scope, node) { |
| switch (node.type) { |
| case "ObjectExpression": // Objects are not booleans |
| case "ArrayExpression": // Arrays are not booleans |
| case "ArrowFunctionExpression": // Functions are not booleans |
| case "FunctionExpression": |
| case "ClassExpression": // Classes are not booleans |
| case "NewExpression": // Objects are not booleans |
| case "TemplateLiteral": // Strings are not booleans |
| case "Literal": // True, false, or not boolean, literals never change. |
| case "UpdateExpression": // Numbers are not booleans |
| return true; |
| case "BinaryExpression": |
| return NUMERIC_OR_STRING_BINARY_OPERATORS.has(node.operator); |
| case "UnaryExpression": { |
| if (node.operator === "delete") { |
| return false; |
| } |
| if (node.operator === "!") { |
| return isConstant(scope, node.argument, true); |
| } |
| |
| /* |
| * The remaining operators return either strings or numbers, neither |
| * of which are boolean. |
| */ |
| return true; |
| } |
| case "SequenceExpression": { |
| const last = node.expressions.at(-1); |
| |
| return hasConstantStrictBooleanComparison(scope, last); |
| } |
| case "Identifier": |
| return ( |
| node.name === "undefined" && |
| isReferenceToGlobalVariable(scope, node) |
| ); |
| case "AssignmentExpression": |
| if (node.operator === "=") { |
| return hasConstantStrictBooleanComparison(scope, node.right); |
| } |
| |
| /* |
| * Handling short-circuiting assignment operators would require |
| * walking the scope. We won't attempt that (for now...) |
| */ |
| if (isLogicalAssignmentOperator(node.operator)) { |
| return false; |
| } |
| |
| /* |
| * The remaining assignment expressions all result in either a number |
| * or a string, neither of which can ever be boolean. |
| */ |
| return true; |
| case "CallExpression": { |
| if (node.callee.type !== "Identifier") { |
| return false; |
| } |
| const functionName = node.callee.name; |
| |
| if ( |
| (functionName === "String" || functionName === "Number") && |
| isReferenceToGlobalVariable(scope, node.callee) |
| ) { |
| return true; |
| } |
| if ( |
| functionName === "Boolean" && |
| isReferenceToGlobalVariable(scope, node.callee) |
| ) { |
| return ( |
| node.arguments.length === 0 || |
| isConstant(scope, node.arguments[0], true) |
| ); |
| } |
| return false; |
| } |
| case "JSXElement": // ESLint has a policy of not assuming any specific JSX behavior. |
| case "JSXFragment": |
| return false; |
| default: |
| return false; |
| } |
| } |
| |
| /** |
| * Test if an AST node will always result in a newly constructed object |
| * @param {Scope} scope The scope in which the node was found. |
| * @param {ASTNode} node The node to test |
| * @returns {boolean} Will `node` always be new? |
| */ |
| function isAlwaysNew(scope, node) { |
| switch (node.type) { |
| case "ObjectExpression": |
| case "ArrayExpression": |
| case "ArrowFunctionExpression": |
| case "FunctionExpression": |
| case "ClassExpression": |
| return true; |
| case "NewExpression": { |
| if (node.callee.type !== "Identifier") { |
| return false; |
| } |
| |
| /* |
| * All the built-in constructors are always new, but |
| * user-defined constructors could return a sentinel |
| * object. |
| * |
| * Catching these is especially useful for primitive constructors |
| * which return boxed values, a surprising gotcha' in JavaScript. |
| */ |
| return ( |
| Object.hasOwn(ECMASCRIPT_GLOBALS, node.callee.name) && |
| isReferenceToGlobalVariable(scope, node.callee) |
| ); |
| } |
| case "Literal": |
| // Regular expressions are objects, and thus always new |
| return typeof node.regex === "object"; |
| case "SequenceExpression": { |
| const last = node.expressions.at(-1); |
| |
| return isAlwaysNew(scope, last); |
| } |
| case "AssignmentExpression": |
| if (node.operator === "=") { |
| return isAlwaysNew(scope, node.right); |
| } |
| return false; |
| case "ConditionalExpression": |
| return ( |
| isAlwaysNew(scope, node.consequent) && |
| isAlwaysNew(scope, node.alternate) |
| ); |
| case "JSXElement": // ESLint has a policy of not assuming any specific JSX behavior. |
| case "JSXFragment": |
| return false; |
| default: |
| return false; |
| } |
| } |
| |
| /** |
| * Checks if one operand will cause the result to be constant. |
| * @param {Scope} scope Scope in which the expression was found. |
| * @param {ASTNode} a One side of the expression |
| * @param {ASTNode} b The other side of the expression |
| * @param {string} operator The binary expression operator |
| * @returns {ASTNode | null} The node which will cause the expression to have a constant result. |
| */ |
| function findBinaryExpressionConstantOperand(scope, a, b, operator) { |
| if (operator === "==" || operator === "!=") { |
| if ( |
| (isNullOrUndefined(scope, a) && |
| hasConstantNullishness(scope, b, false)) || |
| (isStaticBoolean(scope, a) && |
| hasConstantLooseBooleanComparison(scope, b)) |
| ) { |
| return b; |
| } |
| } else if (operator === "===" || operator === "!==") { |
| if ( |
| (isNullOrUndefined(scope, a) && |
| hasConstantNullishness(scope, b, false)) || |
| (isStaticBoolean(scope, a) && |
| hasConstantStrictBooleanComparison(scope, b)) |
| ) { |
| return b; |
| } |
| } |
| return null; |
| } |
| |
| //------------------------------------------------------------------------------ |
| // Rule Definition |
| //------------------------------------------------------------------------------ |
| |
| /** @type {import('../types').Rule.RuleModule} */ |
| module.exports = { |
| meta: { |
| type: "problem", |
| docs: { |
| description: |
| "Disallow expressions where the operation doesn't affect the value", |
| recommended: true, |
| url: "https://eslint.org/docs/latest/rules/no-constant-binary-expression", |
| }, |
| schema: [], |
| messages: { |
| constantBinaryOperand: |
| "Unexpected constant binary expression. Compares constantly with the {{otherSide}}-hand side of the `{{operator}}`.", |
| constantShortCircuit: |
| "Unexpected constant {{property}} on the left-hand side of a `{{operator}}` expression.", |
| alwaysNew: |
| "Unexpected comparison to newly constructed object. These two values can never be equal.", |
| bothAlwaysNew: |
| "Unexpected comparison of two newly constructed objects. These two values can never be equal.", |
| }, |
| }, |
| |
| create(context) { |
| const sourceCode = context.sourceCode; |
| |
| return { |
| LogicalExpression(node) { |
| const { operator, left } = node; |
| const scope = sourceCode.getScope(node); |
| |
| if ( |
| (operator === "&&" || operator === "||") && |
| isConstant(scope, left, true) |
| ) { |
| context.report({ |
| node: left, |
| messageId: "constantShortCircuit", |
| data: { property: "truthiness", operator }, |
| }); |
| } else if ( |
| operator === "??" && |
| hasConstantNullishness(scope, left, false) |
| ) { |
| context.report({ |
| node: left, |
| messageId: "constantShortCircuit", |
| data: { property: "nullishness", operator }, |
| }); |
| } |
| }, |
| BinaryExpression(node) { |
| const scope = sourceCode.getScope(node); |
| const { right, left, operator } = node; |
| const rightConstantOperand = |
| findBinaryExpressionConstantOperand( |
| scope, |
| left, |
| right, |
| operator, |
| ); |
| const leftConstantOperand = findBinaryExpressionConstantOperand( |
| scope, |
| right, |
| left, |
| operator, |
| ); |
| |
| if (rightConstantOperand) { |
| context.report({ |
| node: rightConstantOperand, |
| messageId: "constantBinaryOperand", |
| data: { operator, otherSide: "left" }, |
| }); |
| } else if (leftConstantOperand) { |
| context.report({ |
| node: leftConstantOperand, |
| messageId: "constantBinaryOperand", |
| data: { operator, otherSide: "right" }, |
| }); |
| } else if (operator === "===" || operator === "!==") { |
| if (isAlwaysNew(scope, left)) { |
| context.report({ node: left, messageId: "alwaysNew" }); |
| } else if (isAlwaysNew(scope, right)) { |
| context.report({ node: right, messageId: "alwaysNew" }); |
| } |
| } else if (operator === "==" || operator === "!=") { |
| /* |
| * If both sides are "new", then both sides are objects and |
| * therefore they will be compared by reference even with `==` |
| * equality. |
| */ |
| if (isAlwaysNew(scope, left) && isAlwaysNew(scope, right)) { |
| context.report({ |
| node: left, |
| messageId: "bothAlwaysNew", |
| }); |
| } |
| } |
| }, |
| |
| /* |
| * In theory we could handle short-circuiting assignment operators, |
| * for some constant values, but that would require walking the |
| * scope to find the value of the variable being assigned. This is |
| * dependent on https://github.com/eslint/eslint/issues/13776 |
| * |
| * AssignmentExpression() {}, |
| */ |
| }; |
| }, |
| }; |