/** * @fileoverview Rule to flag numbers that will lose significant figure precision at runtime * @author Jacob Moore */ 'use strict'; //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ /** @type {import('../types').Rule.RuleModule} */ module.exports = { meta: { type: 'problem', dialects: ['typescript', 'javascript'], language: 'javascript', docs: { description: 'Disallow literal numbers that lose precision', recommended: true, url: 'https://eslint.org/docs/latest/rules/no-loss-of-precision', }, schema: [], messages: { noLossOfPrecision: 'This number literal will lose precision at runtime.', }, }, create(context) { /** * Returns whether the node is number literal * @param {Node} node the node literal being evaluated * @returns {boolean} true if the node is a number literal */ function isNumber(node) { return typeof node.value === 'number'; } /** * Gets the source code of the given number literal. Removes `_` numeric separators from the result. * @param {Node} node the number `Literal` node * @returns {string} raw source code of the literal, without numeric separators */ function getRaw(node) { return node.raw.replace(/_/gu, ''); } /** * Checks whether the number is base ten * @param {ASTNode} node the node being evaluated * @returns {boolean} true if the node is in base ten */ function isBaseTen(node) { const prefixes = ['0x', '0X', '0b', '0B', '0o', '0O']; return ( prefixes.every((prefix) => !node.raw.startsWith(prefix)) && !/^0[0-7]+$/u.test(node.raw) ); } /** * Checks that the user-intended non-base ten number equals the actual number after is has been converted to the Number type * @param {Node} node the node being evaluated * @returns {boolean} true if they do not match */ function notBaseTenLosesPrecision(node) { const rawString = getRaw(node).toUpperCase(); let base; if (rawString.startsWith('0B')) { base = 2; } else if (rawString.startsWith('0X')) { base = 16; } else { base = 8; } return !rawString.endsWith(node.value.toString(base).toUpperCase()); } /** * Adds a decimal point to the numeric string at index 1 * @param {string} stringNumber the numeric string without any decimal point * @returns {string} the numeric string with a decimal point in the proper place */ function addDecimalPointToNumber(stringNumber) { return `${stringNumber[0]}.${stringNumber.slice(1)}`; } /** * Returns the number stripped of leading zeros * @param {string} numberAsString the string representation of the number * @returns {string} the stripped string */ function removeLeadingZeros(numberAsString) { for (let i = 0; i < numberAsString.length; i++) { if (numberAsString[i] !== '0') { return numberAsString.slice(i); } } return numberAsString; } /** * Returns the number stripped of trailing zeros * @param {string} numberAsString the string representation of the number * @returns {string} the stripped string */ function removeTrailingZeros(numberAsString) { for (let i = numberAsString.length - 1; i >= 0; i--) { if (numberAsString[i] !== '0') { return numberAsString.slice(0, i + 1); } } return numberAsString; } /** * Converts an integer to an object containing the integer's coefficient and order of magnitude * @param {string} stringInteger the string representation of the integer being converted * @returns {Object} the object containing the integer's coefficient and order of magnitude */ function normalizeInteger(stringInteger) { const significantDigits = removeTrailingZeros( removeLeadingZeros(stringInteger) ); return { magnitude: stringInteger.startsWith('0') ? stringInteger.length - 2 : stringInteger.length - 1, coefficient: addDecimalPointToNumber(significantDigits), }; } /** * * Converts a float to an object containing the floats's coefficient and order of magnitude * @param {string} stringFloat the string representation of the float being converted * @returns {Object} the object containing the integer's coefficient and order of magnitude */ function normalizeFloat(stringFloat) { const trimmedFloat = removeLeadingZeros(stringFloat); if (trimmedFloat.startsWith('.')) { const decimalDigits = trimmedFloat.slice(1); const significantDigits = removeLeadingZeros(decimalDigits); return { magnitude: significantDigits.length - decimalDigits.length - 1, coefficient: addDecimalPointToNumber(significantDigits), }; } return { magnitude: trimmedFloat.indexOf('.') - 1, coefficient: addDecimalPointToNumber(trimmedFloat.replace('.', '')), }; } /** * Converts a base ten number to proper scientific notation * @param {string} stringNumber the string representation of the base ten number to be converted * @returns {string} the number converted to scientific notation */ function convertNumberToScientificNotation(stringNumber) { const splitNumber = stringNumber.replace('E', 'e').split('e'); const originalCoefficient = splitNumber[0]; const normalizedNumber = stringNumber.includes('.') ? normalizeFloat(originalCoefficient) : normalizeInteger(originalCoefficient); const normalizedCoefficient = normalizedNumber.coefficient; const magnitude = splitNumber.length > 1 ? parseInt(splitNumber[1], 10) + normalizedNumber.magnitude : normalizedNumber.magnitude; return `${normalizedCoefficient}e${magnitude}`; } /** * Checks that the user-intended base ten number equals the actual number after is has been converted to the Number type * @param {Node} node the node being evaluated * @returns {boolean} true if they do not match */ function baseTenLosesPrecision(node) { const normalizedRawNumber = convertNumberToScientificNotation( getRaw(node) ); const requestedPrecision = normalizedRawNumber .split('e')[0] .replace('.', '').length; if (requestedPrecision > 100) { return true; } const storedNumber = node.value.toPrecision(requestedPrecision); const normalizedStoredNumber = convertNumberToScientificNotation(storedNumber); return normalizedRawNumber !== normalizedStoredNumber; } /** * Checks that the user-intended number equals the actual number after is has been converted to the Number type * @param {Node} node the node being evaluated * @returns {boolean} true if they do not match */ function losesPrecision(node) { return isBaseTen(node) ? baseTenLosesPrecision(node) : notBaseTenLosesPrecision(node); } return { Literal(node) { if (node.value && isNumber(node) && losesPrecision(node)) { context.report({ messageId: 'noLossOfPrecision', node, }); } }, }; }, };