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.
362 lines
12 KiB
362 lines
12 KiB
/** |
|
* @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)) |
|
}); |
|
} |
|
} |
|
}; |
|
} |
|
};
|
|
|