no-magic-numbers.js 8.45 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
/**
 * @fileoverview Rule to flag statements that use magic numbers (adapted from https://github.com/danielstjules/buddy.js)
 * @author Vincent Lemeunier
 */

"use strict";

const astUtils = require("./utils/ast-utils");

// Maximum array length by the ECMAScript Specification.
const MAX_ARRAY_LENGTH = 2 ** 32 - 1;

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/**
 * Convert the value to bigint if it's a string. Otherwise return the value as-is.
 * @param {bigint|number|string} x The value to normalize.
 * @returns {bigint|number} The normalized value.
 */
function normalizeIgnoreValue(x) {
    if (typeof x === "string") {
        return BigInt(x.slice(0, -1));
    }
    return x;
}

module.exports = {
    meta: {
        type: "suggestion",

        docs: {
            description: "disallow magic numbers",
            category: "Best Practices",
            recommended: false,
            url: "https://eslint.org/docs/rules/no-magic-numbers"
        },

        schema: [{
            type: "object",
            properties: {
                detectObjects: {
                    type: "boolean",
                    default: false
                },
                enforceConst: {
                    type: "boolean",
                    default: false
                },
                ignore: {
                    type: "array",
                    items: {
                        anyOf: [
                            { type: "number" },
                            { type: "string", pattern: "^[+-]?(?:0|[1-9][0-9]*)n$" }
                        ]
                    },
                    uniqueItems: true
                },
                ignoreArrayIndexes: {
                    type: "boolean",
                    default: false
                },
                ignoreDefaultValues: {
                    type: "boolean",
                    default: false
                }
            },
            additionalProperties: false
        }],

        messages: {
            useConst: "Number constants declarations must use 'const'.",
            noMagic: "No magic number: {{raw}}."
        }
    },

    create(context) {
        const config = context.options[0] || {},
            detectObjects = !!config.detectObjects,
            enforceConst = !!config.enforceConst,
            ignore = (config.ignore || []).map(normalizeIgnoreValue),
            ignoreArrayIndexes = !!config.ignoreArrayIndexes,
            ignoreDefaultValues = !!config.ignoreDefaultValues;

        const okTypes = detectObjects ? [] : ["ObjectExpression", "Property", "AssignmentExpression"];

        /**
         * Returns whether the rule is configured to ignore the given value
         * @param {bigint|number} value The value to check
         * @returns {boolean} true if the value is ignored
         */
        function isIgnoredValue(value) {
            return ignore.indexOf(value) !== -1;
        }

        /**
         * Returns whether the number is a default value assignment.
         * @param {ASTNode} fullNumberNode `Literal` or `UnaryExpression` full number node
         * @returns {boolean} true if the number is a default value
         */
        function isDefaultValue(fullNumberNode) {
            const parent = fullNumberNode.parent;

            return parent.type === "AssignmentPattern" && parent.right === fullNumberNode;
        }

        /**
         * Returns whether the given node is used as a radix within parseInt() or Number.parseInt()
         * @param {ASTNode} fullNumberNode `Literal` or `UnaryExpression` full number node
         * @returns {boolean} true if the node is radix
         */
        function isParseIntRadix(fullNumberNode) {
            const parent = fullNumberNode.parent;

            return parent.type === "CallExpression" && fullNumberNode === parent.arguments[1] &&
                (
                    astUtils.isSpecificId(parent.callee, "parseInt") ||
                    astUtils.isSpecificMemberAccess(parent.callee, "Number", "parseInt")
                );
        }

        /**
         * Returns whether the given node is a direct child of a JSX node.
         * In particular, it aims to detect numbers used as prop values in JSX tags.
         * Example: <input maxLength={10} />
         * @param {ASTNode} fullNumberNode `Literal` or `UnaryExpression` full number node
         * @returns {boolean} true if the node is a JSX number
         */
        function isJSXNumber(fullNumberNode) {
            return fullNumberNode.parent.type.indexOf("JSX") === 0;
        }

        /**
         * Returns whether the given node is used as an array index.
         * Value must coerce to a valid array index name: "0", "1", "2" ... "4294967294".
         *
         * All other values, like "-1", "2.5", or "4294967295", are just "normal" object properties,
         * which can be created and accessed on an array in addition to the array index properties,
         * but they don't affect array's length and are not considered by methods such as .map(), .forEach() etc.
         *
         * The maximum array length by the specification is 2 ** 32 - 1 = 4294967295,
         * thus the maximum valid index is 2 ** 32 - 2 = 4294967294.
         *
         * All notations are allowed, as long as the value coerces to one of "0", "1", "2" ... "4294967294".
         *
         * Valid examples:
         * a[0], a[1], a[1.2e1], a[0xAB], a[0n], a[1n]
         * a[-0] (same as a[0] because -0 coerces to "0")
         * a[-0n] (-0n evaluates to 0n)
         *
         * Invalid examples:
         * a[-1], a[-0xAB], a[-1n], a[2.5], a[1.23e1], a[12e-1]
         * a[4294967295] (above the max index, it's an access to a regular property a["4294967295"])
         * a[999999999999999999999] (even if it wasn't above the max index, it would be a["1e+21"])
         * a[1e310] (same as a["Infinity"])
         * @param {ASTNode} fullNumberNode `Literal` or `UnaryExpression` full number node
         * @param {bigint|number} value Value expressed by the fullNumberNode
         * @returns {boolean} true if the node is a valid array index
         */
        function isArrayIndex(fullNumberNode, value) {
            const parent = fullNumberNode.parent;

            return parent.type === "MemberExpression" && parent.property === fullNumberNode &&
                (Number.isInteger(value) || typeof value === "bigint") &&
                value >= 0 && value < MAX_ARRAY_LENGTH;
        }

        return {
            Literal(node) {
                if (!astUtils.isNumericLiteral(node)) {
                    return;
                }

                let fullNumberNode;
                let value;
                let raw;

                // Treat unary minus as a part of the number
                if (node.parent.type === "UnaryExpression" && node.parent.operator === "-") {
                    fullNumberNode = node.parent;
                    value = -node.value;
                    raw = `-${node.raw}`;
                } else {
                    fullNumberNode = node;
                    value = node.value;
                    raw = node.raw;
                }

                const parent = fullNumberNode.parent;

                // Always allow radix arguments and JSX props
                if (
                    isIgnoredValue(value) ||
                    (ignoreDefaultValues && isDefaultValue(fullNumberNode)) ||
                    isParseIntRadix(fullNumberNode) ||
                    isJSXNumber(fullNumberNode) ||
                    (ignoreArrayIndexes && isArrayIndex(fullNumberNode, value))
                ) {
                    return;
                }

                if (parent.type === "VariableDeclarator") {
                    if (enforceConst && parent.parent.kind !== "const") {
                        context.report({
                            node: fullNumberNode,
                            messageId: "useConst"
                        });
                    }
                } else if (
                    okTypes.indexOf(parent.type) === -1 ||
                    (parent.type === "AssignmentExpression" && parent.left.type === "Identifier")
                ) {
                    context.report({
                        node: fullNumberNode,
                        messageId: "noMagic",
                        data: {
                            raw
                        }
                    });
                }
            }
        };
    }
};