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