1/** 2 * @fileoverview Rule to require or disallow yoda comparisons 3 * @author Nicholas C. Zakas 4 */ 5"use strict"; 6 7//-------------------------------------------------------------------------- 8// Requirements 9//-------------------------------------------------------------------------- 10 11const astUtils = require("./utils/ast-utils"); 12 13//-------------------------------------------------------------------------- 14// Helpers 15//-------------------------------------------------------------------------- 16 17/** 18 * Determines whether an operator is a comparison operator. 19 * @param {string} operator The operator to check. 20 * @returns {boolean} Whether or not it is a comparison operator. 21 */ 22function isComparisonOperator(operator) { 23 return /^(==|===|!=|!==|<|>|<=|>=)$/u.test(operator); 24} 25 26/** 27 * Determines whether an operator is an equality operator. 28 * @param {string} operator The operator to check. 29 * @returns {boolean} Whether or not it is an equality operator. 30 */ 31function isEqualityOperator(operator) { 32 return /^(==|===)$/u.test(operator); 33} 34 35/** 36 * Determines whether an operator is one used in a range test. 37 * Allowed operators are `<` and `<=`. 38 * @param {string} operator The operator to check. 39 * @returns {boolean} Whether the operator is used in range tests. 40 */ 41function isRangeTestOperator(operator) { 42 return ["<", "<="].indexOf(operator) >= 0; 43} 44 45/** 46 * Determines whether a non-Literal node is a negative number that should be 47 * treated as if it were a single Literal node. 48 * @param {ASTNode} node Node to test. 49 * @returns {boolean} True if the node is a negative number that looks like a 50 * real literal and should be treated as such. 51 */ 52function isNegativeNumericLiteral(node) { 53 return ( 54 node.type === "UnaryExpression" && 55 node.operator === "-" && 56 node.prefix && 57 astUtils.isNumericLiteral(node.argument) 58 ); 59} 60 61/** 62 * Determines whether a node is a Template Literal which can be determined statically. 63 * @param {ASTNode} node Node to test 64 * @returns {boolean} True if the node is a Template Literal without expression. 65 */ 66function isStaticTemplateLiteral(node) { 67 return node.type === "TemplateLiteral" && node.expressions.length === 0; 68} 69 70/** 71 * Determines whether a non-Literal node should be treated as a single Literal node. 72 * @param {ASTNode} node Node to test 73 * @returns {boolean} True if the node should be treated as a single Literal node. 74 */ 75function looksLikeLiteral(node) { 76 return isNegativeNumericLiteral(node) || isStaticTemplateLiteral(node); 77} 78 79/** 80 * Attempts to derive a Literal node from nodes that are treated like literals. 81 * @param {ASTNode} node Node to normalize. 82 * @returns {ASTNode} One of the following options. 83 * 1. The original node if the node is already a Literal 84 * 2. A normalized Literal node with the negative number as the value if the 85 * node represents a negative number literal. 86 * 3. A normalized Literal node with the string as the value if the node is 87 * a Template Literal without expression. 88 * 4. Otherwise `null`. 89 */ 90function getNormalizedLiteral(node) { 91 if (node.type === "Literal") { 92 return node; 93 } 94 95 if (isNegativeNumericLiteral(node)) { 96 return { 97 type: "Literal", 98 value: -node.argument.value, 99 raw: `-${node.argument.value}` 100 }; 101 } 102 103 if (isStaticTemplateLiteral(node)) { 104 return { 105 type: "Literal", 106 value: node.quasis[0].value.cooked, 107 raw: node.quasis[0].value.raw 108 }; 109 } 110 111 return null; 112} 113 114//------------------------------------------------------------------------------ 115// Rule Definition 116//------------------------------------------------------------------------------ 117 118module.exports = { 119 meta: { 120 type: "suggestion", 121 122 docs: { 123 description: 'require or disallow "Yoda" conditions', 124 category: "Best Practices", 125 recommended: false, 126 url: "https://eslint.org/docs/rules/yoda" 127 }, 128 129 schema: [ 130 { 131 enum: ["always", "never"] 132 }, 133 { 134 type: "object", 135 properties: { 136 exceptRange: { 137 type: "boolean", 138 default: false 139 }, 140 onlyEquality: { 141 type: "boolean", 142 default: false 143 } 144 }, 145 additionalProperties: false 146 } 147 ], 148 149 fixable: "code", 150 messages: { 151 expected: 152 "Expected literal to be on the {{expectedSide}} side of {{operator}}." 153 } 154 }, 155 156 create(context) { 157 158 // Default to "never" (!always) if no option 159 const always = context.options[0] === "always"; 160 const exceptRange = 161 context.options[1] && context.options[1].exceptRange; 162 const onlyEquality = 163 context.options[1] && context.options[1].onlyEquality; 164 165 const sourceCode = context.getSourceCode(); 166 167 /** 168 * Determines whether node represents a range test. 169 * A range test is a "between" test like `(0 <= x && x < 1)` or an "outside" 170 * test like `(x < 0 || 1 <= x)`. It must be wrapped in parentheses, and 171 * both operators must be `<` or `<=`. Finally, the literal on the left side 172 * must be less than or equal to the literal on the right side so that the 173 * test makes any sense. 174 * @param {ASTNode} node LogicalExpression node to test. 175 * @returns {boolean} Whether node is a range test. 176 */ 177 function isRangeTest(node) { 178 const left = node.left, 179 right = node.right; 180 181 /** 182 * Determines whether node is of the form `0 <= x && x < 1`. 183 * @returns {boolean} Whether node is a "between" range test. 184 */ 185 function isBetweenTest() { 186 if (node.operator === "&&" && astUtils.isSameReference(left.right, right.left)) { 187 const leftLiteral = getNormalizedLiteral(left.left); 188 const rightLiteral = getNormalizedLiteral(right.right); 189 190 if (leftLiteral === null && rightLiteral === null) { 191 return false; 192 } 193 194 if (rightLiteral === null || leftLiteral === null) { 195 return true; 196 } 197 198 if (leftLiteral.value <= rightLiteral.value) { 199 return true; 200 } 201 } 202 return false; 203 } 204 205 /** 206 * Determines whether node is of the form `x < 0 || 1 <= x`. 207 * @returns {boolean} Whether node is an "outside" range test. 208 */ 209 function isOutsideTest() { 210 if (node.operator === "||" && astUtils.isSameReference(left.left, right.right)) { 211 const leftLiteral = getNormalizedLiteral(left.right); 212 const rightLiteral = getNormalizedLiteral(right.left); 213 214 if (leftLiteral === null && rightLiteral === null) { 215 return false; 216 } 217 218 if (rightLiteral === null || leftLiteral === null) { 219 return true; 220 } 221 222 if (leftLiteral.value <= rightLiteral.value) { 223 return true; 224 } 225 } 226 227 return false; 228 } 229 230 /** 231 * Determines whether node is wrapped in parentheses. 232 * @returns {boolean} Whether node is preceded immediately by an open 233 * paren token and followed immediately by a close 234 * paren token. 235 */ 236 function isParenWrapped() { 237 return astUtils.isParenthesised(sourceCode, node); 238 } 239 240 return ( 241 node.type === "LogicalExpression" && 242 left.type === "BinaryExpression" && 243 right.type === "BinaryExpression" && 244 isRangeTestOperator(left.operator) && 245 isRangeTestOperator(right.operator) && 246 (isBetweenTest() || isOutsideTest()) && 247 isParenWrapped() 248 ); 249 } 250 251 const OPERATOR_FLIP_MAP = { 252 "===": "===", 253 "!==": "!==", 254 "==": "==", 255 "!=": "!=", 256 "<": ">", 257 ">": "<", 258 "<=": ">=", 259 ">=": "<=" 260 }; 261 262 /** 263 * Returns a string representation of a BinaryExpression node with its sides/operator flipped around. 264 * @param {ASTNode} node The BinaryExpression node 265 * @returns {string} A string representation of the node with the sides and operator flipped 266 */ 267 function getFlippedString(node) { 268 const tokenBefore = sourceCode.getTokenBefore(node); 269 const operatorToken = sourceCode.getFirstTokenBetween( 270 node.left, 271 node.right, 272 token => token.value === node.operator 273 ); 274 const textBeforeOperator = sourceCode 275 .getText() 276 .slice( 277 sourceCode.getTokenBefore(operatorToken).range[1], 278 operatorToken.range[0] 279 ); 280 const textAfterOperator = sourceCode 281 .getText() 282 .slice( 283 operatorToken.range[1], 284 sourceCode.getTokenAfter(operatorToken).range[0] 285 ); 286 const leftText = sourceCode 287 .getText() 288 .slice( 289 node.range[0], 290 sourceCode.getTokenBefore(operatorToken).range[1] 291 ); 292 const firstRightToken = sourceCode.getTokenAfter(operatorToken); 293 const rightText = sourceCode 294 .getText() 295 .slice(firstRightToken.range[0], node.range[1]); 296 297 let prefix = ""; 298 299 if ( 300 tokenBefore && 301 tokenBefore.range[1] === node.range[0] && 302 !astUtils.canTokensBeAdjacent(tokenBefore, firstRightToken) 303 ) { 304 prefix = " "; 305 } 306 307 return ( 308 prefix + 309 rightText + 310 textBeforeOperator + 311 OPERATOR_FLIP_MAP[operatorToken.value] + 312 textAfterOperator + 313 leftText 314 ); 315 } 316 317 //-------------------------------------------------------------------------- 318 // Public 319 //-------------------------------------------------------------------------- 320 321 return { 322 BinaryExpression(node) { 323 const expectedLiteral = always ? node.left : node.right; 324 const expectedNonLiteral = always ? node.right : node.left; 325 326 // If `expectedLiteral` is not a literal, and `expectedNonLiteral` is a literal, raise an error. 327 if ( 328 (expectedNonLiteral.type === "Literal" || 329 looksLikeLiteral(expectedNonLiteral)) && 330 !( 331 expectedLiteral.type === "Literal" || 332 looksLikeLiteral(expectedLiteral) 333 ) && 334 !(!isEqualityOperator(node.operator) && onlyEquality) && 335 isComparisonOperator(node.operator) && 336 !(exceptRange && isRangeTest(context.getAncestors().pop())) 337 ) { 338 context.report({ 339 node, 340 messageId: "expected", 341 data: { 342 operator: node.operator, 343 expectedSide: always ? "left" : "right" 344 }, 345 fix: fixer => 346 fixer.replaceText(node, getFlippedString(node)) 347 }); 348 } 349 } 350 }; 351 } 352}; 353