/** * @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; } /** @type {import('../shared/types').Rule} */ module.exports = { meta: { type: "suggestion", docs: { description: "Disallow magic numbers", 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 }, ignoreClassFieldInitialValues: { 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 = new Set((config.ignore || []).map(normalizeIgnoreValue)), ignoreArrayIndexes = !!config.ignoreArrayIndexes, ignoreDefaultValues = !!config.ignoreDefaultValues, ignoreClassFieldInitialValues = !!config.ignoreClassFieldInitialValues; 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.has(value); } /** * 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 number is the initial value of a class field. * @param {ASTNode} fullNumberNode `Literal` or `UnaryExpression` full number node * @returns {boolean} true if the number is the initial value of a class field. */ function isClassFieldInitialValue(fullNumberNode) { const parent = fullNumberNode.parent; return parent.type === "PropertyDefinition" && parent.value === 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: * @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)) || (ignoreClassFieldInitialValues && isClassFieldInitialValue(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.includes(parent.type) || (parent.type === "AssignmentExpression" && parent.left.type === "Identifier") ) { context.report({ node: fullNumberNode, messageId: "noMagic", data: { raw } }); } } }; } };