]>
Commit | Line | Data |
---|---|---|
eb39fafa DC |
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 | ||
11 | const 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 | */ | |
22 | function isComparisonOperator(operator) { | |
56c4a2cb | 23 | return /^(==|===|!=|!==|<|>|<=|>=)$/u.test(operator); |
eb39fafa DC |
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 | */ | |
31 | function isEqualityOperator(operator) { | |
56c4a2cb | 32 | return /^(==|===)$/u.test(operator); |
eb39fafa DC |
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 | */ | |
41 | function isRangeTestOperator(operator) { | |
8f9d1d4d | 42 | return ["<", "<="].includes(operator); |
eb39fafa DC |
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 | */ | |
52 | function isNegativeNumericLiteral(node) { | |
56c4a2cb DC |
53 | return ( |
54 | node.type === "UnaryExpression" && | |
eb39fafa DC |
55 | node.operator === "-" && |
56 | node.prefix && | |
56c4a2cb DC |
57 | astUtils.isNumericLiteral(node.argument) |
58 | ); | |
eb39fafa DC |
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 | */ | |
66 | function 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 | */ | |
75 | function looksLikeLiteral(node) { | |
56c4a2cb | 76 | return isNegativeNumericLiteral(node) || isStaticTemplateLiteral(node); |
eb39fafa DC |
77 | } |
78 | ||
79 | /** | |
80 | * Attempts to derive a Literal node from nodes that are treated like literals. | |
81 | * @param {ASTNode} node Node to normalize. | |
eb39fafa DC |
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. | |
56c4a2cb | 88 | * 4. Otherwise `null`. |
eb39fafa | 89 | */ |
56c4a2cb | 90 | function getNormalizedLiteral(node) { |
eb39fafa DC |
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 | ||
eb39fafa DC |
111 | return null; |
112 | } | |
113 | ||
eb39fafa DC |
114 | //------------------------------------------------------------------------------ |
115 | // Rule Definition | |
116 | //------------------------------------------------------------------------------ | |
117 | ||
34eeec05 | 118 | /** @type {import('../shared/types').Rule} */ |
eb39fafa DC |
119 | module.exports = { |
120 | meta: { | |
121 | type: "suggestion", | |
122 | ||
123 | docs: { | |
8f9d1d4d | 124 | description: 'Require or disallow "Yoda" conditions', |
eb39fafa | 125 | recommended: false, |
f2a92ac6 | 126 | url: "https://eslint.org/docs/latest/rules/yoda" |
eb39fafa DC |
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: { | |
56c4a2cb DC |
151 | expected: |
152 | "Expected literal to be on the {{expectedSide}} side of {{operator}}." | |
eb39fafa DC |
153 | } |
154 | }, | |
155 | ||
156 | create(context) { | |
157 | ||
158 | // Default to "never" (!always) if no option | |
56c4a2cb DC |
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; | |
eb39fafa | 164 | |
f2a92ac6 | 165 | const sourceCode = context.sourceCode; |
eb39fafa DC |
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() { | |
6f036462 | 186 | if (node.operator === "&&" && astUtils.isSameReference(left.right, right.left)) { |
56c4a2cb DC |
187 | const leftLiteral = getNormalizedLiteral(left.left); |
188 | const rightLiteral = getNormalizedLiteral(right.right); | |
189 | ||
190 | if (leftLiteral === null && rightLiteral === null) { | |
191 | return false; | |
192 | } | |
eb39fafa | 193 | |
56c4a2cb DC |
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; | |
eb39fafa DC |
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() { | |
6f036462 | 210 | if (node.operator === "||" && astUtils.isSameReference(left.left, right.right)) { |
56c4a2cb DC |
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 | } | |
eb39fafa | 226 | |
56c4a2cb | 227 | return false; |
eb39fafa DC |
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 | ||
56c4a2cb DC |
240 | return ( |
241 | node.type === "LogicalExpression" && | |
eb39fafa DC |
242 | left.type === "BinaryExpression" && |
243 | right.type === "BinaryExpression" && | |
244 | isRangeTestOperator(left.operator) && | |
245 | isRangeTestOperator(right.operator) && | |
246 | (isBetweenTest() || isOutsideTest()) && | |
56c4a2cb DC |
247 | isParenWrapped() |
248 | ); | |
eb39fafa DC |
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) { | |
56c4a2cb DC |
268 | const operatorToken = sourceCode.getFirstTokenBetween( |
269 | node.left, | |
270 | node.right, | |
271 | token => token.value === node.operator | |
272 | ); | |
6f036462 | 273 | const lastLeftToken = sourceCode.getTokenBefore(operatorToken); |
eb39fafa | 274 | const firstRightToken = sourceCode.getTokenAfter(operatorToken); |
eb39fafa | 275 | |
6f036462 TL |
276 | const source = sourceCode.getText(); |
277 | ||
278 | const leftText = source.slice( | |
279 | node.range[0], | |
280 | lastLeftToken.range[1] | |
281 | ); | |
282 | const textBeforeOperator = source.slice( | |
283 | lastLeftToken.range[1], | |
284 | operatorToken.range[0] | |
285 | ); | |
286 | const textAfterOperator = source.slice( | |
287 | operatorToken.range[1], | |
288 | firstRightToken.range[0] | |
289 | ); | |
290 | const rightText = source.slice( | |
291 | firstRightToken.range[0], | |
292 | node.range[1] | |
293 | ); | |
294 | ||
295 | const tokenBefore = sourceCode.getTokenBefore(node); | |
296 | const tokenAfter = sourceCode.getTokenAfter(node); | |
eb39fafa | 297 | let prefix = ""; |
6f036462 | 298 | let suffix = ""; |
eb39fafa | 299 | |
56c4a2cb DC |
300 | if ( |
301 | tokenBefore && | |
302 | tokenBefore.range[1] === node.range[0] && | |
303 | !astUtils.canTokensBeAdjacent(tokenBefore, firstRightToken) | |
304 | ) { | |
eb39fafa DC |
305 | prefix = " "; |
306 | } | |
307 | ||
6f036462 TL |
308 | if ( |
309 | tokenAfter && | |
310 | node.range[1] === tokenAfter.range[0] && | |
311 | !astUtils.canTokensBeAdjacent(lastLeftToken, tokenAfter) | |
312 | ) { | |
313 | suffix = " "; | |
314 | } | |
315 | ||
56c4a2cb DC |
316 | return ( |
317 | prefix + | |
318 | rightText + | |
319 | textBeforeOperator + | |
320 | OPERATOR_FLIP_MAP[operatorToken.value] + | |
321 | textAfterOperator + | |
6f036462 TL |
322 | leftText + |
323 | suffix | |
56c4a2cb | 324 | ); |
eb39fafa DC |
325 | } |
326 | ||
327 | //-------------------------------------------------------------------------- | |
328 | // Public | |
329 | //-------------------------------------------------------------------------- | |
330 | ||
331 | return { | |
332 | BinaryExpression(node) { | |
333 | const expectedLiteral = always ? node.left : node.right; | |
334 | const expectedNonLiteral = always ? node.right : node.left; | |
335 | ||
336 | // If `expectedLiteral` is not a literal, and `expectedNonLiteral` is a literal, raise an error. | |
337 | if ( | |
56c4a2cb DC |
338 | (expectedNonLiteral.type === "Literal" || |
339 | looksLikeLiteral(expectedNonLiteral)) && | |
340 | !( | |
341 | expectedLiteral.type === "Literal" || | |
342 | looksLikeLiteral(expectedLiteral) | |
343 | ) && | |
eb39fafa DC |
344 | !(!isEqualityOperator(node.operator) && onlyEquality) && |
345 | isComparisonOperator(node.operator) && | |
f2a92ac6 | 346 | !(exceptRange && isRangeTest(node.parent)) |
eb39fafa DC |
347 | ) { |
348 | context.report({ | |
349 | node, | |
350 | messageId: "expected", | |
351 | data: { | |
352 | operator: node.operator, | |
353 | expectedSide: always ? "left" : "right" | |
354 | }, | |
56c4a2cb DC |
355 | fix: fixer => |
356 | fixer.replaceText(node, getFlippedString(node)) | |
eb39fafa DC |
357 | }); |
358 | } | |
eb39fafa DC |
359 | } |
360 | }; | |
eb39fafa DC |
361 | } |
362 | }; |