Skip to main content

workshop_rs/
format.rs

1//! Shared number formatting for emission.
2//!
3//! [`format_number`] renders a Workshop number the way the pinned oracle
4//! does for computed values: integers print without a decimal point, and
5//! non-integers print the shortest round-trip representation truncated to 16
6//! significant digits (OverPy behavior; evidence: the pinned oracle
7//! snapshots). Literal source spellings (e.g. `0.0`, `5.0`) are preserved by
8//! the frontends' number nodes and take precedence over this formatter.
9
10/// Format a float like the reference frontend: integers print without a
11/// decimal point, and non-integers print the shortest round-trip
12/// representation truncated to 16 significant digits (OverPy behavior;
13/// evidence: the pinned oracle snapshots).
14pub fn format_number(value: f64) -> String {
15    if !value.is_finite() {
16        return format!("{value}");
17    }
18    if value == 0.0 {
19        return "0".to_string();
20    }
21    let rounded = value.round();
22    let near_integer = (value - rounded).abs() <= f64::EPSILON * value.abs().max(1.0) * 4.0;
23    if near_integer && rounded.abs() < 1e15 {
24        return format!("{}", rounded as i64);
25    }
26    truncate_significant(&format!("{value}"), 16)
27}
28
29/// Keep at most `max_digits` significant digits of a decimal string,
30/// truncating (not rounding) and expanding any exponent form.
31fn truncate_significant(text: &str, max_digits: usize) -> String {
32    let (mantissa, exponent) = match text.find('e').or_else(|| text.find('E')) {
33        Some(index) => {
34            let exponent: i32 = text[index + 1..].parse().unwrap_or(0);
35            (&text[..index], exponent)
36        }
37        None => (text, 0),
38    };
39    let (sign, mantissa) = mantissa
40        .strip_prefix('-')
41        .map_or(("", mantissa), |rest| ("-", rest));
42    let digits: Vec<char> = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
43    let before_dot = mantissa.find('.').unwrap_or(mantissa.len());
44    let point = before_dot as i32 + exponent;
45    let first_nonzero = digits
46        .iter()
47        .position(|c| *c != '0')
48        .unwrap_or(digits.len());
49    let mut digits = digits;
50    if digits.len() - first_nonzero > max_digits {
51        digits.truncate(first_nonzero + max_digits);
52    }
53    let mut out = String::from(sign);
54    if point <= 0 {
55        out.push_str("0.");
56        for _ in 0..(-point) {
57            out.push('0');
58        }
59        for c in &digits {
60            out.push(*c);
61        }
62    } else if point as usize >= digits.len() {
63        for c in &digits {
64            out.push(*c);
65        }
66        for _ in 0..(point as usize - digits.len()) {
67            out.push('0');
68        }
69    } else {
70        for (index, c) in digits.iter().enumerate() {
71            if index == point as usize {
72                out.push('.');
73            }
74            out.push(*c);
75        }
76    }
77    out
78}
79
80#[cfg(test)]
81mod tests {
82    use super::format_number;
83
84    #[test]
85    fn integers_print_without_decimals() {
86        assert_eq!(format_number(0.0), "0");
87        assert_eq!(format_number(100.0), "100");
88        assert_eq!(format_number(-3.0), "-3");
89    }
90
91    #[test]
92    fn floats_match_reference_precision() {
93        assert_eq!(format_number(1.8106601717798212), "1.810660171779821");
94        assert_eq!(format_number(-1.2803300858899105), "-1.280330085889910");
95        assert_eq!(format_number(0.016), "0.016");
96        assert_eq!(format_number(0.125), "0.125");
97        assert_eq!(format_number(1.5), "1.5");
98    }
99}