• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/**
2 * @fileoverview A rule to disallow the type conversions with shorter notations.
3 * @author Toru Nagashima
4 */
5
6"use strict";
7
8const astUtils = require("./utils/ast-utils");
9
10//------------------------------------------------------------------------------
11// Helpers
12//------------------------------------------------------------------------------
13
14const INDEX_OF_PATTERN = /^(?:i|lastI)ndexOf$/u;
15const ALLOWABLE_OPERATORS = ["~", "!!", "+", "*"];
16
17/**
18 * Parses and normalizes an option object.
19 * @param {Object} options An option object to parse.
20 * @returns {Object} The parsed and normalized option object.
21 */
22function parseOptions(options) {
23    return {
24        boolean: "boolean" in options ? options.boolean : true,
25        number: "number" in options ? options.number : true,
26        string: "string" in options ? options.string : true,
27        allow: options.allow || []
28    };
29}
30
31/**
32 * Checks whether or not a node is a double logical nigating.
33 * @param {ASTNode} node An UnaryExpression node to check.
34 * @returns {boolean} Whether or not the node is a double logical nigating.
35 */
36function isDoubleLogicalNegating(node) {
37    return (
38        node.operator === "!" &&
39        node.argument.type === "UnaryExpression" &&
40        node.argument.operator === "!"
41    );
42}
43
44/**
45 * Checks whether or not a node is a binary negating of `.indexOf()` method calling.
46 * @param {ASTNode} node An UnaryExpression node to check.
47 * @returns {boolean} Whether or not the node is a binary negating of `.indexOf()` method calling.
48 */
49function isBinaryNegatingOfIndexOf(node) {
50    if (node.operator !== "~") {
51        return false;
52    }
53    const callNode = astUtils.skipChainExpression(node.argument);
54
55    return (
56        callNode.type === "CallExpression" &&
57        astUtils.isSpecificMemberAccess(callNode.callee, null, INDEX_OF_PATTERN)
58    );
59}
60
61/**
62 * Checks whether or not a node is a multiplying by one.
63 * @param {BinaryExpression} node A BinaryExpression node to check.
64 * @returns {boolean} Whether or not the node is a multiplying by one.
65 */
66function isMultiplyByOne(node) {
67    return node.operator === "*" && (
68        node.left.type === "Literal" && node.left.value === 1 ||
69        node.right.type === "Literal" && node.right.value === 1
70    );
71}
72
73/**
74 * Checks whether the result of a node is numeric or not
75 * @param {ASTNode} node The node to test
76 * @returns {boolean} true if the node is a number literal or a `Number()`, `parseInt` or `parseFloat` call
77 */
78function isNumeric(node) {
79    return (
80        node.type === "Literal" && typeof node.value === "number" ||
81        node.type === "CallExpression" && (
82            node.callee.name === "Number" ||
83            node.callee.name === "parseInt" ||
84            node.callee.name === "parseFloat"
85        )
86    );
87}
88
89/**
90 * Returns the first non-numeric operand in a BinaryExpression. Designed to be
91 * used from bottom to up since it walks up the BinaryExpression trees using
92 * node.parent to find the result.
93 * @param {BinaryExpression} node The BinaryExpression node to be walked up on
94 * @returns {ASTNode|null} The first non-numeric item in the BinaryExpression tree or null
95 */
96function getNonNumericOperand(node) {
97    const left = node.left,
98        right = node.right;
99
100    if (right.type !== "BinaryExpression" && !isNumeric(right)) {
101        return right;
102    }
103
104    if (left.type !== "BinaryExpression" && !isNumeric(left)) {
105        return left;
106    }
107
108    return null;
109}
110
111/**
112 * Checks whether a node is an empty string literal or not.
113 * @param {ASTNode} node The node to check.
114 * @returns {boolean} Whether or not the passed in node is an
115 * empty string literal or not.
116 */
117function isEmptyString(node) {
118    return astUtils.isStringLiteral(node) && (node.value === "" || (node.type === "TemplateLiteral" && node.quasis.length === 1 && node.quasis[0].value.cooked === ""));
119}
120
121/**
122 * Checks whether or not a node is a concatenating with an empty string.
123 * @param {ASTNode} node A BinaryExpression node to check.
124 * @returns {boolean} Whether or not the node is a concatenating with an empty string.
125 */
126function isConcatWithEmptyString(node) {
127    return node.operator === "+" && (
128        (isEmptyString(node.left) && !astUtils.isStringLiteral(node.right)) ||
129        (isEmptyString(node.right) && !astUtils.isStringLiteral(node.left))
130    );
131}
132
133/**
134 * Checks whether or not a node is appended with an empty string.
135 * @param {ASTNode} node An AssignmentExpression node to check.
136 * @returns {boolean} Whether or not the node is appended with an empty string.
137 */
138function isAppendEmptyString(node) {
139    return node.operator === "+=" && isEmptyString(node.right);
140}
141
142/**
143 * Returns the operand that is not an empty string from a flagged BinaryExpression.
144 * @param {ASTNode} node The flagged BinaryExpression node to check.
145 * @returns {ASTNode} The operand that is not an empty string from a flagged BinaryExpression.
146 */
147function getNonEmptyOperand(node) {
148    return isEmptyString(node.left) ? node.right : node.left;
149}
150
151//------------------------------------------------------------------------------
152// Rule Definition
153//------------------------------------------------------------------------------
154
155module.exports = {
156    meta: {
157        type: "suggestion",
158
159        docs: {
160            description: "disallow shorthand type conversions",
161            category: "Best Practices",
162            recommended: false,
163            url: "https://eslint.org/docs/rules/no-implicit-coercion"
164        },
165
166        fixable: "code",
167
168        schema: [{
169            type: "object",
170            properties: {
171                boolean: {
172                    type: "boolean",
173                    default: true
174                },
175                number: {
176                    type: "boolean",
177                    default: true
178                },
179                string: {
180                    type: "boolean",
181                    default: true
182                },
183                allow: {
184                    type: "array",
185                    items: {
186                        enum: ALLOWABLE_OPERATORS
187                    },
188                    uniqueItems: true
189                }
190            },
191            additionalProperties: false
192        }],
193
194        messages: {
195            useRecommendation: "use `{{recommendation}}` instead."
196        }
197    },
198
199    create(context) {
200        const options = parseOptions(context.options[0] || {});
201        const sourceCode = context.getSourceCode();
202
203        /**
204         * Reports an error and autofixes the node
205         * @param {ASTNode} node An ast node to report the error on.
206         * @param {string} recommendation The recommended code for the issue
207         * @param {bool} shouldFix Whether this report should fix the node
208         * @returns {void}
209         */
210        function report(node, recommendation, shouldFix) {
211            context.report({
212                node,
213                messageId: "useRecommendation",
214                data: {
215                    recommendation
216                },
217                fix(fixer) {
218                    if (!shouldFix) {
219                        return null;
220                    }
221
222                    const tokenBefore = sourceCode.getTokenBefore(node);
223
224                    if (
225                        tokenBefore &&
226                        tokenBefore.range[1] === node.range[0] &&
227                        !astUtils.canTokensBeAdjacent(tokenBefore, recommendation)
228                    ) {
229                        return fixer.replaceText(node, ` ${recommendation}`);
230                    }
231                    return fixer.replaceText(node, recommendation);
232                }
233            });
234        }
235
236        return {
237            UnaryExpression(node) {
238                let operatorAllowed;
239
240                // !!foo
241                operatorAllowed = options.allow.indexOf("!!") >= 0;
242                if (!operatorAllowed && options.boolean && isDoubleLogicalNegating(node)) {
243                    const recommendation = `Boolean(${sourceCode.getText(node.argument.argument)})`;
244
245                    report(node, recommendation, true);
246                }
247
248                // ~foo.indexOf(bar)
249                operatorAllowed = options.allow.indexOf("~") >= 0;
250                if (!operatorAllowed && options.boolean && isBinaryNegatingOfIndexOf(node)) {
251
252                    // `foo?.indexOf(bar) !== -1` will be true (== found) if the `foo` is nullish. So use `>= 0` in that case.
253                    const comparison = node.argument.type === "ChainExpression" ? ">= 0" : "!== -1";
254                    const recommendation = `${sourceCode.getText(node.argument)} ${comparison}`;
255
256                    report(node, recommendation, false);
257                }
258
259                // +foo
260                operatorAllowed = options.allow.indexOf("+") >= 0;
261                if (!operatorAllowed && options.number && node.operator === "+" && !isNumeric(node.argument)) {
262                    const recommendation = `Number(${sourceCode.getText(node.argument)})`;
263
264                    report(node, recommendation, true);
265                }
266            },
267
268            // Use `:exit` to prevent double reporting
269            "BinaryExpression:exit"(node) {
270                let operatorAllowed;
271
272                // 1 * foo
273                operatorAllowed = options.allow.indexOf("*") >= 0;
274                const nonNumericOperand = !operatorAllowed && options.number && isMultiplyByOne(node) && getNonNumericOperand(node);
275
276                if (nonNumericOperand) {
277                    const recommendation = `Number(${sourceCode.getText(nonNumericOperand)})`;
278
279                    report(node, recommendation, true);
280                }
281
282                // "" + foo
283                operatorAllowed = options.allow.indexOf("+") >= 0;
284                if (!operatorAllowed && options.string && isConcatWithEmptyString(node)) {
285                    const recommendation = `String(${sourceCode.getText(getNonEmptyOperand(node))})`;
286
287                    report(node, recommendation, true);
288                }
289            },
290
291            AssignmentExpression(node) {
292
293                // foo += ""
294                const operatorAllowed = options.allow.indexOf("+") >= 0;
295
296                if (!operatorAllowed && options.string && isAppendEmptyString(node)) {
297                    const code = sourceCode.getText(getNonEmptyOperand(node));
298                    const recommendation = `${code} = String(${code})`;
299
300                    report(node, recommendation, true);
301                }
302            }
303        };
304    }
305};
306