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