diff --git a/docs/rules/no-loss-of-precision.md b/docs/rules/no-loss-of-precision.md new file mode 100644 index 00000000000..e1fcc4e09d7 --- /dev/null +++ b/docs/rules/no-loss-of-precision.md @@ -0,0 +1,32 @@ +# Disallow Number Literals That Lose Precision (no-loss-of-precision) + +This rule would disallow the use of number literals that immediately lose precision at runtime when converted to a JS `Number` due to 64-bit floating-point rounding. + +## Rule Details + +In JS, `Number`s are stored as double-precision floating-point numbers according to the [IEEE 754 standard](https://en.wikipedia.org/wiki/IEEE_754). Because of this, numbers can only retain accuracy up to a certain amount of digits. If the programmer enters additional digits, those digits will be lost in the conversion to the `Number` type and will result in unexpected behavior. + +Examples of **incorrect** code for this rule: + +```js +/*eslint no-loss-of-precision: "error"*/ + +const x = 9007199254740993 +const x = 5123000000000000000000000000001 +const x = 1230000000000000000000000.0 +const x = .1230000000000000000000000 +const x = 0X20000000000001 +``` + +Examples of **correct** code for this rule: + +```js +/*eslint no-loss-of-precision: "error"*/ + +const x = 12345 +const x = 123.456 +const x = 123e34 +const x = 12300000000000000000000000 +const x = 0x1FFFFFFFFFFFFF +const x = 9007199254740991 +``` diff --git a/lib/rules/index.js b/lib/rules/index.js index 7f563eb2ebf..9e5571dd97d 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -148,6 +148,7 @@ module.exports = new LazyLoadingRuleMap(Object.entries({ "no-lone-blocks": () => require("./no-lone-blocks"), "no-lonely-if": () => require("./no-lonely-if"), "no-loop-func": () => require("./no-loop-func"), + "no-loss-of-precision": () => require("./no-loss-of-precision"), "no-magic-numbers": () => require("./no-magic-numbers"), "no-misleading-character-class": () => require("./no-misleading-character-class"), "no-mixed-operators": () => require("./no-mixed-operators"), diff --git a/lib/rules/no-loss-of-precision.js b/lib/rules/no-loss-of-precision.js new file mode 100644 index 00000000000..b95677c2e0b --- /dev/null +++ b/lib/rules/no-loss-of-precision.js @@ -0,0 +1,198 @@ +/** + * @fileoverview Rule to flag numbers that will lose significant figure precision at runtime + * @author Jacob Moore + */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +module.exports = { + meta: { + type: "problem", + + docs: { + description: "disallow literal numbers that lose precision", + category: "Possible Errors", + recommended: false, + url: "https://eslint.org/docs/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"; + } + + + /** + * 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 = node.raw.toUpperCase(); + let base = 0; + + 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.slice(0, 1)}.${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) { + return numberAsString.replace(/^0*/u, ""); + } + + /** + * Returns the number stripped of trailing zeros + * @param {string} numberAsString the string representation of the number + * @returns {string} the stripped string + */ + function removeTrailingZeros(numberAsString) { + return numberAsString.replace(/0*$/u, ""); + } + + /** + * Converts an integer to to an object containing the 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 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 to an object containing the 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 the integer's coefficient and order of magnitude + */ + function normalizeFloat(stringFloat) { + const trimmedFloat = removeLeadingZeros(stringFloat); + + if (trimmedFloat.startsWith(".")) { + const decimalDigits = trimmedFloat.split(".").pop(); + 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(node.raw); + 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 + }); + } + } + }; + } +}; diff --git a/tests/lib/rules/no-loss-of-precision.js b/tests/lib/rules/no-loss-of-precision.js new file mode 100644 index 00000000000..fd2fb204524 --- /dev/null +++ b/tests/lib/rules/no-loss-of-precision.js @@ -0,0 +1,159 @@ +/** + *@fileoverview Tests for no-loss-of-precision rule. + *@author Jacob Moore + */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require("../../../lib/rules/no-loss-of-precision"), + { RuleTester } = require("../../../lib/rule-tester"); + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester(); + +ruleTester.run("no-loss-of-precision", rule, { + valid: [ + "var x = 12345", + "var x = 123.456", + "var x = -123.456", + "var x = -123456", + "var x = 123e34", + "var x = 123.0e34", + "var x = 123e-34", + "var x = -123e34", + "var x = -123e-34", + "var x = 12.3e34", + "var x = 12.3e-34", + "var x = -12.3e34", + "var x = -12.3e-34", + "var x = 12300000000000000000000000", + "var x = -12300000000000000000000000", + "var x = 0.00000000000000000000000123", + "var x = -0.00000000000000000000000123", + "var x = 9007199254740991", + "var x = 0", + "var x = 0.0", + "var x = 0.000000000000000000000000000000000000000000000000000000000000000000000000000000", + "var x = -0", + "var x = 123.0000000000000000000000", + "var x = 019.5", + "var x = 0195", + "var x = 0e5", + + + { code: "var x = 0b11111111111111111111111111111111111111111111111111111", parserOptions: { ecmaVersion: 6 } }, + { code: "var x = 0B11111111111111111111111111111111111111111111111111111", parserOptions: { ecmaVersion: 6 } }, + + { code: "var x = 0o377777777777777777", parserOptions: { ecmaVersion: 6 } }, + { code: "var x = 0O377777777777777777", parserOptions: { ecmaVersion: 6 } }, + "var x = 0377777777777777777", + + "var x = 0x1FFFFFFFFFFFFF", + "var x = 0X1FFFFFFFFFFFFF", + "var x = true", + "var x = 'abc'", + "var x = ''", + "var x = null", + "var x = undefined", + "var x = {}", + "var x = ['a', 'b']", + "var x = new Date()", + "var x = '9007199254740993'" + + ], + invalid: [ + { + code: "var x = 9007199254740993", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 9007199254740.993e3", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 9.007199254740993e15", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = -9007199254740993", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 900719.9254740994", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = -900719.9254740994", + errors: [{ messageId: "noLossOfPrecision" }] + }, + + { + code: "var x = 5123000000000000000000000000001", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = -5123000000000000000000000000001", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 1230000000000000000000000.0", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 1.0000000000000000000000123", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 17498005798264095394980017816940970922825355447145699491406164851279623993595007385788105416184430592", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 2e999", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = .1230000000000000000000000", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 0b100000000000000000000000000000000000000000000000000001", + parserOptions: { ecmaVersion: 6 }, + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 0B100000000000000000000000000000000000000000000000000001", + parserOptions: { ecmaVersion: 6 }, + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 0o400000000000000001", + parserOptions: { ecmaVersion: 6 }, + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 0O400000000000000001", + parserOptions: { ecmaVersion: 6 }, + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 0400000000000000001", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 0x20000000000001", + errors: [{ messageId: "noLossOfPrecision" }] + }, + { + code: "var x = 0X20000000000001", + errors: [{ messageId: "noLossOfPrecision" }] + } + + ] +}); diff --git a/tools/rule-types.json b/tools/rule-types.json index c3c2498556b..f33aa1b6678 100644 --- a/tools/rule-types.json +++ b/tools/rule-types.json @@ -135,6 +135,7 @@ "no-lone-blocks": "suggestion", "no-lonely-if": "suggestion", "no-loop-func": "suggestion", + "no-loss-of-precision": "problem", "no-magic-numbers": "suggestion", "no-misleading-character-class": "problem", "no-mixed-operators": "suggestion", @@ -277,4 +278,4 @@ "wrap-regex": "layout", "yield-star-spacing": "layout", "yoda": "suggestion" -} +} \ No newline at end of file