Skip to main content

monty_types/
format.rs

1//! Pure CPython-compatible formatting helpers shared by the boundary types:
2//! string/bytes `repr()` escaping, shortest-round-trip float rendering, and
3//! timezone-offset `timedelta` reprs.
4
5use std::fmt::{self, Write};
6
7use unicode_general_category::{GeneralCategory, get_general_category};
8/// Writes a Python `repr()` string for a given string slice to a formatter.
9///
10/// Quote choice matches CPython: single quotes by default, switching to double
11/// quotes only when the string contains a `'` but no `"` (so the quote needn't
12/// be escaped). Backslash, the active quote, and `\n`/`\t`/`\r` use the short
13/// escapes; any other **non-printable** character is escaped numerically
14/// (`\xNN`/`\uNNNN`/`\UNNNNNNNN`), e.g. `repr('\x00') == "'\\x00'"` and
15/// `repr('\xa0') == "'\\xa0'"`.
16///
17/// "Non-printable" matches CPython's `str.isprintable` (see
18/// `repr_needs_escape`): Unicode categories `C*` and `Z*`, except the ASCII
19/// space. Category data comes from `unicode-general-category`, whose Unicode
20/// version may differ slightly from CPython's, affecting only recently
21/// (re)assigned code points.
22pub fn string_repr_fmt(s: &str, f: &mut impl Write) -> fmt::Result {
23    let quote = if s.contains('\'') && !s.contains('"') {
24        '"'
25    } else {
26        '\''
27    };
28    f.write_char(quote)?;
29    for c in s.chars() {
30        match c {
31            '\\' => f.write_str("\\\\")?,
32            '\n' => f.write_str("\\n")?,
33            '\t' => f.write_str("\\t")?,
34            '\r' => f.write_str("\\r")?,
35            _ if c == quote => {
36                f.write_char('\\')?;
37                f.write_char(quote)?;
38            }
39            _ if repr_needs_escape(c) => write_char_escape(c, f)?,
40            _ => f.write_char(c)?,
41        }
42    }
43    f.write_char(quote)
44}
45
46/// Whether `c` is escaped numerically in a Python `repr` — i.e. it is not
47/// "printable" in CPython's sense.
48///
49/// Non-printable = Unicode general categories `Other` (`Cc`, `Cf`, `Cs`, `Co`,
50/// `Cn`) and `Separator` (`Zl`, `Zp`, `Zs`), with the sole exception of the
51/// ASCII space `U+0020`. The `\t`/`\n`/`\r` short escapes are handled by the
52/// caller before this is consulted.
53fn repr_needs_escape(c: char) -> bool {
54    c != ' '
55        && matches!(
56            get_general_category(c),
57            GeneralCategory::Control
58                | GeneralCategory::Format
59                | GeneralCategory::Surrogate
60                | GeneralCategory::PrivateUse
61                | GeneralCategory::Unassigned
62                | GeneralCategory::LineSeparator
63                | GeneralCategory::ParagraphSeparator
64                | GeneralCategory::SpaceSeparator
65        )
66}
67
68/// Writes the numeric repr escape for a single character, matching CPython's
69/// width selection: `\xNN` for code points `<= 0xFF`, `\uNNNN` for `<= 0xFFFF`,
70/// otherwise `\UNNNNNNNN`.
71fn write_char_escape(c: char, f: &mut impl Write) -> fmt::Result {
72    let cp = c as u32;
73    if cp <= 0xFF {
74        write!(f, "\\x{cp:02x}")
75    } else if cp <= 0xFFFF {
76        write!(f, "\\u{cp:04x}")
77    } else {
78        write!(f, "\\U{cp:08x}")
79    }
80}
81
82/// Formatter for a Python repr() string.
83#[derive(Debug)]
84pub struct StringRepr<'a>(pub &'a str);
85
86impl fmt::Display for StringRepr<'_> {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        string_repr_fmt(self.0, f)
89    }
90}
91/// Writes a CPython-compatible repr string for bytes to a formatter.
92///
93/// Format: `b'...'` or `b"..."` depending on content.
94/// - Uses single quotes by default
95/// - Switches to double quotes if bytes contain `'` but not `"`
96/// - Escapes: `\\`, `\t`, `\n`, `\r`, `\xNN` for non-printable bytes
97pub fn bytes_repr_fmt(bytes: &[u8], f: &mut impl Write) -> fmt::Result {
98    // Determine quote character: use double quotes if single quote present but not double
99    let has_single = bytes.contains(&b'\'');
100    let has_double = bytes.contains(&b'"');
101    let quote = if has_single && !has_double { '"' } else { '\'' };
102
103    f.write_char('b')?;
104    f.write_char(quote)?;
105
106    for &byte in bytes {
107        match byte {
108            b'\\' => f.write_str("\\\\")?,
109            b'\t' => f.write_str("\\t")?,
110            b'\n' => f.write_str("\\n")?,
111            b'\r' => f.write_str("\\r")?,
112            b'\'' if quote == '\'' => f.write_str("\\'")?,
113            b'"' if quote == '"' => f.write_str("\\\"")?,
114            // Printable ASCII (32-126)
115            0x20..=0x7e => f.write_char(byte as char)?,
116            // Non-printable: use \xNN format
117            _ => write!(f, "\\x{byte:02x}")?,
118        }
119    }
120
121    f.write_char(quote)
122}
123
124/// Returns a CPython-compatible repr string for bytes.
125///
126/// Convenience wrapper around `bytes_repr_fmt` that returns an owned String.
127#[must_use]
128#[expect(clippy::missing_panics_doc, reason = "writing to a String cannot fail")]
129pub fn bytes_repr(bytes: &[u8]) -> String {
130    let mut result = String::new();
131    // Writing to String never fails
132    bytes_repr_fmt(bytes, &mut result).unwrap();
133    result
134}
135/// A [`Display`](fmt::Display) adapter that writes a float exactly as CPython's
136/// `repr()`/`str()` (identical for floats in Python 3): the shortest decimal
137/// string that round-trips, switching to scientific notation when the base-10
138/// exponent is `< -4` or `>= 16`, and always keeping at least one fractional
139/// digit (`1.0`, never `1`) — `1e16` → `"1e+16"`, `1234.5` → `"1234.5"`,
140/// `inf`/`nan` lowercased.
141///
142/// This is the default rendering for a bare `f"{x}"`, `str(x)`, `repr(x)` and
143/// floats inside container reprs — *not* the format mini-language (that's
144/// `format_float_g` et al, in `monty`). Rust can't do this directly: its `f64` `Display`
145/// never uses scientific notation (`1e16` prints as `10000000000000000`) and
146/// renders NaN as `"NaN"`.
147///
148/// As a `Display` adapter it writes straight to the caller's sink with **no
149/// heap allocation**: it borrows Rust's *shortest-digits* guarantee via `{:e}`
150/// into a small stack buffer (an `f64` `{:e}` is ASCII and ≤ 24 bytes) and
151/// re-lays-out those digits per CPython's rules.
152pub struct FormatFloat(pub f64);
153
154impl fmt::Display for FormatFloat {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        let v = self.0;
157        if v.is_nan() {
158            return f.write_str("nan");
159        }
160        if v.is_sign_negative() {
161            f.write_char('-')?;
162        }
163        if v.is_infinite() {
164            return f.write_str("inf");
165        }
166        // Rust's shortest scientific form gives minimal round-tripping digits
167        // plus the base-10 exponent (`1234.5` → `"1.2345e3"`, `0.0` → `"0e0"`),
168        // captured in a stack buffer so nothing touches the heap.
169        let mut sci = StackStr::new();
170        write!(sci, "{:e}", v.abs())?;
171        let sci = sci.as_str();
172        let (mantissa, exp_str) = sci.split_once('e').ok_or(fmt::Error)?;
173        // `{:e}` always emits a single leading digit, so the integer part is one
174        // char and the fraction (if any) follows the `.`.
175        let (int_part, frac) = mantissa.split_once('.').unwrap_or((mantissa, ""));
176        let exp10: i32 = exp_str.parse().map_err(|_| fmt::Error)?;
177        let ndigits = int_part.len() + frac.len();
178        // `decpt` = number of digits to the left of the decimal point.
179        let decpt = exp10 + 1;
180
181        if !(-4..16).contains(&exp10) {
182            // Scientific: leading digit, optional fraction, then `e±NN`.
183            f.write_str(int_part)?;
184            if !frac.is_empty() {
185                f.write_char('.')?;
186                f.write_str(frac)?;
187            }
188            let exp_sign = if exp10 < 0 { '-' } else { '+' };
189            write!(f, "e{exp_sign}{:02}", exp10.unsigned_abs())
190        } else if decpt <= 0 {
191            // `0.00…digits` — `-decpt` leading zeros after the point.
192            f.write_str("0.")?;
193            for _ in 0..-decpt {
194                f.write_char('0')?;
195            }
196            f.write_str(int_part)?;
197            f.write_str(frac)
198        } else {
199            let decpt = usize::try_from(decpt).expect("decpt is positive in this branch");
200            if decpt >= ndigits {
201                // Integer-valued: digits, zeros up to the point, then `.0`.
202                f.write_str(int_part)?;
203                f.write_str(frac)?;
204                for _ in 0..decpt - ndigits {
205                    f.write_char('0')?;
206                }
207                f.write_str(".0")
208            } else {
209                // Point falls inside the digit run. `int_part` is a single digit
210                // and `decpt >= 1`, so the split always lands within `frac`.
211                f.write_str(int_part)?;
212                let split = decpt - int_part.len();
213                f.write_str(&frac[..split])?;
214                f.write_char('.')?;
215                f.write_str(&frac[split..])
216            }
217        }
218    }
219}
220
221/// A fixed-capacity [`fmt::Write`] sink backed by a stack array, used to capture
222/// a bounded `{:e}` rendering without a heap allocation.
223///
224/// 32 bytes comfortably holds any `f64` `{:e}` output (the longest is ~24 ASCII
225/// bytes, e.g. `2.2250738585072014e-308`). A write that would overflow returns
226/// [`fmt::Error`] rather than panicking — unreachable for the bounded `f64`
227/// case, but it keeps the type panic-free for any future caller.
228struct StackStr {
229    buf: [u8; 32],
230    len: usize,
231}
232
233impl StackStr {
234    fn new() -> Self {
235        Self { buf: [0; 32], len: 0 }
236    }
237
238    fn as_str(&self) -> &str {
239        // Only `{:e}` of an `f64` is written here, which is always valid ASCII.
240        str::from_utf8(&self.buf[..self.len]).unwrap_or("")
241    }
242}
243
244impl fmt::Write for StackStr {
245    fn write_str(&mut self, s: &str) -> fmt::Result {
246        let end = self.len.checked_add(s.len()).ok_or(fmt::Error)?;
247        let slot = self.buf.get_mut(self.len..end).ok_or(fmt::Error)?;
248        slot.copy_from_slice(s.as_bytes());
249        self.len = end;
250        Ok(())
251    }
252}
253/// Classifies an invalid-UTF-8 error into CPython's reason wording, from the
254/// first unexpected byte and `Utf8Error::error_len()`.
255///
256/// `error_len == None` means the input ended mid-sequence (`unexpected end of
257/// data`); otherwise a byte that is a legal multi-byte lead (0xC2–0xF4) was
258/// followed by an invalid continuation, and anything else (stray
259/// continuation bytes, the overlong leads 0xC0/0xC1, 0xF5–0xFF) is an
260/// `invalid start byte`. Public (re-exported at the crate root) so `monty-fs`
261/// produces identical wording for text-mode file reads.
262#[must_use]
263pub fn utf8_error_reason(first_bad_byte: u8, error_len: Option<usize>) -> &'static str {
264    if error_len.is_none() {
265        "unexpected end of data"
266    } else if (0xC2..=0xF4).contains(&first_bad_byte) {
267        "invalid continuation byte"
268    } else {
269        "invalid start byte"
270    }
271}
272
273/// Formats the canonical `datetime.timedelta(...)` repr for a fixed timezone
274/// offset in seconds, normalized like Python's `timedelta` (`days` may be
275/// negative, `seconds` in `0..86400`) — e.g. `-18000` →
276/// `datetime.timedelta(days=-1, seconds=68400)`. Used by the
277/// `datetime.timezone` reprs of [`MontyObject`](crate::object::MontyObject).
278#[must_use]
279pub fn format_offset_timedelta_repr(offset_seconds: i32) -> String {
280    const SECONDS_PER_DAY: i32 = 86_400;
281    let days = offset_seconds.div_euclid(SECONDS_PER_DAY);
282    let seconds = offset_seconds.rem_euclid(SECONDS_PER_DAY);
283    if days == 0 && seconds == 0 {
284        "datetime.timedelta(0)".to_owned()
285    } else {
286        let mut out = String::from("datetime.timedelta(");
287        if days != 0 {
288            write!(out, "days={days}").expect("writing to String never fails");
289        }
290        if seconds != 0 {
291            if days != 0 {
292                out.push_str(", ");
293            }
294            write!(out, "seconds={seconds}").expect("writing to String never fails");
295        }
296        out.push(')');
297        out
298    }
299}