You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
500 lines
20 KiB
500 lines
20 KiB
/** |
|
* @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() {}, |
|
*/ |
|
}; |
|
} |
|
};
|
|
|