1 | /**
|
2 | * @fileoverview Rule to require or disallow yoda comparisons
|
3 | * @author Nicholas C. Zakas
|
4 | */
|
5 | ;
|
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) {
|
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 | */
|
31 | function 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 | */
|
41 | function 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 | */
|
52 | function 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 | */
|
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) {
|
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 | */
|
90 | function 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 |
|
118 | module.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 | };
|