354 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			354 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  * @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 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) || astUtils.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 (astUtils.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/latest/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.sourceCode;
 | |
| 
 | |
|         /**
 | |
|          * 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(node.parent))
 | |
|                 ) {
 | |
|                     context.report({
 | |
|                         node,
 | |
|                         messageId: "expected",
 | |
|                         data: {
 | |
|                             operator: node.operator,
 | |
|                             expectedSide: always ? "left" : "right"
 | |
|                         },
 | |
|                         fix: fixer =>
 | |
|                             fixer.replaceText(node, getFlippedString(node))
 | |
|                     });
 | |
|                 }
 | |
|             }
 | |
|         };
 | |
|     }
 | |
| };
 |