123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500 |
- /**
- * @fileoverview Rule to flag constant comparisons and logical expressions that always/never short circuit
- * @author Jordan Eldredge <https://jordaneldredge.com>
- */
- "use strict";
- const globals = require("globals");
- const { isNullLiteral, isConstant, isReferenceToGlobalVariable, isLogicalAssignmentOperator } = require("./utils/ast-utils");
- const NUMERIC_OR_STRING_BINARY_OPERATORS = new Set(["+", "-", "*", "/", "%", "|", "^", "&", "**", "<<", ">>", ">>>"]);
- //------------------------------------------------------------------------------
- // Helpers
- //------------------------------------------------------------------------------
- /**
- * 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.
- * @returns {boolean} Does `node` have constant nullishness?
- */
- function hasConstantNullishness(scope, node) {
- 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 "AssignmentExpression":
- if (node.operator === "=") {
- return hasConstantNullishness(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 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[node.expressions.length - 1];
- return hasConstantNullishness(scope, last);
- }
- 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[node.expressions.length - 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[node.expressions.length - 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 constructures
- * which return boxed values, a surprising gotcha' in JavaScript.
- */
- return Object.hasOwnProperty.call(globals.builtin, 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[node.expressions.length - 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 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")
- );
- }
- /**
- * 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)) ||
- (isStaticBoolean(scope, a) && hasConstantLooseBooleanComparison(scope, b))
- ) {
- return b;
- }
- } else if (operator === "===" || operator === "!==") {
- if (
- (isNullOrUndefined(scope, a) && hasConstantNullishness(scope, b)) ||
- (isStaticBoolean(scope, a) && hasConstantStrictBooleanComparison(scope, b))
- ) {
- return b;
- }
- }
- return null;
- }
- //------------------------------------------------------------------------------
- // Rule Definition
- //------------------------------------------------------------------------------
- /** @type {import('../shared/types').Rule} */
- module.exports = {
- meta: {
- type: "problem",
- docs: {
- description: "Disallow expressions where the operation doesn't affect the value",
- recommended: false,
- url: "https://eslint.org/docs/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) {
- return {
- LogicalExpression(node) {
- const { operator, left } = node;
- const scope = context.getScope();
- if ((operator === "&&" || operator === "||") && isConstant(scope, left, true)) {
- context.report({ node: left, messageId: "constantShortCircuit", data: { property: "truthiness", operator } });
- } else if (operator === "??" && hasConstantNullishness(scope, left)) {
- context.report({ node: left, messageId: "constantShortCircuit", data: { property: "nullishness", operator } });
- }
- },
- BinaryExpression(node) {
- const scope = context.getScope();
- 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
- * dependant on https://github.com/eslint/eslint/issues/13776
- *
- * AssignmentExpression() {},
- */
- };
- }
- };
|