fix(linter/unicorn/prefer-bigint-literals): unwrap redundant constructor call; don't auto-fix for lost precision (#25690)

`prefer-bigint-literals` now simplifies global `BigInt(123n)` calls to
`123n` and withholds numeric autofixes that would change the runtime
bigint value. Rounded number literals still receive a diagnostic with an
“Integer literal loses precision” note.

- Decimal integer checks compare the full stored integer, including
trailing zeros. For example, `BigInt(1000000000000000100)` evaluates to
`1000000000000000128n`, so replacing it with `1000000000000000100n` is
unsafe.
- Binary, octal, and hexadecimal checks use significant binary digits
without narrowing the value to `u64`. This blocks unsafe fixes such as
`BigInt(0xffffffffffffffff)` while preserving valid fixes above
`u64::MAX`, such as `BigInt(0x10000000000000000)`.
- Calls to locally bound `BigInt` functions, parameters, and imports are
left intact.
- Replacements preserve token boundaries: `BigInt(1n)in {'1':0}` becomes
`1n in {'1':0}`. The same padding applies to number and string arguments
and adjacent `instanceof`, `as`, and `satisfies` keywords.

The precision helpers are shared through `utils/number.rs`. Bigint fixes
use exact decimal integer equality, while `no-loss-of-precision` retains
its existing decimal significant-digit behavior. The non-decimal
correction also applies to `no-loss-of-precision`. Regression tests
cover rounded and exact large integers, shadowed calls, and token
boundaries.

---------

Co-authored-by: Cameron Clark <cameron.clark@hey.com>
This commit is contained in:
Jacob Bandes-StorchandCameron Clark authored and GitHub committed 2026-10-09 09:44:43 +00:00
1 parent b5e8eff8da
commit 598ac03f31
6 files changed
+718 -522

No files matched your search

@@ -1,12 +1,9 @@
use std::borrow::Cow;
use cow_utils::CowUtils;
use oxc_ast::{AstKind, ast::NumericLiteral};
use oxc_ast::AstKind;
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;
use crate::{AstNode, context::LintContext, rule::Rule};
use crate::{AstNode, context::LintContext, rule::Rule, utils::loses_precision};
fn no_loss_of_precision_diagnostic(span: Span) -> OxcDiagnostic {
OxcDiagnostic::warn("This number literal will lose precision at runtime.")
@@ -91,7 +88,7 @@ declare_oxc_lint!(
impl Rule for NoLossOfPrecision {
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
match node.kind() {
AstKind::NumericLiteral(node) if Self::lose_precision(node) => {
AstKind::NumericLiteral(node) if loses_precision(node) => {
ctx.diagnostic(no_loss_of_precision_diagnostic(node.span));
}
_ => {}
@@ -99,509 +96,6 @@ impl Rule for NoLossOfPrecision {
}
}
pub struct RawNum<'a> {
int: &'a str,
frac: &'a str,
exp: isize,
}
#[derive(Debug)]
pub struct ScientificNotation<'a> {
int: &'a str,
frac: Cow<'a, str>,
exp: isize,
}
impl PartialEq for ScientificNotation<'_> {
fn eq(&self, other: &Self) -> bool {
if self.int == other.int && self.frac == other.frac {
if self.int == "0" && self.frac.is_empty() {
return true;
}
return self.exp == other.exp;
}
false
}
}
impl<'a> RawNum<'a> {
fn new(num: &str) -> Option<RawNum<'_>> {
// remove sign
let num = num.trim_start_matches(['+', '-']);
let (int, num_without_int) = {
// skip leading zeros
let num_without_zeros = num.trim_start_matches('0');
// read the integer part and store the end index
let int_end = num_without_zeros
.chars()
.position(|ch| !ch.is_ascii_digit())
.unwrap_or(num_without_zeros.len());
// if no integer part was found, default to 0
let int = if int_end == 0 { "0" } else { &num_without_zeros[..int_end] };
// make a slice of the rest of the string
let num_without_int = &num_without_zeros[int_end..];
(int, num_without_int)
};
// if next char is a dot, parse the fractional part
let (frac, num_without_frac) =
num_without_int.strip_prefix('.').map_or(("", num_without_int), |num_without_dot| {
let frac_end = num_without_dot
.chars()
.position(|ch| !ch.is_ascii_digit())
.unwrap_or(num_without_dot.len());
// slice the fractional part and the rest of the string
let frac = &num_without_dot[..frac_end];
let num_without_frac = &num_without_dot[frac_end..];
(frac, num_without_frac)
});
// if next char is an e, treat the rest as the exponent
let exp =
num_without_frac.strip_prefix(['e', 'E']).map_or("0", |num_without_e| num_without_e);
let Ok(exp) = exp.parse::<isize>() else {
return None;
};
Some(RawNum { int, frac, exp })
}
fn normalize(&mut self, parse_as_float: bool) -> ScientificNotation<'a> {
if self.int == "0" && !self.frac.is_empty() {
let frac_zeros = self.frac.chars().take_while(|&ch| ch == '0').count();
#[expect(clippy::cast_possible_wrap)]
let exp = self.exp - 1 - frac_zeros as isize;
self.frac = &self.frac[frac_zeros..];
match self.frac.len() {
0 => ScientificNotation { int: "0", frac: Cow::Borrowed(""), exp },
1 => ScientificNotation { int: &self.frac[..1], frac: Cow::Borrowed(""), exp },
_ => ScientificNotation {
int: &self.frac[..1],
frac: Cow::Borrowed(&self.frac[1..]),
exp,
},
}
} else {
#[expect(clippy::cast_possible_wrap)]
let exp = self.exp + self.int.len() as isize - 1;
if self.int.len() == 1 {
ScientificNotation { int: self.int, frac: Cow::Borrowed(self.frac), exp }
} else {
let frac = if self.frac.is_empty() {
if parse_as_float {
Cow::Borrowed(&self.int[1..])
} else {
let int_trimmed = self.int.trim_end_matches('0');
if int_trimmed.len() == 1 {
Cow::Borrowed("")
} else {
Cow::Borrowed(&int_trimmed[1..])
}
}
} else {
Cow::Owned(format!("{}{}", &self.int[1..], self.frac))
};
ScientificNotation { int: &self.int[..1], frac, exp }
}
}
}
}
impl NoLossOfPrecision {
const MAX_OBVIOUSLY_SAFE_SIGNIFICANT_DIGITS: usize = 15;
fn strip_numeric_separators(raw: &str) -> Cow<'_, str> {
if !raw.as_bytes().contains(&b'_') {
return Cow::Borrowed(raw);
}
raw.cow_replace('_', "")
}
fn non_base_ten_literal_is_exact(raw: &str) -> bool {
let raw = raw.trim_start_matches(['+', '-']);
let (radix, digits) =
if let Some(digits) = raw.strip_prefix("0b").or_else(|| raw.strip_prefix("0B")) {
(2, digits)
} else if let Some(digits) = raw.strip_prefix("0o").or_else(|| raw.strip_prefix("0O")) {
(8, digits)
} else if let Some(digits) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) {
(16, digits)
} else {
(8, raw.trim_start_matches('0'))
};
let mut bit_len = 0;
for byte in digits.bytes() {
if byte == b'_' {
continue;
}
let digit = match byte {
b'0'..=b'9' => byte - b'0',
b'a'..=b'f' => byte - b'a' + 10,
b'A'..=b'F' => byte - b'A' + 10,
_ => return false,
};
if bit_len == 0 {
if digit == 0 {
continue;
}
bit_len = match radix {
2 => 1,
8 => match digit {
1 => 1,
2 | 3 => 2,
_ => 3,
},
_ => match digit {
1 => 1,
2 | 3 => 2,
4..=7 => 3,
_ => 4,
},
};
} else {
bit_len += match radix {
2 => 1,
8 => 3,
_ => 4,
};
}
if bit_len > 53 {
return false;
}
}
true
}
fn base_ten_literal_is_safe(raw: &str, value: f64) -> bool {
if !value.is_finite() {
return false;
}
let raw = raw.trim_start_matches(['+', '-']);
let exponent_start = raw.find(['e', 'E']).unwrap_or(raw.len());
if exponent_start != raw.len() {
return false;
}
let mantissa = &raw[..exponent_start];
let mut digit_index = 0;
let mut first_non_zero = None;
let mut last_non_zero = None;
let mut fractional_has_non_zero = false;
let mut dot_digit_index = None;
for byte in mantissa.bytes() {
match byte {
b'_' => continue,
b'.' => {
dot_digit_index = Some(digit_index);
continue;
}
b'0'..=b'9' => {}
_ => return false,
}
if byte != b'0' {
first_non_zero.get_or_insert(digit_index);
last_non_zero = Some(digit_index);
fractional_has_non_zero |= dot_digit_index.is_some();
}
digit_index += 1;
}
let Some(first_non_zero) = first_non_zero else {
return true;
};
if value == 0.0 {
return false;
}
if let Some(dot_digit_index) = dot_digit_index
&& !fractional_has_non_zero
{
return dot_digit_index - first_non_zero <= Self::MAX_OBVIOUSLY_SAFE_SIGNIFICANT_DIGITS;
}
let last_significant =
if dot_digit_index.is_some() { digit_index - 1 } else { last_non_zero.unwrap() };
last_significant - first_non_zero < Self::MAX_OBVIOUSLY_SAFE_SIGNIFICANT_DIGITS
}
fn not_base_ten_loses_precision(node: &'_ NumericLiteral) -> bool {
let raw = node.raw.as_ref().unwrap().as_str();
if Self::non_base_ten_literal_is_exact(raw) {
return false;
}
let raw = Self::strip_numeric_separators(raw);
let raw = raw.cow_to_ascii_uppercase();
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
// AST always store number as f64, need a cast to format in bin/oct/hex
let value = node.value as u64;
let suffix = if raw.starts_with("0B") {
format!("{value:b}")
} else if raw.starts_with("0X") {
format!("{value:x}")
} else {
format!("{value:o}")
};
!raw.ends_with(&suffix.cow_to_ascii_uppercase().as_ref())
}
fn base_ten_loses_precision(node: &'_ NumericLiteral) -> bool {
let raw = node.raw.as_ref().unwrap().as_str();
if Self::base_ten_literal_is_safe(raw, node.value) {
return false;
}
let raw = Self::strip_numeric_separators(raw);
let Some(raw) = Self::normalize(&raw, false) else {
return true;
};
let total_significant_digits = raw.int.len() + raw.frac.len();
if total_significant_digits > 100 {
return true;
}
let stored = to_precision(node.value, total_significant_digits);
let Some(stored) = Self::normalize(&stored, true) else {
return true;
};
raw != stored
}
fn normalize(num: &str, parse_as_float: bool) -> Option<ScientificNotation<'_>> {
let coefficient = num.trim_start_matches(['+', '-']);
let exp_start =
coefficient.find('e').or_else(|| coefficient.find('E')).unwrap_or(coefficient.len());
let parse_as_float = parse_as_float || coefficient[..exp_start].contains('.');
Some(RawNum::new(num)?.normalize(parse_as_float))
}
pub fn lose_precision(node: &'_ NumericLiteral) -> bool {
if node.base.is_base_10() {
Self::base_ten_loses_precision(node)
} else {
Self::not_base_ten_loses_precision(node)
}
}
}
/// `flt_str_to_exp` - used in `to_precision`
///
/// This function traverses a string representing a number,
/// returning the floored log10 of this number.
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_possible_wrap)]
fn flt_str_to_exp(flt: &str) -> i32 {
let mut non_zero_encountered = false;
let mut dot_encountered = false;
for (i, c) in flt.char_indices() {
if c == '.' {
if non_zero_encountered {
return (i as i32) - 1;
}
dot_encountered = true;
} else if c != '0' {
if dot_encountered {
return 1 - (i as i32);
}
non_zero_encountered = true;
}
}
(flt.len() as i32) - 1
}
/// `round_to_precision` - used in `to_precision`
///
/// This procedure has two roles:
/// - If there are enough or more than enough digits in the
/// string to show the required precision, the number
/// represented by these digits is rounded using string
/// manipulation.
/// - Else, zeroes are appended to the string.
/// - Additionally, sometimes the exponent was wrongly computed and
/// while up-rounding we find that we need an extra digit. When this
/// happens, we return true so that the calling context can adjust
/// the exponent. The string is kept at an exact length of `precision`.
///
/// When this procedure returns, `digits` is exactly `precision` long.
fn round_to_precision(digits: &mut String, precision: usize) -> bool {
if digits.len() > precision {
let to_round = digits.split_off(precision);
let mut digit =
digits.pop().expect("already checked that length is bigger than precision") as u8;
if let Some(first) = to_round.chars().next()
&& first > '4'
{
digit += 1;
}
if digit as char == ':' {
// ':' is '9' + 1
// need to propagate the increment backward
let mut replacement = String::from("0");
let mut propagated = false;
for c in digits.chars().rev() {
let d = match (c, propagated) {
('0'..='8', false) => (c as u8 + 1) as char,
(_, false) => '0',
(_, true) => c,
};
replacement.push(d);
if d != '0' {
propagated = true;
}
}
digits.clear();
let replacement = if propagated {
replacement.as_str()
} else {
digits.push('1');
&replacement.as_str()[1..]
};
for c in replacement.chars().rev() {
digits.push(c);
}
!propagated
} else {
digits.push(digit as char);
false
}
} else {
digits.push_str(&"0".repeat(precision - digits.len()));
false
}
}
fn fractional_digits_for_precision(num: f64, precision: usize) -> usize {
let exponent = format!("{num:e}")
.rsplit_once('e')
.and_then(|(_, exp)| exp.parse::<isize>().ok())
.unwrap_or_default();
if exponent.is_negative() {
exponent.unsigned_abs().saturating_add(precision).saturating_add(1).max(100)
} else {
100
}
}
/// Mimics JavaScript's `Number.prototype.toPrecision()` method
///
/// The `toPrecision()` method returns a string representing the Number object to the specified precision.
///
/// More information:
/// - [ECMAScript reference][spec]
/// - [MDN documentation][mdn]
///
/// [spec]: https://tc39.es/ecma262/#sec-number.prototype.toprecision
/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toPrecision
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_possible_wrap)]
#[expect(clippy::cast_sign_loss)]
pub fn to_precision(mut num: f64, precision: usize) -> String {
// Validate precision range (1-100)
debug_assert!((1..=100).contains(&precision), "Precision must be between 1 and 100");
// Handle non-finite numbers
if !num.is_finite() {
if num.is_nan() {
return "NaN".to_string();
} else if num.is_infinite() {
return if num.is_sign_positive() { "Infinity" } else { "-Infinity" }.to_string();
}
}
let precision_i32 = precision as i32;
// Handle sign
let mut prefix = String::new();
if num < 0.0 {
prefix.push('-');
num = -num;
}
let mut suffix: String;
let mut exponent: i32;
// Handle zero
if num == 0.0 {
suffix = "0".repeat(precision);
exponent = 0;
} else {
// For very small numbers (e.g. 3e-308), we need more than 100 fractional digits
// to keep enough significant digits for precision comparison.
let fractional_digits = fractional_digits_for_precision(num, precision);
suffix = format!("{num:.fractional_digits$}");
// Calculate exponent
exponent = flt_str_to_exp(&suffix);
// Extract relevant digits only
if exponent < 0 {
suffix = suffix.split_off((1 - exponent) as usize);
} else if let Some(n) = suffix.find('.') {
suffix.remove(n);
}
// Round to the specified precision
if round_to_precision(&mut suffix, precision) {
exponent += 1;
}
// Decide between scientific and fixed notation
let great_exp = exponent >= precision_i32;
if exponent < -6 || great_exp {
// Use scientific notation
if precision > 1 {
suffix.insert(1, '.');
}
suffix.push('e');
if great_exp {
suffix.push('+');
}
suffix.push_str(&exponent.to_string());
return prefix + &suffix;
}
}
// Use fixed-point notation
let e_inc = exponent + 1;
if e_inc == precision_i32 {
return prefix + &suffix;
}
if exponent >= 0 {
suffix.insert(e_inc as usize, '.');
} else {
prefix.push('0');
prefix.push('.');
prefix.push_str(&"0".repeat(-e_inc as usize));
}
prefix + &suffix
}
#[test]
fn test() {
use crate::tester::Tester;
@@ -669,6 +163,10 @@ fn test() {
// "var x = 0377777777777777777", // '0'-prefixed octal literals and octal escape sequences are deprecated
"var x = 0x1FFFFFFFFFFFFF",
"var x = 0X1FFFFFFFFFFFFF",
"var x = 0x10000000000000000",
"var x = 0X1_0000_0000_0000_0000",
"var x = 0o2000000000000000000000",
"var x = 0b10000000000000000000000000000000000000000000000000000000000000000",
"var x = true",
"var x = 'abc'",
"var x = ''",
@@ -737,6 +235,10 @@ fn test() {
"var x = 0400000000000000001",
"var x = 0x20000000000001",
"var x = 0X20000000000001",
"var x = 0xffffffffffffffff",
"var x = 0xffffffffffffffffffff",
"var x = 0o1777777777777777777777",
"var x = 0b1111111111111111111111111111111111111111111111111111111111111111",
"var x = 5123_00000000000000000000000000_1", // { "ecmaVersion": 2021 },
"var x = -5_12300000000000000000000_0000001", // { "ecmaVersion": 2021 },
"var x = 123_00000000000000000000_00.0_0", // { "ecmaVersion": 2021 },
@@ -1,10 +1,16 @@
use oxc_ast::{AstKind, ast::Expression};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_semantic::IsGlobalReference;
use oxc_span::{GetSpan, Span};
use oxc_syntax::number::NumberBase;
use crate::{AstNode, context::LintContext, rule::Rule};
use crate::{
AstNode,
context::LintContext,
rule::Rule,
utils::{integer_literal_loses_precision, pad_fix_with_token_boundary},
};
fn prefer_bigint_literals_diagnostic(span: Span) -> OxcDiagnostic {
OxcDiagnostic::warn("Prefer bigint literals over `BigInt(...)`.")
@@ -64,7 +70,11 @@ impl Rule for PreferBigintLiterals {
return;
};
if reference.name != "BigInt" || call.optional || call.arguments.len() != 1 {
if reference.name != "BigInt"
|| !reference.is_global_reference(ctx.scoping())
|| call.optional
|| call.arguments.len() != 1
{
return;
}
@@ -75,15 +85,27 @@ impl Rule for PreferBigintLiterals {
};
if argument_expression.is_big_int_literal() {
ctx.diagnostic_with_fix(prefer_bigint_literals_diagnostic(arg.span()), |fixer| {
let mut replacement = ctx.source_range(argument_expression.span()).to_string();
pad_fix_with_token_boundary(ctx.source_text(), call.span, &mut replacement);
fixer.replace(call.span, replacement)
});
return;
}
match argument_expression.get_inner_expression() {
Expression::StringLiteral(string_literal) => {
if let Some(replacement) = bigint_literal_from_string(&string_literal.value) {
if let Some(mut replacement) = bigint_literal_from_string(&string_literal.value) {
ctx.diagnostic_with_fix(
prefer_bigint_literals_diagnostic(arg.span()),
|fixer| fixer.replace(call.span, replacement),
|fixer| {
pad_fix_with_token_boundary(
ctx.source_text(),
call.span,
&mut replacement,
);
fixer.replace(call.span, replacement)
},
);
}
}
@@ -100,12 +122,24 @@ impl Rule for PreferBigintLiterals {
|raw| raw.as_str(),
);
if let Some(replacement) =
if integer_literal_loses_precision(numeric_literal) {
ctx.diagnostic(
prefer_bigint_literals_diagnostic(arg.span())
.with_note("Integer literal loses precision"),
);
} else if let Some(mut replacement) =
bigint_literal_from_numeric(raw_text, numeric_literal.base)
{
ctx.diagnostic_with_fix(
prefer_bigint_literals_diagnostic(arg.span()),
|fixer| fixer.replace(call.span, replacement),
|fixer| {
pad_fix_with_token_boundary(
ctx.source_text(),
call.span,
&mut replacement,
);
fixer.replace(call.span, replacement)
},
);
} else {
ctx.diagnostic(prefer_bigint_literals_diagnostic(arg.span()));
@@ -206,7 +240,11 @@ fn test() {
r"BigInt?.(1)",
r"BigInt(1.1)",
r"typeof BigInt",
r"BigInt(1n)",
r"((BigInt) => BigInt(1n))(() => 2n)",
r"function f(BigInt) { return BigInt(1); }",
r"const BigInt = () => 2n; BigInt(1n);",
r"import BigInt from 'custom-bigint'; BigInt('1');",
r"function BigInt() { return 2n; } BigInt(1n);",
r#"BigInt("not-number")"#,
r#"BigInt("1_2")"#,
r#"BigInt("1\\\n2")"#,
@@ -235,16 +273,53 @@ fn test() {
r"BigInt(0x20000000000001)",
r"BigInt(9_007_199_254_740_993)",
r"BigInt(0x20_00_00_00_00_00_01)",
r"BigInt(0777777777777777777)",
r"BigInt(1000000000000000100)",
r"BigInt(100000000000000000000000)",
r"BigInt(100_000_000_000_000_010_000)",
r"BigInt(0xffffffffffffffff)",
r"BigInt(0xffffffffffffffffffff)",
r"BigInt(0b1111111111111111111111111111111111111111111111111111111111111111)",
r"BigInt(0o1777777777777777777777)",
r"BigInt(9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999)",
];
let fix = vec![
(r"BigInt('42')", "42n"),
(r"BigInt(' 0xFF ')", "0xFFn"),
(r"BigInt(0)", "0n"),
(r"BigInt(1n)", r"1n"),
(r"BigInt(1n)in {'1':0}", r"1n in {'1':0}"),
(r"BigInt(1n)instanceof Object", r"1n instanceof Object"),
(r"BigInt(1n)as bigint", r"1n as bigint"),
(r"BigInt(1n)satisfies bigint", r"1n satisfies bigint"),
(r"BigInt(1)in {'1':0}", r"1n in {'1':0}"),
(r"BigInt('1')instanceof Object", r"1n instanceof Object"),
(r"BigInt(1n).toString()", r"1n.toString()"),
(r"BigInt(0B11_11)", "0B11_11n"),
(r"BigInt(0O777_777)", "0O777_777n"),
(r"BigInt(0777)", "0o777n"),
(r"BigInt(0888)", "888n"),
(r"BigInt(1000000000000000100)", r"BigInt(1000000000000000100)"),
(r"BigInt(100000000000000000000000)", r"BigInt(100000000000000000000000)"),
(r"BigInt(100_000_000_000_000_010_000)", r"BigInt(100_000_000_000_000_010_000)"),
(r"BigInt(1000000000000000000)", "1000000000000000000n"),
(r"BigInt(9007199254740992)", "9007199254740992n"),
(r"BigInt(9_007_199_254_740_992)", "9_007_199_254_740_992n"),
(r"BigInt(0xffffffffffffffff)", r"BigInt(0xffffffffffffffff)"),
(r"BigInt(0xffffffffffffffffffff)", r"BigInt(0xffffffffffffffffffff)"),
(
r"BigInt(0b1111111111111111111111111111111111111111111111111111111111111111)",
r"BigInt(0b1111111111111111111111111111111111111111111111111111111111111111)",
),
(r"BigInt(0o1777777777777777777777)", r"BigInt(0o1777777777777777777777)"),
(r"BigInt(0x10000000000000000)", "0x10000000000000000n"),
(r"BigInt(0X1_0000_0000_0000_0000)", "0X1_0000_0000_0000_0000n"),
(r"BigInt(0o2000000000000000000000)", "0o2000000000000000000000n"),
(
r"BigInt(0b10000000000000000000000000000000000000000000000000000000000000000)",
"0b10000000000000000000000000000000000000000000000000000000000000000n",
),
(r#"BigInt("0777")"#, "777n"),
(r#"BigInt("0888")"#, "888n"),
(r#"BigInt("0b1010")"#, "0b1010n"),
@@ -255,10 +330,6 @@ fn test() {
r"BigInt('9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999')",
"9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999n",
),
(
r"BigInt(9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999)",
"9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999n",
),
(r"BigInt(1e2)", r"BigInt(1e2)"),
(r"BigInt(1e6)", r"BigInt(1e6)"),
(r"BigInt(1E6)", r"BigInt(1E6)"),
@@ -298,6 +298,38 @@ source: crates/oxc_linter/src/tester.rs
help: Use a number literal representable by a 64-bit floating-point number, or use a `BigInt` literal (for example, `9007199254740993n`) for exact large integers.
note: In JavaScript, `Number` values exactly represent integers only in the range -9007199254740991 to 9007199254740991 (`Number.MIN_SAFE_INTEGER` to `Number.MAX_SAFE_INTEGER`). `BigInt` supports arbitrarily large integers.
⚠ eslint(no-loss-of-precision): This number literal will lose precision at runtime.
╭─[no_loss_of_precision.tsx:1:9]
1 │ var x = 0xffffffffffffffff
· ──────────────────
╰────
help: Use a number literal representable by a 64-bit floating-point number, or use a `BigInt` literal (for example, `9007199254740993n`) for exact large integers.
note: In JavaScript, `Number` values exactly represent integers only in the range -9007199254740991 to 9007199254740991 (`Number.MIN_SAFE_INTEGER` to `Number.MAX_SAFE_INTEGER`). `BigInt` supports arbitrarily large integers.
⚠ eslint(no-loss-of-precision): This number literal will lose precision at runtime.
╭─[no_loss_of_precision.tsx:1:9]
1 │ var x = 0xffffffffffffffffffff
· ──────────────────────
╰────
help: Use a number literal representable by a 64-bit floating-point number, or use a `BigInt` literal (for example, `9007199254740993n`) for exact large integers.
note: In JavaScript, `Number` values exactly represent integers only in the range -9007199254740991 to 9007199254740991 (`Number.MIN_SAFE_INTEGER` to `Number.MAX_SAFE_INTEGER`). `BigInt` supports arbitrarily large integers.
⚠ eslint(no-loss-of-precision): This number literal will lose precision at runtime.
╭─[no_loss_of_precision.tsx:1:9]
1 │ var x = 0o1777777777777777777777
· ────────────────────────
╰────
help: Use a number literal representable by a 64-bit floating-point number, or use a `BigInt` literal (for example, `9007199254740993n`) for exact large integers.
note: In JavaScript, `Number` values exactly represent integers only in the range -9007199254740991 to 9007199254740991 (`Number.MIN_SAFE_INTEGER` to `Number.MAX_SAFE_INTEGER`). `BigInt` supports arbitrarily large integers.
⚠ eslint(no-loss-of-precision): This number literal will lose precision at runtime.
╭─[no_loss_of_precision.tsx:1:9]
1 │ var x = 0b1111111111111111111111111111111111111111111111111111111111111111
· ──────────────────────────────────────────────────────────────────
╰────
help: Use a number literal representable by a 64-bit floating-point number, or use a `BigInt` literal (for example, `9007199254740993n`) for exact large integers.
note: In JavaScript, `Number` values exactly represent integers only in the range -9007199254740991 to 9007199254740991 (`Number.MIN_SAFE_INTEGER` to `Number.MAX_SAFE_INTEGER`). `BigInt` supports arbitrarily large integers.
⚠ eslint(no-loss-of-precision): This number literal will lose precision at runtime.
╭─[no_loss_of_precision.tsx:1:9]
1 │ var x = 5123_00000000000000000000000000_1
@@ -127,6 +127,7 @@ source: crates/oxc_linter/src/tester.rs
· ────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
@@ -134,6 +135,7 @@ source: crates/oxc_linter/src/tester.rs
· ────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
@@ -141,6 +143,7 @@ source: crates/oxc_linter/src/tester.rs
· ─────────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
@@ -148,3 +151,76 @@ source: crates/oxc_linter/src/tester.rs
· ──────────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
1 │ BigInt(0777777777777777777)
· ───────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
1 │ BigInt(1000000000000000100)
· ───────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
1 │ BigInt(100000000000000000000000)
· ────────────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
1 │ BigInt(100_000_000_000_000_010_000)
· ───────────────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
1 │ BigInt(0xffffffffffffffff)
· ──────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
1 │ BigInt(0xffffffffffffffffffff)
· ──────────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
1 │ BigInt(0b1111111111111111111111111111111111111111111111111111111111111111)
· ──────────────────────────────────────────────────────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
1 │ BigInt(0o1777777777777777777777)
· ────────────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
⚠ unicorn(prefer-bigint-literals): Prefer bigint literals over `BigInt(...)`.
╭─[prefer_bigint_literals.tsx:1:8]
1 │ BigInt(9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999)
· ────────────────────────────────────────────────────────────────────────────────────────────────────
╰────
help: Use a bigint literal (e.g. `123n`) instead of calling `BigInt` with a literal argument.
note: Integer literal loses precision
+3 -2
View File
@@ -22,6 +22,7 @@ mod jest;
mod jsdoc;
mod nextjs;
mod node;
mod number;
mod promise;
mod react;
mod react_compiler;
@@ -39,8 +40,8 @@ pub mod vue_casing;
pub use self::{
comment::*, config::*, control_flow::*, express::*, jest::*, jsdoc::*, nextjs::*, node::*,
promise::*, react::*, react_compiler::*, react_perf::*, regex::*, schemars::*, static_value::*,
this_expression::*, typescript::*, unicorn::*, url::*, vitest::*, vue::*,
number::*, promise::*, react::*, react_compiler::*, react_perf::*, regex::*, schemars::*,
static_value::*, this_expression::*, typescript::*, unicorn::*, url::*, vitest::*, vue::*,
};
/// List of Eslint rules that have TypeScript equivalents.
+514
View File
@@ -0,0 +1,514 @@
use cow_utils::CowUtils;
use oxc_ast::ast::NumericLiteral;
use oxc_syntax::number::NumberBase;
use std::borrow::Cow;
const MAX_OBVIOUSLY_SAFE_SIGNIFICANT_DIGITS: usize = 15;
struct RawNum<'a> {
int: &'a str,
frac: &'a str,
exp: isize,
}
#[derive(Debug)]
pub struct ScientificNotation<'a> {
int: &'a str,
frac: Cow<'a, str>,
exp: isize,
}
impl PartialEq for ScientificNotation<'_> {
fn eq(&self, other: &Self) -> bool {
if self.int == other.int && self.frac == other.frac {
if self.int == "0" && self.frac.is_empty() {
return true;
}
return self.exp == other.exp;
}
false
}
}
impl<'a> RawNum<'a> {
fn new(num: &str) -> Option<RawNum<'_>> {
// remove sign
let num = num.trim_start_matches(['+', '-']);
let (int, num_without_int) = {
// skip leading zeros
let num_without_zeros = num.trim_start_matches('0');
// read the integer part and store the end index
let int_end = num_without_zeros
.chars()
.position(|ch| !ch.is_ascii_digit())
.unwrap_or(num_without_zeros.len());
// if no integer part was found, default to 0
let int = if int_end == 0 { "0" } else { &num_without_zeros[..int_end] };
// make a slice of the rest of the string
let num_without_int = &num_without_zeros[int_end..];
(int, num_without_int)
};
// if next char is a dot, parse the fractional part
let (frac, num_without_frac) =
num_without_int.strip_prefix('.').map_or(("", num_without_int), |num_without_dot| {
let frac_end = num_without_dot
.chars()
.position(|ch| !ch.is_ascii_digit())
.unwrap_or(num_without_dot.len());
// slice the fractional part and the rest of the string
let frac = &num_without_dot[..frac_end];
let num_without_frac = &num_without_dot[frac_end..];
(frac, num_without_frac)
});
// if next char is an e, treat the rest as the exponent
let exp =
num_without_frac.strip_prefix(['e', 'E']).map_or("0", |num_without_e| num_without_e);
let Ok(exp) = exp.parse::<isize>() else {
return None;
};
Some(RawNum { int, frac, exp })
}
fn normalize(&mut self, parse_as_float: bool) -> ScientificNotation<'a> {
if self.int == "0" && !self.frac.is_empty() {
let frac_zeros = self.frac.chars().take_while(|&ch| ch == '0').count();
#[expect(clippy::cast_possible_wrap)]
let exp = self.exp - 1 - frac_zeros as isize;
self.frac = &self.frac[frac_zeros..];
match self.frac.len() {
0 => ScientificNotation { int: "0", frac: Cow::Borrowed(""), exp },
1 => ScientificNotation { int: &self.frac[..1], frac: Cow::Borrowed(""), exp },
_ => ScientificNotation {
int: &self.frac[..1],
frac: Cow::Borrowed(&self.frac[1..]),
exp,
},
}
} else {
#[expect(clippy::cast_possible_wrap)]
let exp = self.exp + self.int.len() as isize - 1;
if self.int.len() == 1 {
ScientificNotation { int: self.int, frac: Cow::Borrowed(self.frac), exp }
} else {
let frac = if self.frac.is_empty() {
if parse_as_float {
Cow::Borrowed(&self.int[1..])
} else {
let int_trimmed = self.int.trim_end_matches('0');
if int_trimmed.len() == 1 {
Cow::Borrowed("")
} else {
Cow::Borrowed(&int_trimmed[1..])
}
}
} else {
Cow::Owned(format!("{}{}", &self.int[1..], self.frac))
};
ScientificNotation { int: &self.int[..1], frac, exp }
}
}
}
}
fn strip_numeric_separators(raw: &str) -> Cow<'_, str> {
if !raw.as_bytes().contains(&b'_') {
return Cow::Borrowed(raw);
}
raw.cow_replace('_', "")
}
fn non_base_ten_literal_is_exact(raw: &str) -> bool {
let raw = raw.trim_start_matches(['+', '-']);
let (radix, digits) =
if let Some(digits) = raw.strip_prefix("0b").or_else(|| raw.strip_prefix("0B")) {
(2, digits)
} else if let Some(digits) = raw.strip_prefix("0o").or_else(|| raw.strip_prefix("0O")) {
(8, digits)
} else if let Some(digits) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) {
(16, digits)
} else {
(8, raw.trim_start_matches('0'))
};
let bits_per_digit = match radix {
2 => 1,
8 => 3,
_ => 4,
};
let mut bit_len = 0;
let mut trailing_zero_bits = 0;
for byte in digits.bytes() {
if byte == b'_' {
continue;
}
let digit = match byte {
b'0'..=b'9' => byte - b'0',
b'a'..=b'f' => byte - b'a' + 10,
b'A'..=b'F' => byte - b'A' + 10,
_ => return false,
};
if bit_len == 0 {
if digit == 0 {
continue;
}
bit_len = match radix {
2 => 1,
8 => match digit {
1 => 1,
2 | 3 => 2,
_ => 3,
},
_ => match digit {
1 => 1,
2 | 3 => 2,
4..=7 => 3,
_ => 4,
},
};
} else {
bit_len += bits_per_digit;
}
trailing_zero_bits = if digit == 0 {
trailing_zero_bits + bits_per_digit
} else {
digit.trailing_zeros() as usize
};
}
// A Number can represent up to 53 significant binary digits. Trailing zero bits
// are carried by its exponent and do not consume significand precision.
bit_len - trailing_zero_bits <= 53
}
fn base_ten_literal_is_safe(raw: &str, value: f64) -> bool {
if !value.is_finite() {
return false;
}
let raw = raw.trim_start_matches(['+', '-']);
let exponent_start = raw.find(['e', 'E']).unwrap_or(raw.len());
if exponent_start != raw.len() {
return false;
}
let mantissa = &raw[..exponent_start];
let mut digit_index = 0;
let mut first_non_zero = None;
let mut last_non_zero = None;
let mut fractional_has_non_zero = false;
let mut dot_digit_index = None;
for byte in mantissa.bytes() {
match byte {
b'_' => continue,
b'.' => {
dot_digit_index = Some(digit_index);
continue;
}
b'0'..=b'9' => {}
_ => return false,
}
if byte != b'0' {
first_non_zero.get_or_insert(digit_index);
last_non_zero = Some(digit_index);
fractional_has_non_zero |= dot_digit_index.is_some();
}
digit_index += 1;
}
let Some(first_non_zero) = first_non_zero else {
return true;
};
if value == 0.0 {
return false;
}
if let Some(dot_digit_index) = dot_digit_index
&& !fractional_has_non_zero
{
return dot_digit_index - first_non_zero <= MAX_OBVIOUSLY_SAFE_SIGNIFICANT_DIGITS;
}
let last_significant =
if dot_digit_index.is_some() { digit_index - 1 } else { last_non_zero.unwrap() };
last_significant - first_non_zero < MAX_OBVIOUSLY_SAFE_SIGNIFICANT_DIGITS
}
fn not_base_ten_loses_precision(node: &'_ NumericLiteral) -> bool {
let raw = node.raw.as_ref().unwrap().as_str();
!node.value.is_finite() || !non_base_ten_literal_is_exact(raw)
}
fn base_ten_loses_precision(node: &'_ NumericLiteral) -> bool {
let raw = node.raw.as_ref().unwrap().as_str();
if base_ten_literal_is_safe(raw, node.value) {
return false;
}
let raw = strip_numeric_separators(raw);
let Some(raw) = normalize(&raw, false) else {
return true;
};
let total_significant_digits = raw.int.len() + raw.frac.len();
if total_significant_digits > 100 {
return true;
}
let stored = to_precision(node.value, total_significant_digits);
let Some(stored) = normalize(&stored, true) else {
return true;
};
raw != stored
}
fn normalize(num: &str, parse_as_float: bool) -> Option<ScientificNotation<'_>> {
let coefficient = num.trim_start_matches(['+', '-']);
let exp_start =
coefficient.find('e').or_else(|| coefficient.find('E')).unwrap_or(coefficient.len());
let parse_as_float = parse_as_float || coefficient[..exp_start].contains('.');
Some(RawNum::new(num)?.normalize(parse_as_float))
}
pub fn loses_precision(node: &'_ NumericLiteral) -> bool {
if node.base.is_base_10() {
base_ten_loses_precision(node)
} else {
not_base_ten_loses_precision(node)
}
}
/// Unlike `loses_precision`, integer fixes must preserve every digit, including trailing zeros.
pub fn integer_literal_loses_precision(node: &NumericLiteral) -> bool {
let raw = node.raw.as_ref().unwrap().as_str();
if node.base != NumberBase::Decimal || raw.contains(['e', 'E']) {
return loses_precision(node);
}
// Decimal integer literals in the safe integer range are always exact.
if node.value <= 9_007_199_254_740_991.0 {
return false;
}
let stored = format!("{:.0}", node.value);
!raw.bytes()
.filter(|&byte| byte != b'_')
.skip_while(|&byte| byte == b'0')
.eq(stored.bytes().skip_while(|&byte| byte == b'0'))
}
/// `flt_str_to_exp` - used in `to_precision`
///
/// This function traverses a string representing a number,
/// returning the floored log10 of this number.
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_possible_wrap)]
fn flt_str_to_exp(flt: &str) -> i32 {
let mut non_zero_encountered = false;
let mut dot_encountered = false;
for (i, c) in flt.char_indices() {
if c == '.' {
if non_zero_encountered {
return (i as i32) - 1;
}
dot_encountered = true;
} else if c != '0' {
if dot_encountered {
return 1 - (i as i32);
}
non_zero_encountered = true;
}
}
(flt.len() as i32) - 1
}
/// `round_to_precision` - used in `to_precision`
///
/// This procedure has two roles:
/// - If there are enough or more than enough digits in the
/// string to show the required precision, the number
/// represented by these digits is rounded using string
/// manipulation.
/// - Else, zeroes are appended to the string.
/// - Additionally, sometimes the exponent was wrongly computed and
/// while up-rounding we find that we need an extra digit. When this
/// happens, we return true so that the calling context can adjust
/// the exponent. The string is kept at an exact length of `precision`.
///
/// When this procedure returns, `digits` is exactly `precision` long.
fn round_to_precision(digits: &mut String, precision: usize) -> bool {
if digits.len() > precision {
let to_round = digits.split_off(precision);
let mut digit =
digits.pop().expect("already checked that length is bigger than precision") as u8;
if let Some(first) = to_round.chars().next()
&& first > '4'
{
digit += 1;
}
if digit as char == ':' {
// ':' is '9' + 1
// need to propagate the increment backward
let mut replacement = String::from("0");
let mut propagated = false;
for c in digits.chars().rev() {
let d = match (c, propagated) {
('0'..='8', false) => (c as u8 + 1) as char,
(_, false) => '0',
(_, true) => c,
};
replacement.push(d);
if d != '0' {
propagated = true;
}
}
digits.clear();
let replacement = if propagated {
replacement.as_str()
} else {
digits.push('1');
&replacement.as_str()[1..]
};
for c in replacement.chars().rev() {
digits.push(c);
}
!propagated
} else {
digits.push(digit as char);
false
}
} else {
digits.push_str(&"0".repeat(precision - digits.len()));
false
}
}
fn fractional_digits_for_precision(num: f64, precision: usize) -> usize {
let exponent = format!("{num:e}")
.rsplit_once('e')
.and_then(|(_, exp)| exp.parse::<isize>().ok())
.unwrap_or_default();
if exponent.is_negative() {
exponent.unsigned_abs().saturating_add(precision).saturating_add(1).max(100)
} else {
100
}
}
/// Mimics JavaScript's `Number.prototype.toPrecision()` method
///
/// The `toPrecision()` method returns a string representing the Number object to the specified precision.
///
/// More information:
/// - [ECMAScript reference][spec]
/// - [MDN documentation][mdn]
///
/// [spec]: https://tc39.es/ecma262/#sec-number.prototype.toprecision
/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toPrecision
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_possible_wrap)]
#[expect(clippy::cast_sign_loss)]
fn to_precision(mut num: f64, precision: usize) -> String {
// Validate precision range (1-100)
debug_assert!((1..=100).contains(&precision), "Precision must be between 1 and 100");
// Handle non-finite numbers
if !num.is_finite() {
if num.is_nan() {
return "NaN".to_string();
} else if num.is_infinite() {
return if num.is_sign_positive() { "Infinity" } else { "-Infinity" }.to_string();
}
}
let precision_i32 = precision as i32;
// Handle sign
let mut prefix = String::new();
if num < 0.0 {
prefix.push('-');
num = -num;
}
let mut suffix: String;
let mut exponent: i32;
// Handle zero
if num == 0.0 {
suffix = "0".repeat(precision);
exponent = 0;
} else {
// For very small numbers (e.g. 3e-308), we need more than 100 fractional digits
// to keep enough significant digits for precision comparison.
let fractional_digits = fractional_digits_for_precision(num, precision);
suffix = format!("{num:.fractional_digits$}");
// Calculate exponent
exponent = flt_str_to_exp(&suffix);
// Extract relevant digits only
if exponent < 0 {
suffix = suffix.split_off((1 - exponent) as usize);
} else if let Some(n) = suffix.find('.') {
suffix.remove(n);
}
// Round to the specified precision
if round_to_precision(&mut suffix, precision) {
exponent += 1;
}
// Decide between scientific and fixed notation
let great_exp = exponent >= precision_i32;
if exponent < -6 || great_exp {
// Use scientific notation
if precision > 1 {
suffix.insert(1, '.');
}
suffix.push('e');
if great_exp {
suffix.push('+');
}
suffix.push_str(&exponent.to_string());
return prefix + &suffix;
}
}
// Use fixed-point notation
let e_inc = exponent + 1;
if e_inc == precision_i32 {
return prefix + &suffix;
}
if exponent >= 0 {
suffix.insert(e_inc as usize, '.');
} else {
prefix.push('0');
prefix.push('.');
prefix.push_str(&"0".repeat(-e_inc as usize));
}
prefix + &suffix
}