Skip to main content

rac_engine/
pycompat.rs

1//! CPython 3.11 string / float / round semantics (PORT-CONTRACT.md §"Python-
2//! compatibility primitives", PORT-CONTRACT.d/07 §1.4–1.5).
3//!
4//! Unicode behavior is table-driven from the packaged
5//! `assets/spec/pycompat-tables.json`
6//! (generated by `rust/spec/extract_pycompat_tables.py` against the oracle
7//! interpreter), embedded at build time and parsed once into a static.
8//!
9//! Float formatting (`py_float_repr`) reproduces CPython `repr(float)`:
10//! shortest round-trip digits reshaped Python-style (`.0` for integral,
11//! `1e-05`/`1e+20` exponent shaping). `py_round` and the `.1f`/`.0%` format
12//! helpers do correct decimal rounding (half-to-even) of the *exact* binary
13//! value via an arbitrary-precision decimal expansion of the mantissa —
14//! never multiply-by-10^n tricks.
15
16use std::collections::HashMap;
17use std::fmt::Write;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::OnceLock;
20
21const TABLES_JSON: &str = include_str!("../assets/spec/pycompat-tables.json");
22
23// ---------------------------------------------------------------------------
24// stdin surrogateescape (PEP 383) — sentinel representation
25//
26// The oracle reads stdin as text with `errors="surrogateescape"` (C locale →
27// UTF-8 mode): each undecodable byte b becomes the lone surrogate U+DC00+b.
28// Rust `String` cannot hold surrogates, so `decode_stdin_surrogateescape`
29// maps each such byte to a plane-16 private-use SENTINEL char U+10FC00+b
30// (U+10FC80..=U+10FCFF) and flips a process-wide flag. While the flag is set,
31// the sinks that can observe the char identity translate the sentinel back
32// to oracle-surrogate behavior:
33//   - YAML `check_printable`  → "unacceptable character #xdcXX" (frontmatter)
34//   - `py_repr_str`           → `\udcXX` escape
35//   - the JSON writer         → `\udcXX` (ensure_ascii)
36//   - stdout emission         → the raw original byte (surrogateescape
37//                                re-encoding on the oracle's stdout)
38//
39// The flag gating means file-based runs (which decode with U+FFFD
40// replacement, matching the oracle's `errors="replace"`) are bit-for-bit
41// unaffected. KNOWN SEAM: input that legitimately contains U+10FC80..=
42// U+10FCFF (4-byte UTF-8 in the plane-16 PUA) in the same run as an
43// undecodable stdin byte would be mis-translated; the oracle treats those
44// as ordinary astral chars. Unobservable short of adversarial input using
45// that exact code-point range together with invalid stdin bytes.
46// ---------------------------------------------------------------------------
47
48/// First sentinel code point; add the raw byte value (0x80..=0xFF).
49pub const SURROGATE_SENTINEL_BASE: u32 = 0x10FC00;
50
51static STDIN_SURROGATES: AtomicBool = AtomicBool::new(false);
52
53/// True once `decode_stdin_surrogateescape` has produced a sentinel.
54pub fn surrogate_sentinels_active() -> bool {
55    STDIN_SURROGATES.load(Ordering::Relaxed)
56}
57
58/// Test hook: reset/force the sentinel flag.
59pub fn set_surrogate_sentinels_active(on: bool) {
60    STDIN_SURROGATES.store(on, Ordering::Relaxed);
61}
62
63/// If `c` is an active sentinel, the surrogate code point (0xDC80..=0xDCFF)
64/// the oracle would hold instead.
65pub fn sentinel_surrogate(c: char) -> Option<u32> {
66    let cp = c as u32;
67    if surrogate_sentinels_active() && (SURROGATE_SENTINEL_BASE + 0x80..=SURROGATE_SENTINEL_BASE + 0xFF).contains(&cp) {
68        Some(0xDC00 + (cp - SURROGATE_SENTINEL_BASE))
69    } else {
70        None
71    }
72}
73
74/// Decode stdin bytes the way the oracle's `sys.stdin.read()` does under the
75/// parity environment: UTF-8 with `surrogateescape` — every undecodable byte
76/// becomes one sentinel char (CPython maps each bad byte individually, which
77/// matches walking Rust's `Utf8Error` with per-byte emission).
78pub fn decode_stdin_surrogateescape(bytes: &[u8]) -> String {
79    let mut out = String::with_capacity(bytes.len());
80    let mut rest = bytes;
81    loop {
82        match std::str::from_utf8(rest) {
83            Ok(s) => {
84                out.push_str(s);
85                return out;
86            }
87            Err(e) => {
88                let valid = e.valid_up_to();
89                out.push_str(std::str::from_utf8(&rest[..valid]).expect("valid prefix"));
90                let bad = e.error_len().unwrap_or(rest.len() - valid);
91                for &b in &rest[valid..valid + bad] {
92                    out.push(
93                        char::from_u32(SURROGATE_SENTINEL_BASE + b as u32)
94                            .expect("plane-16 PUA sentinel"),
95                    );
96                    STDIN_SURROGATES.store(true, Ordering::Relaxed);
97                }
98                rest = &rest[valid + bad..];
99            }
100        }
101    }
102}
103
104/// Encode a rendered stdout payload, re-materializing sentinel chars as
105/// their raw original bytes (the oracle's stdout uses `surrogateescape`, so
106/// a lone surrogate prints as the byte that produced it). Borrow when no
107/// translation is needed.
108pub fn encode_stdout_surrogateescape(text: &str) -> std::borrow::Cow<'_, [u8]> {
109    if !surrogate_sentinels_active() || !text.chars().any(|c| sentinel_surrogate(c).is_some()) {
110        return std::borrow::Cow::Borrowed(text.as_bytes());
111    }
112    let mut out = Vec::with_capacity(text.len());
113    for c in text.chars() {
114        if let Some(sur) = sentinel_surrogate(c) {
115            out.push((sur - 0xDC00) as u8);
116        } else {
117            let mut buf = [0u8; 4];
118            out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
119        }
120    }
121    std::borrow::Cow::Owned(out)
122}
123
124struct Tables {
125    casefold: HashMap<u32, String>,
126    str_whitespace: Vec<(u32, u32)>,
127    splitlines_boundaries: Vec<u32>,
128    isprintable: Vec<(u32, u32)>,
129    re_digit: Vec<(u32, u32)>,
130    re_word: Vec<(u32, u32)>,
131}
132
133fn parse_ranges(v: &serde_json::Value) -> Vec<(u32, u32)> {
134    v.as_array()
135        .expect("range table must be an array")
136        .iter()
137        .map(|pair| {
138            let p = pair.as_array().expect("range entry must be a pair");
139            (
140                p[0].as_u64().expect("range start") as u32,
141                p[1].as_u64().expect("range end") as u32,
142            )
143        })
144        .collect()
145}
146
147fn tables() -> &'static Tables {
148    static TABLES: OnceLock<Tables> = OnceLock::new();
149    TABLES.get_or_init(|| {
150        let root: serde_json::Value =
151            serde_json::from_str(TABLES_JSON).expect("pycompat-tables.json must parse");
152        let casefold = root["casefold"]
153            .as_object()
154            .expect("casefold table")
155            .iter()
156            .map(|(k, v)| {
157                (
158                    k.parse::<u32>().expect("casefold key"),
159                    v.as_str().expect("casefold value").to_string(),
160                )
161            })
162            .collect();
163        let splitlines_boundaries = root["splitlines_boundaries"]
164            .as_array()
165            .expect("splitlines_boundaries")
166            .iter()
167            .map(|v| v.as_u64().expect("boundary cp") as u32)
168            .collect();
169        Tables {
170            casefold,
171            str_whitespace: parse_ranges(&root["str_whitespace"]),
172            splitlines_boundaries,
173            isprintable: parse_ranges(&root["isprintable"]),
174            re_digit: parse_ranges(&root["re_digit"]),
175            re_word: parse_ranges(&root["re_word"]),
176        }
177    })
178}
179
180fn in_ranges(ranges: &[(u32, u32)], cp: u32) -> bool {
181    // Ranges are sorted and disjoint; binary search on the start.
182    let idx = ranges.partition_point(|&(start, _)| start <= cp);
183    idx > 0 && cp <= ranges[idx - 1].1
184}
185
186// ---------------------------------------------------------------------------
187// Case folding / whitespace / line boundaries / character classes
188// ---------------------------------------------------------------------------
189
190/// Python `str.casefold()` — full Unicode case folding (`ß` → `ss`).
191pub fn py_casefold(s: &str) -> String {
192    let t = tables();
193    let mut out = String::with_capacity(s.len());
194    for c in s.chars() {
195        match t.casefold.get(&(c as u32)) {
196            Some(folded) => out.push_str(folded),
197            None => out.push(c),
198        }
199    }
200    out
201}
202
203/// Python `str.isspace()` for a single character (includes `\x1c`–`\x1f`
204/// and NBSP; excludes U+FEFF and U+200B).
205pub fn py_is_space(c: char) -> bool {
206    in_ranges(&tables().str_whitespace, c as u32)
207}
208
209/// Python `str.strip()` with no argument (strips the Python whitespace set).
210pub fn py_strip(s: &str) -> &str {
211    py_rstrip(py_lstrip(s))
212}
213
214/// Python `str.lstrip()` with no argument.
215pub fn py_lstrip(s: &str) -> &str {
216    s.trim_start_matches(py_is_space)
217}
218
219/// Python `str.rstrip()` with no argument.
220pub fn py_rstrip(s: &str) -> &str {
221    s.trim_end_matches(py_is_space)
222}
223
224fn is_line_boundary(c: char) -> bool {
225    let b = &tables().splitlines_boundaries;
226    b.binary_search(&(c as u32)).is_ok()
227}
228
229/// Python `str.splitlines()` (keepends=False): splits on the exact Python
230/// boundary set (`\n \r \v \f \x1c \x1d \x1e \x85 U+2028 U+2029`), with a
231/// `\r\n` pair consumed as a single boundary; no trailing empty element.
232pub fn py_splitlines(s: &str) -> Vec<&str> {
233    let mut out = Vec::new();
234    let mut start = 0usize;
235    let mut iter = s.char_indices().peekable();
236    while let Some((i, c)) = iter.next() {
237        if is_line_boundary(c) {
238            out.push(&s[start..i]);
239            let mut end = i + c.len_utf8();
240            if c == '\r' {
241                if let Some(&(j, '\n')) = iter.peek() {
242                    iter.next();
243                    end = j + 1;
244                }
245            }
246            start = end;
247        }
248    }
249    if start < s.len() {
250        out.push(&s[start..]);
251    }
252    out
253}
254
255/// The first non-empty `py_strip`'d line of `s` (`str.splitlines()`
256/// boundaries), or `""` when every line is blank.
257pub fn first_nonempty_line(s: &str) -> &str {
258    py_splitlines(s)
259        .into_iter()
260        .map(py_strip)
261        .find(|l| !l.is_empty())
262        .unwrap_or("")
263}
264
265/// `Path(path).read_text(encoding="utf-8")` — strict UTF-8 with universal
266/// newlines (`\r\n`/`\r` → `\n`); `None` on OSError/UnicodeDecodeError
267/// (callers substitute "" or a structured `unreadable` error).
268pub fn read_text_universal(path: &str) -> Option<String> {
269    let bytes = std::fs::read(path).ok()?;
270    let text = String::from_utf8(bytes).ok()?;
271    Some(text.replace("\r\n", "\n").replace('\r', "\n"))
272}
273
274/// Python `str.isprintable()` for a single character.
275pub fn py_is_printable(c: char) -> bool {
276    in_ranges(&tables().isprintable, c as u32)
277}
278
279/// Python `re` `\d` class membership (Unicode default).
280pub fn is_re_digit(c: char) -> bool {
281    in_ranges(&tables().re_digit, c as u32)
282}
283
284/// Python `re` `\w` class membership (Unicode default).
285pub fn is_re_word(c: char) -> bool {
286    in_ranges(&tables().re_word, c as u32)
287}
288
289// ---------------------------------------------------------------------------
290// repr(str)
291// ---------------------------------------------------------------------------
292
293/// Python `repr()` for strings: single quotes unless the string contains a
294/// single quote and no double quote; escapes for backslash/quote/`\t\n\r`;
295/// `\xXX`/`\uXXXX`/`\UXXXXXXXX` (lowercase hex) for non-printable characters;
296/// printable non-ASCII stays literal.
297pub fn py_repr_str(s: &str) -> String {
298    let quote = if s.contains('\'') && !s.contains('"') {
299        '"'
300    } else {
301        '\''
302    };
303    let mut out = String::with_capacity(s.len() + 2);
304    out.push(quote);
305    for c in s.chars() {
306        if c == quote || c == '\\' {
307            out.push('\\');
308            out.push(c);
309        } else if c == '\t' {
310            out.push_str("\\t");
311        } else if c == '\n' {
312            out.push_str("\\n");
313        } else if c == '\r' {
314            out.push_str("\\r");
315        } else if let Some(sur) = sentinel_surrogate(c) {
316            // stdin surrogateescape sentinel: repr as the lone surrogate.
317            write!(out, "\\u{sur:04x}").unwrap();
318        } else if py_is_printable(c) {
319            out.push(c);
320        } else {
321            let cp = c as u32;
322            if cp < 0x100 {
323                write!(out, "\\x{cp:02x}").unwrap();
324            } else if cp < 0x10000 {
325                write!(out, "\\u{cp:04x}").unwrap();
326            } else {
327                write!(out, "\\U{cp:08x}").unwrap();
328            }
329        }
330    }
331    out.push(quote);
332    out
333}
334
335// ---------------------------------------------------------------------------
336// urllib.parse — quote_plus / urlencode (share-URL formatting)
337// ---------------------------------------------------------------------------
338
339/// `urllib.parse.quote_plus(s, safe='')` over the string's UTF-8 bytes:
340/// the ALWAYS-SAFE set (ASCII alphanumerics plus `_.-~`) stays literal,
341/// a space becomes `+`, and every other byte becomes uppercase `%XX`.
342pub fn quote_plus(s: &str) -> String {
343    let mut out = String::with_capacity(s.len());
344    for b in s.bytes() {
345        match b {
346            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b'~' => {
347                out.push(b as char)
348            }
349            b' ' => out.push('+'),
350            _ => {
351                write!(out, "%{b:02X}").unwrap();
352            }
353        }
354    }
355    out
356}
357
358/// `urllib.parse.urlencode(mapping)` over string pairs — `quote_plus` on
359/// each key and value, pairs joined with `&` in the given order.
360pub fn quote_plus_urlencode(pairs: &[(&str, &str)]) -> String {
361    pairs
362        .iter()
363        .map(|(k, v)| format!("{}={}", quote_plus(k), quote_plus(v)))
364        .collect::<Vec<_>>()
365        .join("&")
366}
367
368// ---------------------------------------------------------------------------
369// repr(float)
370// ---------------------------------------------------------------------------
371
372/// CPython `repr(float)`: shortest round-trip decimal digits (David Gay
373/// dtoa mode-0 semantics: among round-tripping candidates prefer the one
374/// nearest the exact binary value, ties to even), fixed notation when the
375/// decimal point position is in (-4, 16], otherwise exponent form with
376/// lowercase `e`, mandatory sign, and a minimum two-digit exponent.
377/// Integral in-range floats keep a `.0` suffix. `inf` / `-inf` / `nan`.
378///
379/// NOTE: Rust's own `{}`/`{:e}` shortest formatter is NOT equivalent — it
380/// diverges from CPython on exact-tie mantissas (e.g. the double nearest
381/// -101065508335255.125 reprs as `...12` in CPython, `...13` via `{:e}`),
382/// hence the exact-decimal search below.
383pub fn py_float_repr(x: f64) -> String {
384    if x.is_nan() {
385        return "nan".to_string();
386    }
387    if x.is_infinite() {
388        return if x > 0.0 { "inf" } else { "-inf" }.to_string();
389    }
390    let neg = x.is_sign_negative();
391    let sign = if neg { "-" } else { "" };
392    if x == 0.0 {
393        return format!("{sign}0.0");
394    }
395    let ax = x.abs();
396    let (n, k) = exact_decimal(ax);
397    let exact_decpt = n.len() as i64 - k; // value == 0.<n> * 10^exact_decpt
398    let (digits, decpt) = shortest_digits(ax, &n, exact_decpt);
399    if decpt <= -4 || decpt > 16 {
400        // Scientific form d[.ddd]e±XX
401        let e10 = decpt - 1;
402        let mantissa = if digits.len() > 1 {
403            format!("{}.{}", &digits[..1], &digits[1..])
404        } else {
405            digits.clone()
406        };
407        let (esign, eabs) = if e10 < 0 { ('-', -e10) } else { ('+', e10) };
408        format!("{sign}{mantissa}e{esign}{eabs:02}")
409    } else if decpt <= 0 {
410        format!("{sign}0.{}{}", "0".repeat((-decpt) as usize), digits)
411    } else if (decpt as usize) >= digits.len() {
412        format!(
413            "{sign}{}{}.0",
414            digits,
415            "0".repeat(decpt as usize - digits.len())
416        )
417    } else {
418        let d = decpt as usize;
419        format!("{sign}{}.{}", &digits[..d], &digits[d..])
420    }
421}
422
423/// Compare the removed digit string `r` against half a unit in the last
424/// kept place (i.e. against `5` followed by zeros).
425fn cmp_rem_half(r: &str) -> std::cmp::Ordering {
426    let first = r.as_bytes()[0];
427    if first > b'5' {
428        std::cmp::Ordering::Greater
429    } else if first < b'5' {
430        std::cmp::Ordering::Less
431    } else if r[1..].bytes().all(|b| b == b'0') {
432        std::cmp::Ordering::Equal
433    } else {
434        std::cmp::Ordering::Greater
435    }
436}
437
438fn strip_trailing_zeros(d: &str) -> &str {
439    let end = d.trim_end_matches('0');
440    if end.is_empty() {
441        &d[..1]
442    } else {
443        end
444    }
445}
446
447/// Does `0.<digits> * 10^decpt` parse back to exactly `x`?
448fn roundtrips(digits: &str, decpt: i64, x_bits: u64) -> bool {
449    let e = decpt - digits.len() as i64;
450    let text = format!("{digits}e{e}");
451    text.parse::<f64>().map(|v| v.to_bits()) == Ok(x_bits)
452}
453
454/// Shortest round-trip digits for positive finite `x`, given its exact
455/// decimal expansion `n` (digit string) and decimal point position
456/// `decpt` (value == 0.<n> * 10^decpt). Among the two d-digit candidates
457/// (truncation and its increment) prefers whichever round-trips; when both
458/// do, the nearer to the exact value wins, exact ties to even — matching
459/// CPython's `_Py_dg_dtoa` mode 0.
460fn shortest_digits(x: f64, n: &str, decpt: i64) -> (String, i64) {
461    let x_bits = x.to_bits();
462    for d in 1..=17usize {
463        if d >= n.len() {
464            // The exact expansion itself fits in d digits: it is the value.
465            return (strip_trailing_zeros(n).to_string(), decpt);
466        }
467        let lo = &n[..d];
468        let rem = &n[d..];
469        let hi_full = inc_decimal(lo);
470        let (hi, hi_decpt) = if hi_full.len() > d {
471            // Carry out of the top digit (999… -> 1000…): keep d digits,
472            // shift the decimal point. The truncation is exact (1 then 0s).
473            (hi_full[..d].to_string(), decpt + 1)
474        } else {
475            (hi_full, decpt)
476        };
477        let lo_ok = roundtrips(lo, decpt, x_bits);
478        let hi_ok = roundtrips(&hi, hi_decpt, x_bits);
479        match (lo_ok, hi_ok) {
480            (true, false) => return (strip_trailing_zeros(lo).to_string(), decpt),
481            (false, true) => return (strip_trailing_zeros(&hi).to_string(), hi_decpt),
482            (true, true) => {
483                let pick_hi = match cmp_rem_half(rem) {
484                    std::cmp::Ordering::Greater => true,
485                    std::cmp::Ordering::Less => false,
486                    std::cmp::Ordering::Equal => {
487                        (lo.as_bytes()[d - 1] - b'0') % 2 == 1
488                    }
489                };
490                return if pick_hi {
491                    (strip_trailing_zeros(&hi).to_string(), hi_decpt)
492                } else {
493                    (strip_trailing_zeros(lo).to_string(), decpt)
494                };
495            }
496            (false, false) => continue,
497        }
498    }
499    unreachable!("17 significant digits always round-trip a double")
500}
501
502// ---------------------------------------------------------------------------
503// Exact decimal expansion (minimal big-integer) — the engine behind
504// py_float_repr, py_round, and the fixed-point format helpers.
505// ---------------------------------------------------------------------------
506
507fn big_mul_small(v: &mut Vec<u64>, m: u64) {
508    let mut carry: u128 = 0;
509    for limb in v.iter_mut() {
510        let p = (*limb as u128) * (m as u128) + carry;
511        *limb = p as u64;
512        carry = p >> 64;
513    }
514    while carry > 0 {
515        v.push(carry as u64);
516        carry >>= 64;
517    }
518}
519
520fn big_shl(v: &mut Vec<u64>, bits: u64) {
521    let words = (bits / 64) as usize;
522    let rem = bits % 64;
523    if rem > 0 {
524        let mut carry: u64 = 0;
525        for limb in v.iter_mut() {
526            let new = (*limb << rem) | carry;
527            carry = *limb >> (64 - rem);
528            *limb = new;
529        }
530        if carry > 0 {
531            v.push(carry);
532        }
533    }
534    if words > 0 {
535        let mut shifted = vec![0u64; words];
536        shifted.append(v);
537        *v = shifted;
538    }
539}
540
541fn big_divmod_small(v: &mut Vec<u64>, d: u64) -> u64 {
542    let mut rem: u128 = 0;
543    for limb in v.iter_mut().rev() {
544        let cur = (rem << 64) | (*limb as u128);
545        *limb = (cur / d as u128) as u64;
546        rem = cur % d as u128;
547    }
548    while v.len() > 1 && *v.last().unwrap() == 0 {
549        v.pop();
550    }
551    rem as u64
552}
553
554fn big_is_zero(v: &[u64]) -> bool {
555    v.iter().all(|&l| l == 0)
556}
557
558fn big_to_decimal(mut v: Vec<u64>) -> String {
559    const CHUNK: u64 = 10_000_000_000_000_000_000; // 10^19
560    let mut chunks: Vec<u64> = Vec::new();
561    loop {
562        let r = big_divmod_small(&mut v, CHUNK);
563        chunks.push(r);
564        if big_is_zero(&v) {
565            break;
566        }
567    }
568    let mut out = chunks.pop().unwrap().to_string();
569    for c in chunks.iter().rev() {
570        out.push_str(&format!("{c:019}"));
571    }
572    out
573}
574
575/// Exact decimal expansion of a finite |x|: returns (digit string of N, k)
576/// such that |x| == N * 10^-k exactly, with k >= 0. Zero => ("0", 0).
577fn exact_decimal(x: f64) -> (String, i64) {
578    debug_assert!(x.is_finite() && x >= 0.0);
579    let bits = x.to_bits();
580    let exp_biased = ((bits >> 52) & 0x7ff) as i64;
581    let frac = bits & ((1u64 << 52) - 1);
582    let (m, e) = if exp_biased == 0 {
583        (frac, -1074i64)
584    } else {
585        (frac | (1u64 << 52), exp_biased - 1075)
586    };
587    if m == 0 {
588        return ("0".to_string(), 0);
589    }
590    let mut v = vec![m];
591    if e >= 0 {
592        big_shl(&mut v, e as u64);
593        (big_to_decimal(v), 0)
594    } else {
595        // x = m / 2^k = (m * 5^k) / 10^k
596        let k = -e;
597        const POW5_27: u64 = 7_450_580_596_923_828_125; // 5^27
598        let mut rem = k;
599        while rem >= 27 {
600            big_mul_small(&mut v, POW5_27);
601            rem -= 27;
602        }
603        if rem > 0 {
604            big_mul_small(&mut v, 5u64.pow(rem as u32));
605        }
606        (big_to_decimal(v), k)
607    }
608}
609
610fn inc_decimal(q: &str) -> String {
611    let mut digits: Vec<u8> = q.bytes().collect();
612    for d in digits.iter_mut().rev() {
613        if *d == b'9' {
614            *d = b'0';
615        } else {
616            *d += 1;
617            return String::from_utf8(digits).unwrap();
618        }
619    }
620    let mut out = String::with_capacity(digits.len() + 1);
621    out.push('1');
622    out.push_str(std::str::from_utf8(&digits).unwrap());
623    out
624}
625
626/// Drop `drop` digits (> 0) from the end of decimal string `n`, rounding
627/// half-to-even against the exact removed remainder.
628fn round_decimal_half_even(n: &str, drop: usize) -> String {
629    if drop > n.len() {
630        // Removed part starts with a leading zero pad => strictly < half.
631        return "0".to_string();
632    }
633    let (q, r) = n.split_at(n.len() - drop);
634    let q = if q.is_empty() { "0" } else { q };
635    match cmp_rem_half(r) {
636        std::cmp::Ordering::Less => q.to_string(),
637        std::cmp::Ordering::Greater => inc_decimal(q),
638        std::cmp::Ordering::Equal => {
639            let last = q.as_bytes()[q.len() - 1];
640            if (last - b'0') % 2 == 1 {
641                inc_decimal(q)
642            } else {
643                q.to_string()
644            }
645        }
646    }
647}
648
649/// CPython `round(x, ndigits)`: correct decimal rounding, half-to-even, of
650/// the exact binary value (David Gay dtoa semantics). Non-finite and zero
651/// inputs return unchanged; a magnitude overflow (CPython raises
652/// `OverflowError`) saturates to infinity here.
653pub fn py_round(x: f64, ndigits: i32) -> f64 {
654    if !x.is_finite() || x == 0.0 {
655        return x;
656    }
657    let neg = x < 0.0;
658    let (n, k) = exact_decimal(x.abs());
659    let nd = ndigits as i64;
660    if nd >= k {
661        return x; // already exact at (or beyond) the requested precision
662    }
663    let drop = k - nd;
664    if drop > n.len() as i64 {
665        return if neg { -0.0 } else { 0.0 };
666    }
667    let q = round_decimal_half_even(&n, drop as usize);
668    let text = format!("{}{}e{}", if neg { "-" } else { "" }, q, -nd);
669    text.parse::<f64>().expect("decimal string parses")
670}
671
672/// Exact fixed-point formatting with `nd` fractional digits, half-to-even
673/// on the true binary value (the core of Python's `.Nf` format spec).
674fn py_fixed(x: f64, nd: usize) -> String {
675    if x.is_nan() {
676        return "nan".to_string();
677    }
678    if x.is_infinite() {
679        return if x > 0.0 { "inf" } else { "-inf" }.to_string();
680    }
681    let sign = if x.is_sign_negative() { "-" } else { "" };
682    let (n, k) = exact_decimal(x.abs());
683    let mut q = if nd as i64 >= k {
684        let mut s = n;
685        s.push_str(&"0".repeat((nd as i64 - k) as usize));
686        s
687    } else {
688        let drop = k - nd as i64;
689        if drop > n.len() as i64 {
690            "0".to_string()
691        } else {
692            round_decimal_half_even(&n, drop as usize)
693        }
694    };
695    if q.len() < nd + 1 {
696        q = format!("{}{}", "0".repeat(nd + 1 - q.len()), q);
697    }
698    if nd == 0 {
699        format!("{sign}{q}")
700    } else {
701        let split = q.len() - nd;
702        format!("{sign}{}.{}", &q[..split], &q[split..])
703    }
704}
705
706/// Python `f"{x:.1f}"` — one fixed decimal, round-half-to-even on the exact
707/// binary value.
708pub fn py_format_1f(x: f64) -> String {
709    py_fixed(x, 1)
710}
711
712/// Python `f"{x:.<nd>f}"` for an arbitrary decimal count (eval's `.3f`,
713/// `.6f`, and `.0f` report/gate formats).
714pub fn py_format_fixed(x: f64, nd: usize) -> String {
715    py_fixed(x, nd)
716}
717
718/// Python `f"{x:.0%}"` — multiplies by 100 in binary (a rounding f64
719/// multiply, exactly as CPython does), formats with zero decimals
720/// half-to-even, appends `%`.
721pub fn py_format_percent0(x: f64) -> String {
722    if x.is_nan() {
723        return "nan%".to_string();
724    }
725    if x.is_infinite() {
726        return if x > 0.0 { "inf%" } else { "-inf%" }.to_string();
727    }
728    let mut s = py_fixed(x * 100.0, 0);
729    s.push('%');
730    s
731}
732
733// ---------------------------------------------------------------------------
734// posixpath — normpath / abspath / relpath (the OKF bundle's key derivation)
735// ---------------------------------------------------------------------------
736
737/// Python `posixpath.normpath(path)`: collapse duplicate slashes and `.`
738/// components, resolve `..` lexically (kept when it would climb past a
739/// relative start; dropped at an absolute root), preserve an exactly-double
740/// leading slash. Empty input yields `.`.
741pub fn py_normpath(path: &str) -> String {
742    if path.is_empty() {
743        return ".".to_string();
744    }
745    let initial_slashes = if path.starts_with('/') {
746        if path.starts_with("//") && !path.starts_with("///") {
747            2
748        } else {
749            1
750        }
751    } else {
752        0
753    };
754    let mut comps: Vec<&str> = Vec::new();
755    for comp in path.split('/') {
756        if comp.is_empty() || comp == "." {
757            continue;
758        }
759        if comp != ".."
760            || (initial_slashes == 0 && comps.is_empty())
761            || comps.last() == Some(&"..")
762        {
763            comps.push(comp);
764        } else if !comps.is_empty() {
765            comps.pop();
766        }
767    }
768    let mut out = "/".repeat(initial_slashes);
769    out.push_str(&comps.join("/"));
770    if out.is_empty() {
771        ".".to_string()
772    } else {
773        out
774    }
775}
776
777/// Python `posixpath.abspath(path)` — `normpath(join(cwd, path))`, lexical
778/// (never touches the filesystem beyond reading the cwd).
779pub fn py_abspath(path: &str) -> String {
780    if path.starts_with('/') {
781        return py_normpath(path);
782    }
783    let cwd = std::env::current_dir()
784        .map(|p| p.to_string_lossy().into_owned())
785        .unwrap_or_else(|_| ".".to_string());
786    py_normpath(&format!("{cwd}/{path}"))
787}
788
789/// Python `os.path.relpath(path, start)` on POSIX: both sides are
790/// `abspath`'d lexically, then the relative walk is derived from the
791/// component lists. Returns `.` when they coincide.
792pub fn py_relpath(path: &str, start: &str) -> String {
793    let path_abs = py_abspath(path);
794    let start_abs = py_abspath(start);
795    let path_list: Vec<&str> = path_abs.split('/').filter(|c| !c.is_empty()).collect();
796    let start_list: Vec<&str> = start_abs.split('/').filter(|c| !c.is_empty()).collect();
797    let common = path_list
798        .iter()
799        .zip(start_list.iter())
800        .take_while(|(a, b)| a == b)
801        .count();
802    let mut rel: Vec<&str> = Vec::new();
803    rel.resize(start_list.len() - common, "..");
804    rel.extend(&path_list[common..]);
805    if rel.is_empty() {
806        ".".to_string()
807    } else {
808        rel.join("/")
809    }
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    #[test]
817    fn casefold_basics() {
818        assert_eq!(py_casefold("Straße"), "strasse");
819        assert_eq!(py_casefold("ABC"), "abc");
820    }
821
822    #[test]
823    fn strip_python_whitespace() {
824        assert_eq!(py_strip("\u{1c}\u{a0} x \t"), "x");
825        assert_eq!(py_strip("\u{feff}x\u{200b}"), "\u{feff}x\u{200b}");
826    }
827
828    #[test]
829    fn splitlines_crlf() {
830        assert_eq!(py_splitlines("a\r\nb\rc\nd\n"), vec!["a", "b", "c", "d"]);
831        assert_eq!(py_splitlines(""), Vec::<&str>::new());
832    }
833
834    #[test]
835    fn repr_quote_flip() {
836        assert_eq!(py_repr_str("it's"), "\"it's\"");
837        assert_eq!(py_repr_str("both '\""), "'both \\'\"'");
838        assert_eq!(py_repr_str("café"), "'café'");
839        assert_eq!(py_repr_str("\u{7f}"), "'\\x7f'");
840    }
841
842    #[test]
843    fn float_repr_shapes() {
844        assert_eq!(py_float_repr(1e16), "1e+16");
845        assert_eq!(py_float_repr(1e-5), "1e-05");
846        assert_eq!(py_float_repr(0.0001), "0.0001");
847        assert_eq!(py_float_repr(100.0), "100.0");
848        assert_eq!(py_float_repr(-0.0), "-0.0");
849        assert_eq!(py_float_repr(5e-324), "5e-324");
850    }
851
852    #[test]
853    fn round_half_even_exact() {
854        assert_eq!(py_round(2.675, 2), 2.67);
855        assert_eq!(py_round(0.125, 2), 0.12);
856        assert_eq!(py_round(2.5, 0), 2.0);
857        assert!(py_round(-0.4, 0) == 0.0 && py_round(-0.4, 0).is_sign_negative());
858    }
859
860    #[test]
861    fn format_helpers() {
862        assert_eq!(py_format_1f(0.25), "0.2");
863        assert_eq!(py_format_1f(-0.04), "-0.0");
864        assert_eq!(py_format_percent0(0.855), "86%");
865    }
866
867    /// stdin surrogateescape decode: each undecodable byte becomes one
868    /// sentinel char, exactly as CPython maps each bad byte to U+DC00+b.
869    /// NOTE: tests only ever turn the
870    /// process-wide sentinel flag ON (never off) so parallel test threads
871    /// cannot race each other; non-sentinel chars are unaffected by it.
872    #[test]
873    fn stdin_surrogateescape_decode_and_reencode() {
874        let cases: &[(&[u8], &[u32])] = &[
875            (b"abc", &[0x61, 0x62, 0x63]),
876            (b"---\n\xcc\n---", &[0x2d, 0x2d, 0x2d, 0x0a, 0x10FCCC, 0x0a, 0x2d, 0x2d, 0x2d]),
877            (b"\xc3\x28", &[0x10FCC3, 0x28]),               // bad continuation
878            (b"\xf0\x9f\x98", &[0x10FCF0, 0x10FC9F, 0x10FC98]), // truncated 4-byte
879            (b"\xed\xa0\x80", &[0x10FCED, 0x10FCA0, 0x10FC80]), // encoded surrogate
880            (b"\xc0\xaf", &[0x10FCC0, 0x10FCAF]),           // overlong
881            (b"\xc3\xa9", &[0xE9]),                         // valid 2-byte passes
882        ];
883        for (bytes, chars) in cases {
884            let s = decode_stdin_surrogateescape(bytes);
885            let got: Vec<u32> = s.chars().map(|c| c as u32).collect();
886            assert_eq!(&got, chars, "decode of {bytes:?}");
887            // round-trip: stdout re-encoding restores the original bytes
888            assert_eq!(
889                encode_stdout_surrogateescape(&s).as_ref(),
890                *bytes,
891                "re-encode of {bytes:?}"
892            );
893        }
894        assert!(surrogate_sentinels_active());
895    }
896
897    #[test]
898    fn sentinel_repr_is_lone_surrogate_escape() {
899        set_surrogate_sentinels_active(true);
900        let s = decode_stdin_surrogateescape(b"a\xccb");
901        assert_eq!(py_repr_str(&s), "'a\\udcccb'"); // Python repr('a\udcccb')
902    }
903
904    #[test]
905    fn normpath_contract_examples() {
906        // Pinned against CPython posixpath.normpath.
907        assert_eq!(py_normpath(""), ".");
908        assert_eq!(py_normpath("a//b/./c/"), "a/b/c");
909        assert_eq!(py_normpath("a/b/../c"), "a/c");
910        assert_eq!(py_normpath("../a"), "../a");
911        assert_eq!(py_normpath("a/../../b"), "../b");
912        assert_eq!(py_normpath("/../a"), "/a");
913        assert_eq!(py_normpath("//a/b"), "//a/b");
914        assert_eq!(py_normpath("///a/b"), "/a/b");
915        assert_eq!(py_normpath("/"), "/");
916    }
917
918    #[test]
919    fn relpath_contract_examples() {
920        // Absolute inputs keep relpath cwd-independent in the test.
921        assert_eq!(py_relpath("/x/decisions/decisions/a.md", "/x/decisions"), "decisions/a.md");
922        assert_eq!(py_relpath("/x/decisions", "/x/decisions"), ".");
923        assert_eq!(py_relpath("/x/other/a.md", "/x/decisions"), "../other/a.md");
924        assert_eq!(py_relpath("/x/decisions/a.md", "/x/decisions/"), "a.md");
925    }
926}