Skip to content

Commit c77787c

Browse files
authored
fix(linter): improve eslint/no-loss-of-precision (#11437)
- Closes: #11436
1 parent 93cf3b6 commit c77787c

File tree

1 file changed

+201
-41
lines changed

1 file changed

+201
-41
lines changed

crates/oxc_linter/src/rules/eslint/no_loss_of_precision.rs

Lines changed: 201 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,6 @@ pub struct ScientificNotation<'a> {
101101
int: &'a str,
102102
frac: Cow<'a, str>,
103103
exp: isize,
104-
scientific: bool,
105-
precision: usize,
106104
}
107105

108106
impl PartialEq for ScientificNotation<'_> {
@@ -168,60 +166,39 @@ impl<'a> RawNum<'a> {
168166
}
169167

170168
fn normalize(&mut self) -> ScientificNotation<'a> {
171-
let scientific = self.exp != 0;
172-
let precision = self.frac.len();
173-
if self.int.starts_with('0') {
169+
if self.int == "0" && !self.frac.is_empty() {
174170
let frac_zeros = self.frac.chars().take_while(|&ch| ch == '0').count();
175171
#[expect(clippy::cast_possible_wrap)]
176172
let exp = self.exp - 1 - frac_zeros as isize;
177173
self.frac = &self.frac[frac_zeros..];
178174

179175
match self.frac.len() {
180-
0 => ScientificNotation {
181-
int: "0",
182-
frac: Cow::Borrowed(""),
183-
exp,
184-
scientific,
185-
precision,
186-
},
187-
1 => ScientificNotation {
188-
int: &self.frac[..1],
189-
frac: Cow::Borrowed(""),
190-
exp,
191-
scientific,
192-
precision,
193-
},
176+
0 => ScientificNotation { int: "0", frac: Cow::Borrowed(""), exp },
177+
1 => ScientificNotation { int: &self.frac[..1], frac: Cow::Borrowed(""), exp },
194178
_ => ScientificNotation {
195179
int: &self.frac[..1],
196180
frac: Cow::Borrowed(&self.frac[1..]),
197181
exp,
198-
scientific,
199-
precision,
200182
},
201183
}
202184
} else {
203185
#[expect(clippy::cast_possible_wrap)]
204186
let exp = self.exp + self.int.len() as isize - 1;
205187
if self.int.len() == 1 {
206-
ScientificNotation {
207-
int: self.int,
208-
frac: Cow::Borrowed(self.frac),
209-
exp,
210-
scientific,
211-
precision,
212-
}
188+
ScientificNotation { int: self.int, frac: Cow::Borrowed(self.frac), exp }
213189
} else {
214190
let frac = if self.frac.is_empty() {
215-
Cow::Borrowed(&self.int[1..])
191+
let int_trimmed = self.int.trim_end_matches('0');
192+
if int_trimmed.len() == 1 {
193+
Cow::Borrowed("")
194+
} else {
195+
Cow::Borrowed(&int_trimmed[1..])
196+
}
216197
} else {
217-
Cow::Owned(
218-
format!("{}{}", &self.int[1..], self.frac)
219-
.trim_end_matches('0')
220-
.to_string(),
221-
)
198+
Cow::Owned(format!("{}{}", &self.int[1..], self.frac))
222199
};
223200

224-
ScientificNotation { int: &self.int[..1], frac, exp, scientific, precision }
201+
ScientificNotation { int: &self.int[..1], frac, exp }
225202
}
226203
}
227204
}
@@ -250,14 +227,14 @@ impl NoLossOfPrecision {
250227
return true;
251228
};
252229

253-
if raw.frac.len() >= 100 {
230+
let total_significant_digits = raw.int.len() + raw.frac.len();
231+
232+
if total_significant_digits > 100 {
254233
return true;
255234
}
256-
let stored = match (raw.scientific, raw.precision) {
257-
(true, _) => format!("{:.1$e}", node.value, raw.frac.len()),
258-
(false, 0) => node.value.to_string(),
259-
(false, precision) => format!("{:.1$}", node.value, precision),
260-
};
235+
236+
let stored = to_precision(node.value, total_significant_digits);
237+
261238
let Some(stored) = Self::normalize(&stored) else {
262239
return true;
263240
};
@@ -277,6 +254,188 @@ impl NoLossOfPrecision {
277254
}
278255
}
279256

257+
/// `flt_str_to_exp` - used in `to_precision`
258+
///
259+
/// This function traverses a string representing a number,
260+
/// returning the floored log10 of this number.
261+
#[expect(clippy::cast_possible_truncation)]
262+
#[expect(clippy::cast_possible_wrap)]
263+
fn flt_str_to_exp(flt: &str) -> i32 {
264+
let mut non_zero_encountered = false;
265+
let mut dot_encountered = false;
266+
for (i, c) in flt.char_indices() {
267+
if c == '.' {
268+
if non_zero_encountered {
269+
return (i as i32) - 1;
270+
}
271+
dot_encountered = true;
272+
} else if c != '0' {
273+
if dot_encountered {
274+
return 1 - (i as i32);
275+
}
276+
non_zero_encountered = true;
277+
}
278+
}
279+
(flt.len() as i32) - 1
280+
}
281+
282+
/// `round_to_precision` - used in `to_precision`
283+
///
284+
/// This procedure has two roles:
285+
/// - If there are enough or more than enough digits in the
286+
/// string to show the required precision, the number
287+
/// represented by these digits is rounded using string
288+
/// manipulation.
289+
/// - Else, zeroes are appended to the string.
290+
/// - Additionally, sometimes the exponent was wrongly computed and
291+
/// while up-rounding we find that we need an extra digit. When this
292+
/// happens, we return true so that the calling context can adjust
293+
/// the exponent. The string is kept at an exact length of `precision`.
294+
///
295+
/// When this procedure returns, `digits` is exactly `precision` long.
296+
fn round_to_precision(digits: &mut String, precision: usize) -> bool {
297+
if digits.len() > precision {
298+
let to_round = digits.split_off(precision);
299+
let mut digit =
300+
digits.pop().expect("already checked that length is bigger than precision") as u8;
301+
if let Some(first) = to_round.chars().next() {
302+
if first > '4' {
303+
digit += 1;
304+
}
305+
}
306+
307+
if digit as char == ':' {
308+
// ':' is '9' + 1
309+
// need to propagate the increment backward
310+
let mut replacement = String::from("0");
311+
let mut propagated = false;
312+
for c in digits.chars().rev() {
313+
let d = match (c, propagated) {
314+
('0'..='8', false) => (c as u8 + 1) as char,
315+
(_, false) => '0',
316+
(_, true) => c,
317+
};
318+
replacement.push(d);
319+
if d != '0' {
320+
propagated = true;
321+
}
322+
}
323+
digits.clear();
324+
let replacement = if propagated {
325+
replacement.as_str()
326+
} else {
327+
digits.push('1');
328+
&replacement.as_str()[1..]
329+
};
330+
for c in replacement.chars().rev() {
331+
digits.push(c);
332+
}
333+
!propagated
334+
} else {
335+
digits.push(digit as char);
336+
false
337+
}
338+
} else {
339+
digits.push_str(&"0".repeat(precision - digits.len()));
340+
false
341+
}
342+
}
343+
344+
/// Mimics JavaScript's `Number.prototype.toPrecision()` method
345+
///
346+
/// The `toPrecision()` method returns a string representing the Number object to the specified precision.
347+
///
348+
/// More information:
349+
/// - [ECMAScript reference][spec]
350+
/// - [MDN documentation][mdn]
351+
///
352+
/// [spec]: https://tc39.es/ecma262/#sec-number.prototype.toprecision
353+
/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toPrecision
354+
#[expect(clippy::cast_possible_truncation)]
355+
#[expect(clippy::cast_possible_wrap)]
356+
#[expect(clippy::cast_sign_loss)]
357+
pub fn to_precision(mut num: f64, precision: usize) -> String {
358+
// Validate precision range (1-100)
359+
debug_assert!((1..=100).contains(&precision), "Precision must be between 1 and 100");
360+
361+
// Handle non-finite numbers
362+
if !num.is_finite() {
363+
if num.is_nan() {
364+
return "NaN".to_string();
365+
} else if num.is_infinite() {
366+
return if num.is_sign_positive() { "Infinity" } else { "-Infinity" }.to_string();
367+
}
368+
}
369+
370+
let precision_i32 = precision as i32;
371+
372+
// Handle sign
373+
let mut prefix = String::new();
374+
if num < 0.0 {
375+
prefix.push('-');
376+
num = -num;
377+
}
378+
379+
let mut suffix: String;
380+
let mut exponent: i32;
381+
382+
// Handle zero
383+
if num == 0.0 {
384+
suffix = "0".repeat(precision);
385+
exponent = 0;
386+
} else {
387+
// Format with maximum precision to get all digits
388+
suffix = format!("{num:.100}");
389+
390+
// Calculate exponent
391+
exponent = flt_str_to_exp(&suffix);
392+
393+
// Extract relevant digits only
394+
if exponent < 0 {
395+
suffix = suffix.split_off((1 - exponent) as usize);
396+
} else if let Some(n) = suffix.find('.') {
397+
suffix.remove(n);
398+
}
399+
400+
// Round to the specified precision
401+
if round_to_precision(&mut suffix, precision) {
402+
exponent += 1;
403+
}
404+
405+
// Decide between scientific and fixed notation
406+
let great_exp = exponent >= precision_i32;
407+
if exponent < -6 || great_exp {
408+
// Use scientific notation
409+
if precision > 1 {
410+
suffix.insert(1, '.');
411+
}
412+
suffix.push('e');
413+
if great_exp {
414+
suffix.push('+');
415+
}
416+
suffix.push_str(&exponent.to_string());
417+
418+
return prefix + &suffix;
419+
}
420+
}
421+
422+
// Use fixed-point notation
423+
let e_inc = exponent + 1;
424+
if e_inc == precision_i32 {
425+
return prefix + &suffix;
426+
}
427+
428+
if exponent >= 0 {
429+
suffix.insert(e_inc as usize, '.');
430+
} else {
431+
prefix.push('0');
432+
prefix.push('.');
433+
prefix.push_str(&"0".repeat(-e_inc as usize));
434+
}
435+
436+
prefix + &suffix
437+
}
438+
280439
#[test]
281440
fn test() {
282441
use crate::tester::Tester;
@@ -350,6 +509,7 @@ fn test() {
350509
("var a = Infinity", None),
351510
("var a = 480.00", None),
352511
("var a = -30.00", None),
512+
("(1000000000000000128).toFixed(0)", None),
353513
];
354514

355515
let fail = vec![

0 commit comments

Comments
 (0)