UNPKG

12.5 kBJavaScriptView Raw
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 operatorToken = sourceCode.getFirstTokenBetween(
269 node.left,
270 node.right,
271 token => token.value === node.operator
272 );
273 const lastLeftToken = sourceCode.getTokenBefore(operatorToken);
274 const firstRightToken = sourceCode.getTokenAfter(operatorToken);
275
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);
297 let prefix = "";
298 let suffix = "";
299
300 if (
301 tokenBefore &&
302 tokenBefore.range[1] === node.range[0] &&
303 !astUtils.canTokensBeAdjacent(tokenBefore, firstRightToken)
304 ) {
305 prefix = " ";
306 }
307
308 if (
309 tokenAfter &&
310 node.range[1] === tokenAfter.range[0] &&
311 !astUtils.canTokensBeAdjacent(lastLeftToken, tokenAfter)
312 ) {
313 suffix = " ";
314 }
315
316 return (
317 prefix +
318 rightText +
319 textBeforeOperator +
320 OPERATOR_FLIP_MAP[operatorToken.value] +
321 textAfterOperator +
322 leftText +
323 suffix
324 );
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 (
338 (expectedNonLiteral.type === "Literal" ||
339 looksLikeLiteral(expectedNonLiteral)) &&
340 !(
341 expectedLiteral.type === "Literal" ||
342 looksLikeLiteral(expectedLiteral)
343 ) &&
344 !(!isEqualityOperator(node.operator) && onlyEquality) &&
345 isComparisonOperator(node.operator) &&
346 !(exceptRange && isRangeTest(context.getAncestors().pop()))
347 ) {
348 context.report({
349 node,
350 messageId: "expected",
351 data: {
352 operator: node.operator,
353 expectedSide: always ? "left" : "right"
354 },
355 fix: fixer =>
356 fixer.replaceText(node, getFlippedString(node))
357 });
358 }
359 }
360 };
361 }
362};