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.
 
 
 
 
 
 

269 lines
9.2 KiB

/**
* @fileoverview Rule to require or disallow yoda comparisons
* @author Nicholas C. Zakas
*/
"use strict";
//--------------------------------------------------------------------------
// 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 (/^(==|===|!=|!==|<|>|<=|>=)$/).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 (/^(==|===)$/).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 ["<", "<="].indexOf(operator) >= 0;
}
/**
* 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 looksLikeLiteral(node) {
return (node.type === "UnaryExpression" &&
node.operator === "-" &&
node.prefix &&
node.argument.type === "Literal" &&
typeof node.argument.value === "number");
}
/**
* Attempts to derive a Literal node from nodes that are treated like literals.
* @param {ASTNode} node Node to normalize.
* @returns {ASTNode} The original node if the node is already a Literal, or a
* normalized Literal node with the negative number as the
* value if the node represents a negative number literal,
* otherwise null if the node cannot be converted to a
* normalized literal.
*/
function getNormalizedLiteral(node) {
if (node.type === "Literal") {
return node;
}
if (looksLikeLiteral(node)) {
return {
type: "Literal",
value: -node.argument.value,
raw: `-${node.argument.value}`
};
}
return null;
}
/**
* Checks whether two expressions reference the same value. For example:
* a = a
* a.b = a.b
* a[0] = a[0]
* a['b'] = a['b']
* @param {ASTNode} a Left side of the comparison.
* @param {ASTNode} b Right side of the comparison.
* @returns {boolean} True if both sides match and reference the same value.
*/
function same(a, b) {
if (a.type !== b.type) {
return false;
}
switch (a.type) {
case "Identifier":
return a.name === b.name;
case "Literal":
return a.value === b.value;
case "MemberExpression":
// x[0] = x[0]
// x[y] = x[y]
// x.y = x.y
return same(a.object, b.object) && same(a.property, b.property);
case "ThisExpression":
return true;
default:
return false;
}
}
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
module.exports = {
meta: {
docs: {
description: "require or disallow \"Yoda\" conditions",
category: "Best Practices",
recommended: false
},
schema: [
{
enum: ["always", "never"]
},
{
type: "object",
properties: {
exceptRange: {
type: "boolean"
},
onlyEquality: {
type: "boolean"
}
},
additionalProperties: false
}
]
},
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() {
let leftLiteral, rightLiteral;
return (node.operator === "&&" &&
(leftLiteral = getNormalizedLiteral(left.left)) &&
(rightLiteral = getNormalizedLiteral(right.right)) &&
leftLiteral.value <= rightLiteral.value &&
same(left.right, right.left));
}
/**
* Determines whether node is of the form `x < 0 || 1 <= x`.
* @returns {boolean} Whether node is an "outside" range test.
*/
function isOutsideTest() {
let leftLiteral, rightLiteral;
return (node.operator === "||" &&
(leftLiteral = getNormalizedLiteral(left.right)) &&
(rightLiteral = getNormalizedLiteral(right.left)) &&
leftLiteral.value <= rightLiteral.value &&
same(left.left, right.right));
}
/**
* 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() {
let tokenBefore, tokenAfter;
return ((tokenBefore = sourceCode.getTokenBefore(node)) &&
tokenBefore.value === "(" &&
(tokenAfter = sourceCode.getTokenAfter(node)) &&
tokenAfter.value === ")");
}
return (node.type === "LogicalExpression" &&
left.type === "BinaryExpression" &&
right.type === "BinaryExpression" &&
isRangeTestOperator(left.operator) &&
isRangeTestOperator(right.operator) &&
(isBetweenTest() || isOutsideTest()) &&
isParenWrapped());
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
return {
BinaryExpression: always ? function(node) {
// Comparisons must always be yoda-style: if ("blue" === color)
if (
(node.right.type === "Literal" || looksLikeLiteral(node.right)) &&
!(node.left.type === "Literal" || looksLikeLiteral(node.left)) &&
!(!isEqualityOperator(node.operator) && onlyEquality) &&
isComparisonOperator(node.operator) &&
!(exceptRange && isRangeTest(context.getAncestors().pop()))
) {
context.report({
node,
message: "Expected literal to be on the left side of {{operator}}.",
data: {
operator: node.operator
}
});
}
} : function(node) {
// Comparisons must never be yoda-style (default)
if (
(node.left.type === "Literal" || looksLikeLiteral(node.left)) &&
!(node.right.type === "Literal" || looksLikeLiteral(node.right)) &&
!(!isEqualityOperator(node.operator) && onlyEquality) &&
isComparisonOperator(node.operator) &&
!(exceptRange && isRangeTest(context.getAncestors().pop()))
) {
context.report({
node,
message: "Expected literal to be on the right side of {{operator}}.",
data: {
operator: node.operator
}
});
}
}
};
}
};