Skip to main content

xil_core/
pyjson.rs

1//! `json.dumps` the way CPython writes it, so files and stdout compare byte
2//! for byte with the Python pipeline.
3//!
4//! Covers what the pipeline uses: `indent=2` and compact form, `ensure_ascii`
5//! on and off, Python float `repr`, insertion-ordered objects (serde_json is
6//! built with `preserve_order`).
7
8use std::fmt::Write;
9
10use serde_json::Value;
11
12/// How to render.
13#[derive(Clone, Copy, Debug)]
14pub struct Style {
15    /// `indent=2` when `Some(2)`, compact (`", "` / `": "`) when `None`.
16    pub indent: Option<usize>,
17    /// Escape every non-ASCII character as `\uXXXX` (Python's default).
18    pub ensure_ascii: bool,
19}
20
21impl Style {
22    /// `json.dumps(obj, indent=2)`
23    pub const INDENT2: Style = Style {
24        indent: Some(2),
25        ensure_ascii: true,
26    };
27    /// `json.dumps(obj, indent=2, ensure_ascii=False)`
28    pub const INDENT2_UTF8: Style = Style {
29        indent: Some(2),
30        ensure_ascii: false,
31    };
32    /// `json.dumps(obj)`
33    pub const COMPACT: Style = Style {
34        indent: None,
35        ensure_ascii: true,
36    };
37    /// `json.dumps(obj, ensure_ascii=False)`
38    pub const COMPACT_UTF8: Style = Style {
39        indent: None,
40        ensure_ascii: false,
41    };
42}
43
44/// Marker prefix for a value that must be emitted verbatim, unquoted.
45///
46/// Python writes non-finite floats as the bare tokens `NaN`, `Infinity`
47/// and `-Infinity`. JSON has no syntax for them and `serde_json::Number`
48/// cannot hold them, so they travel as strings carrying this prefix. A
49/// NUL cannot occur in the data this serializes, which is what makes it
50/// safe as a sentinel.
51pub const RAW_MARKER: char = '\u{0}';
52
53/// Wrap a float the way Python's json module would write it: an ordinary
54/// number when finite, a bare token when not.
55pub fn py_float(v: f64) -> Value {
56    if v.is_finite() {
57        return serde_json::Number::from_f64(v)
58            .map(Value::Number)
59            .unwrap_or(Value::Null);
60    }
61    let token = if v.is_nan() {
62        "NaN"
63    } else if v > 0.0 {
64        "Infinity"
65    } else {
66        "-Infinity"
67    };
68    Value::String(format!("{RAW_MARKER}{token}"))
69}
70
71/// Serialize `v` in the given style. Never fails: every `Value` is representable.
72pub fn dumps(v: &Value, style: Style) -> String {
73    let mut out = String::new();
74    write_value(&mut out, v, style, 0);
75    out
76}
77
78fn write_value(out: &mut String, v: &Value, style: Style, depth: usize) {
79    match v {
80        Value::Null => out.push_str("null"),
81        Value::Bool(true) => out.push_str("true"),
82        Value::Bool(false) => out.push_str("false"),
83        Value::Number(n) => {
84            if let Some(i) = n.as_i64() {
85                let _ = write!(out, "{i}");
86            } else if let Some(u) = n.as_u64() {
87                let _ = write!(out, "{u}");
88            } else if let Some(f) = n.as_f64() {
89                out.push_str(&float_repr(f));
90            }
91        }
92        Value::String(s) => match s.strip_prefix(RAW_MARKER) {
93            Some(token) => out.push_str(token),
94            None => write_string(out, s, style.ensure_ascii),
95        },
96        Value::Array(items) => {
97            if items.is_empty() {
98                out.push_str("[]");
99                return;
100            }
101            out.push('[');
102            for (i, item) in items.iter().enumerate() {
103                if i > 0 {
104                    out.push(',');
105                }
106                newline(out, style, depth + 1);
107                write_value(out, item, style, depth + 1);
108            }
109            newline(out, style, depth);
110            out.push(']');
111        }
112        Value::Object(map) => {
113            if map.is_empty() {
114                out.push_str("{}");
115                return;
116            }
117            out.push('{');
118            for (i, (k, item)) in map.iter().enumerate() {
119                if i > 0 {
120                    out.push(',');
121                }
122                newline(out, style, depth + 1);
123                write_string(out, k, style.ensure_ascii);
124                out.push_str(": ");
125                write_value(out, item, style, depth + 1);
126            }
127            newline(out, style, depth);
128            out.push('}');
129        }
130    }
131}
132
133/// Between items: a newline plus indentation when indenting, a single space
134/// after the comma in compact mode (Python's default `", "` separator).
135fn newline(out: &mut String, style: Style, depth: usize) {
136    match style.indent {
137        Some(n) => {
138            out.push('\n');
139            for _ in 0..depth * n {
140                out.push(' ');
141            }
142        }
143        None => {
144            // Compact: `[1, 2]` — the space goes after the comma, but not
145            // before the first item or the closing bracket.
146            if out.ends_with(',') {
147                out.push(' ');
148            }
149        }
150    }
151}
152
153/// Python's string escaping: `"` `\` and the C escapes, other control
154/// characters as `\u00XX`; with `ensure_ascii`, everything above U+007F as
155/// `\uXXXX` (surrogate pairs beyond the BMP). `/` is never escaped.
156pub fn write_string(out: &mut String, s: &str, ensure_ascii: bool) {
157    out.push('"');
158    for c in s.chars() {
159        match c {
160            '"' => out.push_str("\\\""),
161            '\\' => out.push_str("\\\\"),
162            '\n' => out.push_str("\\n"),
163            '\r' => out.push_str("\\r"),
164            '\t' => out.push_str("\\t"),
165            '\u{08}' => out.push_str("\\b"),
166            '\u{0c}' => out.push_str("\\f"),
167            c if (c as u32) < 0x20 => {
168                let _ = write!(out, "\\u{:04x}", c as u32);
169            }
170            c if ensure_ascii && (c as u32) > 0x7f => {
171                let cp = c as u32;
172                if cp > 0xffff {
173                    let v = cp - 0x10000;
174                    let hi = 0xd800 + (v >> 10);
175                    let lo = 0xdc00 + (v & 0x3ff);
176                    let _ = write!(out, "\\u{hi:04x}\\u{lo:04x}");
177                } else {
178                    let _ = write!(out, "\\u{cp:04x}");
179                }
180            }
181            c => out.push(c),
182        }
183    }
184    out.push('"');
185}
186
187/// `repr(float)`: shortest round-trip digits, fixed notation when the
188/// decimal exponent is in `-4..16`, otherwise `d.ddde±XX` with a two-digit
189/// minimum exponent. Integral values keep a `.0`.
190pub fn float_repr(f: f64) -> String {
191    if f.is_nan() {
192        return "NaN".to_string();
193    }
194    if f.is_infinite() {
195        return if f > 0.0 { "Infinity" } else { "-Infinity" }.to_string();
196    }
197    if f == 0.0 {
198        return if f.is_sign_negative() { "-0.0" } else { "0.0" }.to_string();
199    }
200    // Rust's `{:e}` is shortest-round-trip: "1.5e-5", "1e16", "-2.5e-7".
201    let sci = format!("{f:e}");
202    let (mant, exp) = sci
203        .split_once('e')
204        .expect("LowerExp always has an exponent");
205    let exp: i32 = exp.parse().expect("exponent is an integer");
206    let (neg, mant) = match mant.strip_prefix('-') {
207        Some(m) => (true, m),
208        None => (false, mant),
209    };
210    let digits: String = mant.chars().filter(|c| *c != '.').collect();
211    let mut out = String::new();
212    if neg {
213        out.push('-');
214    }
215    if (-4..16).contains(&exp) {
216        // Fixed notation. `digits` has the decimal point after the first digit.
217        let point = exp + 1; // digits before the decimal point
218        if point <= 0 {
219            out.push_str("0.");
220            for _ in 0..(-point) {
221                out.push('0');
222            }
223            out.push_str(&digits);
224        } else if (point as usize) >= digits.len() {
225            out.push_str(&digits);
226            for _ in 0..(point as usize - digits.len()) {
227                out.push('0');
228            }
229            out.push_str(".0");
230        } else {
231            out.push_str(&digits[..point as usize]);
232            out.push('.');
233            out.push_str(&digits[point as usize..]);
234        }
235    } else {
236        out.push_str(&digits[..1]);
237        if digits.len() > 1 {
238            out.push('.');
239            out.push_str(&digits[1..]);
240        }
241        let _ = write!(out, "e{}{:02}", if exp < 0 { '-' } else { '+' }, exp.abs());
242    }
243    out
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use serde_json::json;
250
251    #[test]
252    fn float_repr_matches_python() {
253        let cases = [
254            (1e-4, "0.0001"),
255            (1e-5, "1e-05"),
256            (1e15, "1000000000000000.0"),
257            (1e16, "1e+16"),
258            (0.000123456, "0.000123456"),
259            (1234567890123456.0, "1234567890123456.0"),
260            (12345678901234567.0, "1.2345678901234568e+16"),
261            (-0.0, "-0.0"),
262            (2.5e-7, "2.5e-07"),
263            (5.0, "5.0"),
264            (0.3, "0.3"),
265            (0.1, "0.1"),
266            (100.0, "100.0"),
267            (-1.5, "-1.5"),
268            (123.456e-7, "1.23456e-05"),
269        ];
270        for (f, want) in cases {
271            assert_eq!(float_repr(f), want, "repr({f})");
272        }
273    }
274
275    #[test]
276    fn indent2_layout_matches_python() {
277        let v = json!({"a": [1, 2.0, "x"], "e": {}, "l": [], "n": null, "b": true});
278        let want = "{\n  \"a\": [\n    1,\n    2.0,\n    \"x\"\n  ],\n  \"e\": {},\n  \"l\": [],\n  \"n\": null,\n  \"b\": true\n}";
279        assert_eq!(dumps(&v, Style::INDENT2), want);
280    }
281
282    #[test]
283    fn compact_uses_python_separators() {
284        let v = json!({"a": [1, 2], "b": "c"});
285        assert_eq!(dumps(&v, Style::COMPACT), r#"{"a": [1, 2], "b": "c"}"#);
286        assert_eq!(dumps(&json!([]), Style::COMPACT), "[]");
287        assert_eq!(dumps(&json!({}), Style::COMPACT), "{}");
288    }
289
290    #[test]
291    fn non_finite_floats_are_bare_tokens() {
292        let v =
293            json!({"a": py_float(f64::NEG_INFINITY), "b": py_float(1.5), "c": py_float(f64::NAN)});
294        assert_eq!(
295            dumps(&v, Style::COMPACT),
296            r#"{"a": -Infinity, "b": 1.5, "c": NaN}"#
297        );
298        assert_eq!(dumps(&py_float(f64::INFINITY), Style::COMPACT), "Infinity");
299    }
300
301    #[test]
302    fn ensure_ascii_escapes_like_python() {
303        let v = json!(["é", "—", "😀", "tab\there", "q\"", "bs\\", "\u{01}", "a/b"]);
304        assert_eq!(
305            dumps(&v, Style::COMPACT),
306            "[\"\\u00e9\", \"\\u2014\", \"\\ud83d\\ude00\", \"tab\\there\", \"q\\\"\", \"bs\\\\\", \"\\u0001\", \"a/b\"]"
307        );
308        assert_eq!(
309            dumps(&v, Style::COMPACT_UTF8),
310            "[\"\u{e9}\", \"\u{2014}\", \"\u{1F600}\", \"tab\\there\", \"q\\\"\", \"bs\\\\\", \"\\u0001\", \"a/b\"]"
311        );
312    }
313}