| /** |
| * @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 ["<", "<="].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 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)) |
| }); |
| } |
| } |
| }; |
| } |
| }; |