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}