Skip to main content

kohebi_core/
float.rs

1//! `repr` of a float, which is not what any Rust format specifier produces.
2//!
3//! Rust prints `1e30` as thirty digits and `1.0` as `1`. CPython picks between
4//! fixed and exponential notation on the position of the decimal point, pads
5//! the exponent to two digits, and always signs it. The digits themselves are
6//! the same in both languages, the shortest string that reads back as the same
7//! double, so `{:e}` supplies them and only the presentation is redone here.
8
9use std::fmt::Write as _;
10
11/// Whether a float with no fractional part keeps a trailing `.0`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum DotZero {
14    /// `repr(100.0)` is `100.0`.
15    Add,
16    /// `repr(100j)` is `100j`.
17    Omit,
18}
19
20/// `repr` of a float, with `dot_zero` deciding the trailing `.0`.
21#[must_use]
22pub fn float_repr(value: f64, dot_zero: DotZero) -> String {
23    if value.is_nan() {
24        return "nan".to_owned();
25    }
26    if value.is_infinite() {
27        return if value < 0.0 { "-inf" } else { "inf" }.to_owned();
28    }
29
30    let sign = if value.is_sign_negative() { "-" } else { "" };
31    let (digits, exponent) = shortest_digits(value.abs());
32
33    // CPython counts from the decimal point rather than from the first digit:
34    // `decpt` is where the point sits relative to the start of the digits.
35    let decpt = exponent + 1;
36    let mut out = String::with_capacity(digits.len() + 8);
37    out.push_str(sign);
38
39    if decpt <= -4 || decpt > 16 {
40        out.push_str(&digits[..1]);
41        if digits.len() > 1 {
42            out.push('.');
43            out.push_str(&digits[1..]);
44        }
45        let exp = decpt - 1;
46        let _ = write!(out, "e{}{:02}", if exp < 0 { '-' } else { '+' }, exp.abs());
47    } else if decpt <= 0 {
48        out.push_str("0.");
49        for _ in 0..-decpt {
50            out.push('0');
51        }
52        out.push_str(&digits);
53    } else {
54        // Past the two branches above, the point sits inside or just after the
55        // digits, so it is an index into them.
56        let at = usize::try_from(decpt).expect("decpt is positive and small here");
57        if at >= digits.len() {
58            out.push_str(&digits);
59            for _ in 0..(at - digits.len()) {
60                out.push('0');
61            }
62            if dot_zero == DotZero::Add {
63                out.push_str(".0");
64            }
65        } else {
66            out.push_str(&digits[..at]);
67            out.push('.');
68            out.push_str(&digits[at..]);
69        }
70    }
71    out
72}
73
74/// The shortest round-tripping digits of a finite non-negative float, and the
75/// power of ten the first of them stands for.
76///
77/// `{:e}` gives `d.dddde±X`, which is exactly that information in a different
78/// arrangement, so this unpicks it rather than generating digits again.
79fn shortest_digits(value: f64) -> (String, i32) {
80    let formatted = format!("{value:e}");
81    let (mantissa, exponent) = formatted
82        .split_once('e')
83        .expect("Rust always writes an exponent in `{:e}` form");
84    let digits = mantissa.replace('.', "");
85    let exponent = exponent
86        .parse::<i32>()
87        .expect("Rust always writes a decimal exponent");
88    (digits, exponent)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn repr(value: f64) -> String {
96        float_repr(value, DotZero::Add)
97    }
98
99    #[test]
100    fn a_float_keeps_a_trailing_zero_and_an_imaginary_does_not() {
101        assert_eq!(repr(100.0), "100.0");
102        assert_eq!(float_repr(100.0, DotZero::Omit), "100");
103        assert_eq!(float_repr(0.0, DotZero::Omit), "0");
104    }
105
106    #[test]
107    fn the_switch_to_exponential_notation_is_where_cpython_puts_it() {
108        assert_eq!(repr(1e15), "1000000000000000.0");
109        assert_eq!(repr(1e16), "1e+16");
110        assert_eq!(repr(0.0001), "0.0001");
111        assert_eq!(repr(0.00001), "1e-05");
112    }
113
114    #[test]
115    fn an_overflowing_literal_is_infinity_and_prints_as_one() {
116        assert_eq!(repr(f64::INFINITY), "inf");
117        assert_eq!(repr(f64::NEG_INFINITY), "-inf");
118        assert_eq!(repr(f64::NAN), "nan");
119    }
120}