1 | /**
|
2 | * @fileoverview Rule to require or disallow yoda comparisons
|
3 | * @author Nicholas C. Zakas
|
4 | */
|
5 | ;
|
6 |
|
7 | //--------------------------------------------------------------------------
|
8 | // Helpers
|
9 | //--------------------------------------------------------------------------
|
10 |
|
11 | /**
|
12 | * Determines whether an operator is a comparison operator.
|
13 | * @param {string} operator The operator to check.
|
14 | * @returns {boolean} Whether or not it is a comparison operator.
|
15 | */
|
16 | function isComparisonOperator(operator) {
|
17 | return (/^(==|===|!=|!==|<|>|<=|>=)$/).test(operator);
|
18 | }
|
19 |
|
20 | /**
|
21 | * Determines whether an operator is an equality operator.
|
22 | * @param {string} operator The operator to check.
|
23 | * @returns {boolean} Whether or not it is an equality operator.
|
24 | */
|
25 | function isEqualityOperator(operator) {
|
26 | return (/^(==|===)$/).test(operator);
|
27 | }
|
28 |
|
29 | /**
|
30 | * Determines whether an operator is one used in a range test.
|
31 | * Allowed operators are `<` and `<=`.
|
32 | * @param {string} operator The operator to check.
|
33 | * @returns {boolean} Whether the operator is used in range tests.
|
34 | */
|
35 | function isRangeTestOperator(operator) {
|
36 | return ["<", "<="].indexOf(operator) >= 0;
|
37 | }
|
38 |
|
39 | /**
|
40 | * Determines whether a non-Literal node is a negative number that should be
|
41 | * treated as if it were a single Literal node.
|
42 | * @param {ASTNode} node Node to test.
|
43 | * @returns {boolean} True if the node is a negative number that looks like a
|
44 | * real literal and should be treated as such.
|
45 | */
|
46 | function looksLikeLiteral(node) {
|
47 | return (node.type === "UnaryExpression" &&
|
48 | node.operator === "-" &&
|
49 | node.prefix &&
|
50 | node.argument.type === "Literal" &&
|
51 | typeof node.argument.value === "number");
|
52 | }
|
53 |
|
54 | /**
|
55 | * Attempts to derive a Literal node from nodes that are treated like literals.
|
56 | * @param {ASTNode} node Node to normalize.
|
57 | * @returns {ASTNode} The original node if the node is already a Literal, or a
|
58 | * normalized Literal node with the negative number as the
|
59 | * value if the node represents a negative number literal,
|
60 | * otherwise null if the node cannot be converted to a
|
61 | * normalized literal.
|
62 | */
|
63 | function getNormalizedLiteral(node) {
|
64 | if (node.type === "Literal") {
|
65 | return node;
|
66 | }
|
67 |
|
68 | if (looksLikeLiteral(node)) {
|
69 | return {
|
70 | type: "Literal",
|
71 | value: -node.argument.value,
|
72 | raw: `-${node.argument.value}`
|
73 | };
|
74 | }
|
75 |
|
76 | return null;
|
77 | }
|
78 |
|
79 | /**
|
80 | * Checks whether two expressions reference the same value. For example:
|
81 | * a = a
|
82 | * a.b = a.b
|
83 | * a[0] = a[0]
|
84 | * a['b'] = a['b']
|
85 | * @param {ASTNode} a Left side of the comparison.
|
86 | * @param {ASTNode} b Right side of the comparison.
|
87 | * @returns {boolean} True if both sides match and reference the same value.
|
88 | */
|
89 | function same(a, b) {
|
90 | if (a.type !== b.type) {
|
91 | return false;
|
92 | }
|
93 |
|
94 | switch (a.type) {
|
95 | case "Identifier":
|
96 | return a.name === b.name;
|
97 |
|
98 | case "Literal":
|
99 | return a.value === b.value;
|
100 |
|
101 | case "MemberExpression":
|
102 |
|
103 | // x[0] = x[0]
|
104 | // x[y] = x[y]
|
105 | // x.y = x.y
|
106 | return same(a.object, b.object) && same(a.property, b.property);
|
107 |
|
108 | case "ThisExpression":
|
109 | return true;
|
110 |
|
111 | default:
|
112 | return false;
|
113 | }
|
114 | }
|
115 |
|
116 | //------------------------------------------------------------------------------
|
117 | // Rule Definition
|
118 | //------------------------------------------------------------------------------
|
119 |
|
120 | module.exports = {
|
121 | meta: {
|
122 | docs: {
|
123 | description: "require or disallow \"Yoda\" conditions",
|
124 | category: "Best Practices",
|
125 | recommended: false
|
126 | },
|
127 |
|
128 | schema: [
|
129 | {
|
130 | enum: ["always", "never"]
|
131 | },
|
132 | {
|
133 | type: "object",
|
134 | properties: {
|
135 | exceptRange: {
|
136 | type: "boolean"
|
137 | },
|
138 | onlyEquality: {
|
139 | type: "boolean"
|
140 | }
|
141 | },
|
142 | additionalProperties: false
|
143 | }
|
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 = (context.options[1] && context.options[1].exceptRange);
|
152 | const onlyEquality = (context.options[1] && context.options[1].onlyEquality);
|
153 |
|
154 | const sourceCode = context.getSourceCode();
|
155 |
|
156 | /**
|
157 | * Determines whether node represents a range test.
|
158 | * A range test is a "between" test like `(0 <= x && x < 1)` or an "outside"
|
159 | * test like `(x < 0 || 1 <= x)`. It must be wrapped in parentheses, and
|
160 | * both operators must be `<` or `<=`. Finally, the literal on the left side
|
161 | * must be less than or equal to the literal on the right side so that the
|
162 | * test makes any sense.
|
163 | * @param {ASTNode} node LogicalExpression node to test.
|
164 | * @returns {boolean} Whether node is a range test.
|
165 | */
|
166 | function isRangeTest(node) {
|
167 | const left = node.left,
|
168 | right = node.right;
|
169 |
|
170 | /**
|
171 | * Determines whether node is of the form `0 <= x && x < 1`.
|
172 | * @returns {boolean} Whether node is a "between" range test.
|
173 | */
|
174 | function isBetweenTest() {
|
175 | let leftLiteral, rightLiteral;
|
176 |
|
177 | return (node.operator === "&&" &&
|
178 | (leftLiteral = getNormalizedLiteral(left.left)) &&
|
179 | (rightLiteral = getNormalizedLiteral(right.right)) &&
|
180 | leftLiteral.value <= rightLiteral.value &&
|
181 | same(left.right, right.left));
|
182 | }
|
183 |
|
184 | /**
|
185 | * Determines whether node is of the form `x < 0 || 1 <= x`.
|
186 | * @returns {boolean} Whether node is an "outside" range test.
|
187 | */
|
188 | function isOutsideTest() {
|
189 | let leftLiteral, rightLiteral;
|
190 |
|
191 | return (node.operator === "||" &&
|
192 | (leftLiteral = getNormalizedLiteral(left.right)) &&
|
193 | (rightLiteral = getNormalizedLiteral(right.left)) &&
|
194 | leftLiteral.value <= rightLiteral.value &&
|
195 | same(left.left, right.right));
|
196 | }
|
197 |
|
198 | /**
|
199 | * Determines whether node is wrapped in parentheses.
|
200 | * @returns {boolean} Whether node is preceded immediately by an open
|
201 | * paren token and followed immediately by a close
|
202 | * paren token.
|
203 | */
|
204 | function isParenWrapped() {
|
205 | let tokenBefore, tokenAfter;
|
206 |
|
207 | return ((tokenBefore = sourceCode.getTokenBefore(node)) &&
|
208 | tokenBefore.value === "(" &&
|
209 | (tokenAfter = sourceCode.getTokenAfter(node)) &&
|
210 | tokenAfter.value === ")");
|
211 | }
|
212 |
|
213 | return (node.type === "LogicalExpression" &&
|
214 | left.type === "BinaryExpression" &&
|
215 | right.type === "BinaryExpression" &&
|
216 | isRangeTestOperator(left.operator) &&
|
217 | isRangeTestOperator(right.operator) &&
|
218 | (isBetweenTest() || isOutsideTest()) &&
|
219 | isParenWrapped());
|
220 | }
|
221 |
|
222 | //--------------------------------------------------------------------------
|
223 | // Public
|
224 | //--------------------------------------------------------------------------
|
225 |
|
226 | return {
|
227 | BinaryExpression: always ? function(node) {
|
228 |
|
229 | // Comparisons must always be yoda-style: if ("blue" === color)
|
230 | if (
|
231 | (node.right.type === "Literal" || looksLikeLiteral(node.right)) &&
|
232 | !(node.left.type === "Literal" || looksLikeLiteral(node.left)) &&
|
233 | !(!isEqualityOperator(node.operator) && onlyEquality) &&
|
234 | isComparisonOperator(node.operator) &&
|
235 | !(exceptRange && isRangeTest(context.getAncestors().pop()))
|
236 | ) {
|
237 | context.report({
|
238 | node,
|
239 | message: "Expected literal to be on the left side of {{operator}}.",
|
240 | data: {
|
241 | operator: node.operator
|
242 | }
|
243 | });
|
244 | }
|
245 |
|
246 | } : function(node) {
|
247 |
|
248 | // Comparisons must never be yoda-style (default)
|
249 | if (
|
250 | (node.left.type === "Literal" || looksLikeLiteral(node.left)) &&
|
251 | !(node.right.type === "Literal" || looksLikeLiteral(node.right)) &&
|
252 | !(!isEqualityOperator(node.operator) && onlyEquality) &&
|
253 | isComparisonOperator(node.operator) &&
|
254 | !(exceptRange && isRangeTest(context.getAncestors().pop()))
|
255 | ) {
|
256 | context.report({
|
257 | node,
|
258 | message: "Expected literal to be on the right side of {{operator}}.",
|
259 | data: {
|
260 | operator: node.operator
|
261 | }
|
262 | });
|
263 | }
|
264 |
|
265 | }
|
266 | };
|
267 |
|
268 | }
|
269 | };
|