• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 tokenBefore = sourceCode.getTokenBefore(node);
269            const operatorToken = sourceCode.getFirstTokenBetween(
270                node.left,
271                node.right,
272                token => token.value === node.operator
273            );
274            const textBeforeOperator = sourceCode
275                .getText()
276                .slice(
277                    sourceCode.getTokenBefore(operatorToken).range[1],
278                    operatorToken.range[0]
279                );
280            const textAfterOperator = sourceCode
281                .getText()
282                .slice(
283                    operatorToken.range[1],
284                    sourceCode.getTokenAfter(operatorToken).range[0]
285                );
286            const leftText = sourceCode
287                .getText()
288                .slice(
289                    node.range[0],
290                    sourceCode.getTokenBefore(operatorToken).range[1]
291                );
292            const firstRightToken = sourceCode.getTokenAfter(operatorToken);
293            const rightText = sourceCode
294                .getText()
295                .slice(firstRightToken.range[0], node.range[1]);
296
297            let prefix = "";
298
299            if (
300                tokenBefore &&
301                tokenBefore.range[1] === node.range[0] &&
302                !astUtils.canTokensBeAdjacent(tokenBefore, firstRightToken)
303            ) {
304                prefix = " ";
305            }
306
307            return (
308                prefix +
309                rightText +
310                textBeforeOperator +
311                OPERATOR_FLIP_MAP[operatorToken.value] +
312                textAfterOperator +
313                leftText
314            );
315        }
316
317        //--------------------------------------------------------------------------
318        // Public
319        //--------------------------------------------------------------------------
320
321        return {
322            BinaryExpression(node) {
323                const expectedLiteral = always ? node.left : node.right;
324                const expectedNonLiteral = always ? node.right : node.left;
325
326                // If `expectedLiteral` is not a literal, and `expectedNonLiteral` is a literal, raise an error.
327                if (
328                    (expectedNonLiteral.type === "Literal" ||
329                        looksLikeLiteral(expectedNonLiteral)) &&
330                    !(
331                        expectedLiteral.type === "Literal" ||
332                        looksLikeLiteral(expectedLiteral)
333                    ) &&
334                    !(!isEqualityOperator(node.operator) && onlyEquality) &&
335                    isComparisonOperator(node.operator) &&
336                    !(exceptRange && isRangeTest(context.getAncestors().pop()))
337                ) {
338                    context.report({
339                        node,
340                        messageId: "expected",
341                        data: {
342                            operator: node.operator,
343                            expectedSide: always ? "left" : "right"
344                        },
345                        fix: fixer =>
346                            fixer.replaceText(node, getFlippedString(node))
347                    });
348                }
349            }
350        };
351    }
352};
353