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.
363 lines
12 KiB
363 lines
12 KiB
2 years ago
|
/**
|
||
|
* @fileoverview Rule to require or disallow yoda comparisons
|
||
|
* @author Nicholas C. Zakas
|
||
|
*/
|
||
|
"use strict";
|
||
|
|
||
|
//--------------------------------------------------------------------------
|
||
|
// Requirements
|
||
|
//--------------------------------------------------------------------------
|
||
|
|
||
|
const astUtils = require("./utils/ast-utils");
|
||
|
|
||
|
//--------------------------------------------------------------------------
|
||
|
// Helpers
|
||
|
//--------------------------------------------------------------------------
|
||
|
|
||
|
/**
|
||
|
* Determines whether an operator is a comparison operator.
|
||
|
* @param {string} operator The operator to check.
|
||
|
* @returns {boolean} Whether or not it is a comparison operator.
|
||
|
*/
|
||
|
function isComparisonOperator(operator) {
|
||
|
return /^(==|===|!=|!==|<|>|<=|>=)$/u.test(operator);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines whether an operator is an equality operator.
|
||
|
* @param {string} operator The operator to check.
|
||
|
* @returns {boolean} Whether or not it is an equality operator.
|
||
|
*/
|
||
|
function isEqualityOperator(operator) {
|
||
|
return /^(==|===)$/u.test(operator);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines whether an operator is one used in a range test.
|
||
|
* Allowed operators are `<` and `<=`.
|
||
|
* @param {string} operator The operator to check.
|
||
|
* @returns {boolean} Whether the operator is used in range tests.
|
||
|
*/
|
||
|
function isRangeTestOperator(operator) {
|
||
|
return ["<", "<="].includes(operator);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines whether a non-Literal node is a negative number that should be
|
||
|
* treated as if it were a single Literal node.
|
||
|
* @param {ASTNode} node Node to test.
|
||
|
* @returns {boolean} True if the node is a negative number that looks like a
|
||
|
* real literal and should be treated as such.
|
||
|
*/
|
||
|
function isNegativeNumericLiteral(node) {
|
||
|
return (
|
||
|
node.type === "UnaryExpression" &&
|
||
|
node.operator === "-" &&
|
||
|
node.prefix &&
|
||
|
astUtils.isNumericLiteral(node.argument)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines whether a node is a Template Literal which can be determined statically.
|
||
|
* @param {ASTNode} node Node to test
|
||
|
* @returns {boolean} True if the node is a Template Literal without expression.
|
||
|
*/
|
||
|
function isStaticTemplateLiteral(node) {
|
||
|
return node.type === "TemplateLiteral" && node.expressions.length === 0;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines whether a non-Literal node should be treated as a single Literal node.
|
||
|
* @param {ASTNode} node Node to test
|
||
|
* @returns {boolean} True if the node should be treated as a single Literal node.
|
||
|
*/
|
||
|
function looksLikeLiteral(node) {
|
||
|
return isNegativeNumericLiteral(node) || isStaticTemplateLiteral(node);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Attempts to derive a Literal node from nodes that are treated like literals.
|
||
|
* @param {ASTNode} node Node to normalize.
|
||
|
* @returns {ASTNode} One of the following options.
|
||
|
* 1. The original node if the node is already a Literal
|
||
|
* 2. A normalized Literal node with the negative number as the value if the
|
||
|
* node represents a negative number literal.
|
||
|
* 3. A normalized Literal node with the string as the value if the node is
|
||
|
* a Template Literal without expression.
|
||
|
* 4. Otherwise `null`.
|
||
|
*/
|
||
|
function getNormalizedLiteral(node) {
|
||
|
if (node.type === "Literal") {
|
||
|
return node;
|
||
|
}
|
||
|
|
||
|
if (isNegativeNumericLiteral(node)) {
|
||
|
return {
|
||
|
type: "Literal",
|
||
|
value: -node.argument.value,
|
||
|
raw: `-${node.argument.value}`
|
||
|
};
|
||
|
}
|
||
|
|
||
|
if (isStaticTemplateLiteral(node)) {
|
||
|
return {
|
||
|
type: "Literal",
|
||
|
value: node.quasis[0].value.cooked,
|
||
|
raw: node.quasis[0].value.raw
|
||
|
};
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
//------------------------------------------------------------------------------
|
||
|
// Rule Definition
|
||
|
//------------------------------------------------------------------------------
|
||
|
|
||
|
/** @type {import('../shared/types').Rule} */
|
||
|
module.exports = {
|
||
|
meta: {
|
||
|
type: "suggestion",
|
||
|
|
||
|
docs: {
|
||
|
description: 'Require or disallow "Yoda" conditions',
|
||
|
recommended: false,
|
||
|
url: "https://eslint.org/docs/rules/yoda"
|
||
|
},
|
||
|
|
||
|
schema: [
|
||
|
{
|
||
|
enum: ["always", "never"]
|
||
|
},
|
||
|
{
|
||
|
type: "object",
|
||
|
properties: {
|
||
|
exceptRange: {
|
||
|
type: "boolean",
|
||
|
default: false
|
||
|
},
|
||
|
onlyEquality: {
|
||
|
type: "boolean",
|
||
|
default: false
|
||
|
}
|
||
|
},
|
||
|
additionalProperties: false
|
||
|
}
|
||
|
],
|
||
|
|
||
|
fixable: "code",
|
||
|
messages: {
|
||
|
expected:
|
||
|
"Expected literal to be on the {{expectedSide}} side of {{operator}}."
|
||
|
}
|
||
|
},
|
||
|
|
||
|
create(context) {
|
||
|
|
||
|
// Default to "never" (!always) if no option
|
||
|
const always = context.options[0] === "always";
|
||
|
const exceptRange =
|
||
|
context.options[1] && context.options[1].exceptRange;
|
||
|
const onlyEquality =
|
||
|
context.options[1] && context.options[1].onlyEquality;
|
||
|
|
||
|
const sourceCode = context.getSourceCode();
|
||
|
|
||
|
/**
|
||
|
* Determines whether node represents a range test.
|
||
|
* A range test is a "between" test like `(0 <= x && x < 1)` or an "outside"
|
||
|
* test like `(x < 0 || 1 <= x)`. It must be wrapped in parentheses, and
|
||
|
* both operators must be `<` or `<=`. Finally, the literal on the left side
|
||
|
* must be less than or equal to the literal on the right side so that the
|
||
|
* test makes any sense.
|
||
|
* @param {ASTNode} node LogicalExpression node to test.
|
||
|
* @returns {boolean} Whether node is a range test.
|
||
|
*/
|
||
|
function isRangeTest(node) {
|
||
|
const left = node.left,
|
||
|
right = node.right;
|
||
|
|
||
|
/**
|
||
|
* Determines whether node is of the form `0 <= x && x < 1`.
|
||
|
* @returns {boolean} Whether node is a "between" range test.
|
||
|
*/
|
||
|
function isBetweenTest() {
|
||
|
if (node.operator === "&&" && astUtils.isSameReference(left.right, right.left)) {
|
||
|
const leftLiteral = getNormalizedLiteral(left.left);
|
||
|
const rightLiteral = getNormalizedLiteral(right.right);
|
||
|
|
||
|
if (leftLiteral === null && rightLiteral === null) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (rightLiteral === null || leftLiteral === null) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
if (leftLiteral.value <= rightLiteral.value) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines whether node is of the form `x < 0 || 1 <= x`.
|
||
|
* @returns {boolean} Whether node is an "outside" range test.
|
||
|
*/
|
||
|
function isOutsideTest() {
|
||
|
if (node.operator === "||" && astUtils.isSameReference(left.left, right.right)) {
|
||
|
const leftLiteral = getNormalizedLiteral(left.right);
|
||
|
const rightLiteral = getNormalizedLiteral(right.left);
|
||
|
|
||
|
if (leftLiteral === null && rightLiteral === null) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (rightLiteral === null || leftLiteral === null) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
if (leftLiteral.value <= rightLiteral.value) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines whether node is wrapped in parentheses.
|
||
|
* @returns {boolean} Whether node is preceded immediately by an open
|
||
|
* paren token and followed immediately by a close
|
||
|
* paren token.
|
||
|
*/
|
||
|
function isParenWrapped() {
|
||
|
return astUtils.isParenthesised(sourceCode, node);
|
||
|
}
|
||
|
|
||
|
return (
|
||
|
node.type === "LogicalExpression" &&
|
||
|
left.type === "BinaryExpression" &&
|
||
|
right.type === "BinaryExpression" &&
|
||
|
isRangeTestOperator(left.operator) &&
|
||
|
isRangeTestOperator(right.operator) &&
|
||
|
(isBetweenTest() || isOutsideTest()) &&
|
||
|
isParenWrapped()
|
||
|
);
|
||
|
}
|
||
|
|
||
|
const OPERATOR_FLIP_MAP = {
|
||
|
"===": "===",
|
||
|
"!==": "!==",
|
||
|
"==": "==",
|
||
|
"!=": "!=",
|
||
|
"<": ">",
|
||
|
">": "<",
|
||
|
"<=": ">=",
|
||
|
">=": "<="
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Returns a string representation of a BinaryExpression node with its sides/operator flipped around.
|
||
|
* @param {ASTNode} node The BinaryExpression node
|
||
|
* @returns {string} A string representation of the node with the sides and operator flipped
|
||
|
*/
|
||
|
function getFlippedString(node) {
|
||
|
const operatorToken = sourceCode.getFirstTokenBetween(
|
||
|
node.left,
|
||
|
node.right,
|
||
|
token => token.value === node.operator
|
||
|
);
|
||
|
const lastLeftToken = sourceCode.getTokenBefore(operatorToken);
|
||
|
const firstRightToken = sourceCode.getTokenAfter(operatorToken);
|
||
|
|
||
|
const source = sourceCode.getText();
|
||
|
|
||
|
const leftText = source.slice(
|
||
|
node.range[0],
|
||
|
lastLeftToken.range[1]
|
||
|
);
|
||
|
const textBeforeOperator = source.slice(
|
||
|
lastLeftToken.range[1],
|
||
|
operatorToken.range[0]
|
||
|
);
|
||
|
const textAfterOperator = source.slice(
|
||
|
operatorToken.range[1],
|
||
|
firstRightToken.range[0]
|
||
|
);
|
||
|
const rightText = source.slice(
|
||
|
firstRightToken.range[0],
|
||
|
node.range[1]
|
||
|
);
|
||
|
|
||
|
const tokenBefore = sourceCode.getTokenBefore(node);
|
||
|
const tokenAfter = sourceCode.getTokenAfter(node);
|
||
|
let prefix = "";
|
||
|
let suffix = "";
|
||
|
|
||
|
if (
|
||
|
tokenBefore &&
|
||
|
tokenBefore.range[1] === node.range[0] &&
|
||
|
!astUtils.canTokensBeAdjacent(tokenBefore, firstRightToken)
|
||
|
) {
|
||
|
prefix = " ";
|
||
|
}
|
||
|
|
||
|
if (
|
||
|
tokenAfter &&
|
||
|
node.range[1] === tokenAfter.range[0] &&
|
||
|
!astUtils.canTokensBeAdjacent(lastLeftToken, tokenAfter)
|
||
|
) {
|
||
|
suffix = " ";
|
||
|
}
|
||
|
|
||
|
return (
|
||
|
prefix +
|
||
|
rightText +
|
||
|
textBeforeOperator +
|
||
|
OPERATOR_FLIP_MAP[operatorToken.value] +
|
||
|
textAfterOperator +
|
||
|
leftText +
|
||
|
suffix
|
||
|
);
|
||
|
}
|
||
|
|
||
|
//--------------------------------------------------------------------------
|
||
|
// Public
|
||
|
//--------------------------------------------------------------------------
|
||
|
|
||
|
return {
|
||
|
BinaryExpression(node) {
|
||
|
const expectedLiteral = always ? node.left : node.right;
|
||
|
const expectedNonLiteral = always ? node.right : node.left;
|
||
|
|
||
|
// If `expectedLiteral` is not a literal, and `expectedNonLiteral` is a literal, raise an error.
|
||
|
if (
|
||
|
(expectedNonLiteral.type === "Literal" ||
|
||
|
looksLikeLiteral(expectedNonLiteral)) &&
|
||
|
!(
|
||
|
expectedLiteral.type === "Literal" ||
|
||
|
looksLikeLiteral(expectedLiteral)
|
||
|
) &&
|
||
|
!(!isEqualityOperator(node.operator) && onlyEquality) &&
|
||
|
isComparisonOperator(node.operator) &&
|
||
|
!(exceptRange && isRangeTest(context.getAncestors().pop()))
|
||
|
) {
|
||
|
context.report({
|
||
|
node,
|
||
|
messageId: "expected",
|
||
|
data: {
|
||
|
operator: node.operator,
|
||
|
expectedSide: always ? "left" : "right"
|
||
|
},
|
||
|
fix: fixer =>
|
||
|
fixer.replaceText(node, getFlippedString(node))
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
};
|