Skip to main content

xil_core/
pyfmt.rs

1//! Python number and string formatting corners that show up in console
2//! output: `round(x, n)`, `f"{x:.1f}"`, width padding by character count,
3//! `s[:n]` slicing, and `html.escape`.
4
5/// `round(x, ndigits)`: correctly rounded on the binary value, ties to
6/// even — the same digits CPython's `float.__round__` produces.
7pub fn round_to(x: f64, ndigits: usize) -> f64 {
8    if !x.is_finite() {
9        return x;
10    }
11    format!("{x:.ndigits$}").parse().unwrap_or(x)
12}
13
14/// `f"{x:.{prec}f}"`, spelling infinities and NaN the way Python does.
15pub fn fixed(x: f64, prec: usize) -> String {
16    if x.is_nan() {
17        "nan".to_string()
18    } else if x.is_infinite() {
19        (if x > 0.0 { "inf" } else { "-inf" }).to_string()
20    } else {
21        format!("{x:.prec$}")
22    }
23}
24
25/// `f"{s:<width}"` — pad on the right to `width` characters.
26pub fn pad_right(s: &str, width: usize) -> String {
27    let n = s.chars().count();
28    if n >= width {
29        s.to_string()
30    } else {
31        format!("{s}{}", " ".repeat(width - n))
32    }
33}
34
35/// `f"{s:>width}"` — pad on the left to `width` characters.
36pub fn pad_left(s: &str, width: usize) -> String {
37    let n = s.chars().count();
38    if n >= width {
39        s.to_string()
40    } else {
41        format!("{}{s}", " ".repeat(width - n))
42    }
43}
44
45/// `s[:n]` — the first `n` characters.
46pub fn head(s: &str, n: usize) -> String {
47    s.chars().take(n).collect()
48}
49
50/// `f"{n:,}"` — thousands separators, sign kept in front.
51pub fn commas(n: i64) -> String {
52    let digits = n.unsigned_abs().to_string();
53    let mut out = String::new();
54    for (i, c) in digits.chars().enumerate() {
55        if i > 0 && (digits.len() - i) % 3 == 0 {
56            out.push(',');
57        }
58        out.push(c);
59    }
60    if n < 0 {
61        format!("-{out}")
62    } else {
63        out
64    }
65}
66
67/// `html.escape(s)` with `quote=True`.
68pub fn html_escape(s: &str) -> String {
69    let mut out = String::with_capacity(s.len());
70    for c in s.chars() {
71        match c {
72            '&' => out.push_str("&amp;"),
73            '<' => out.push_str("&lt;"),
74            '>' => out.push_str("&gt;"),
75            '"' => out.push_str("&quot;"),
76            '\'' => out.push_str("&#x27;"),
77            _ => out.push(c),
78        }
79    }
80    out
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    /// Expected values printed by CPython 3.13.
88    #[test]
89    fn rounding_and_fixed_match_cpython() {
90        assert_eq!(round_to(2.675, 2), 2.67);
91        assert_eq!(round_to(0.15, 1), 0.1);
92        assert_eq!(round_to(1.25, 1), 1.2);
93        assert_eq!(round_to(2.35, 1), 2.4);
94        assert_eq!(round_to(100.0 / 3.0, 1), 33.3);
95        assert_eq!(fixed(2.5, 0), "2");
96        assert_eq!(fixed(3.5, 0), "4");
97        assert_eq!(fixed(0.25, 1), "0.2");
98        assert_eq!(fixed(0.35, 1), "0.3");
99        assert_eq!(fixed(f64::NEG_INFINITY, 1), "-inf");
100        assert_eq!(pad_left(&fixed(12345.678, 1), 6), "12345.7");
101    }
102
103    #[test]
104    fn padding_and_slicing_count_characters() {
105        assert_eq!(pad_right("ééé", 6), "ééé   ");
106        assert_eq!(pad_left("ab", 4), "  ab");
107        assert_eq!(head("日本語テキスト", 3), "日本語");
108        assert_eq!(head("ab", 5), "ab");
109    }
110
111    #[test]
112    fn commas_like_format_spec() {
113        assert_eq!(commas(0), "0");
114        assert_eq!(commas(1234567), "1,234,567");
115        assert_eq!(commas(-1234), "-1,234");
116        assert_eq!(commas(-123), "-123");
117    }
118
119    #[test]
120    fn escape_matches_html_module() {
121        assert_eq!(
122            html_escape("a & b < c > d \" e ' f"),
123            "a &amp; b &lt; c &gt; d &quot; e &#x27; f"
124        );
125    }
126}