Skip to main content

kime_core/
pyjson.rs

1//! Python's `json.dumps`, byte for byte, for the values a request can hold.
2//!
3//! Laya renders structured states, criteria and instructions with `json.dumps`, and the compat
4//! models were trained on exactly that text. A different float spelling (`1e-5` against Python's
5//! `1e-05`) or a different escape (`\u00e9` against `é`) changes the token ids, so this writer copies
6//! Python's choices rather than serde_json's.
7//!
8//! Integers larger than `u64` arrive from serde_json as floats and print as floats, where Python
9//! would print them exactly. No real request has hit that yet.
10
11use serde_json::Value;
12
13/// The options Laya uses. Every call site in Laya keeps Python's default separators, `", "` and
14/// `": "`, so only `ensure_ascii` varies.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct Dumps {
17    /// Escape everything outside printable ASCII as `\uXXXX`, as `json.dumps` does by default.
18    pub ensure_ascii: bool,
19}
20
21impl Dumps {
22    /// `json.dumps(value, ensure_ascii=...)`.
23    #[must_use]
24    pub fn to_string(self, value: &Value) -> String {
25        let mut out = String::new();
26        self.write(value, &mut out);
27        out
28    }
29
30    /// Appends `json.dumps(value)` to `out`.
31    pub fn write(self, value: &Value, out: &mut String) {
32        match value {
33            Value::Null => out.push_str("null"),
34            Value::Bool(true) => out.push_str("true"),
35            Value::Bool(false) => out.push_str("false"),
36            Value::Number(n) => {
37                if let Some(i) = n.as_i64() {
38                    out.push_str(&i.to_string());
39                } else if let Some(u) = n.as_u64() {
40                    out.push_str(&u.to_string());
41                } else {
42                    float_repr(n.as_f64().unwrap_or(f64::NAN), out);
43                }
44            }
45            Value::String(s) => self.string(s, out),
46            Value::Array(items) => {
47                out.push('[');
48                for (i, item) in items.iter().enumerate() {
49                    if i > 0 {
50                        out.push_str(", ");
51                    }
52                    self.write(item, out);
53                }
54                out.push(']');
55            }
56            Value::Object(map) => {
57                out.push('{');
58                for (i, (k, v)) in map.iter().enumerate() {
59                    if i > 0 {
60                        out.push_str(", ");
61                    }
62                    self.string(k, out);
63                    out.push_str(": ");
64                    self.write(v, out);
65                }
66                out.push('}');
67            }
68        }
69    }
70
71    fn string(self, s: &str, out: &mut String) {
72        out.push('"');
73        for c in s.chars() {
74            match c {
75                '"' => out.push_str("\\\""),
76                '\\' => out.push_str("\\\\"),
77                '\n' => out.push_str("\\n"),
78                '\r' => out.push_str("\\r"),
79                '\t' => out.push_str("\\t"),
80                '\u{8}' => out.push_str("\\b"),
81                '\u{c}' => out.push_str("\\f"),
82                c if (c as u32) < 0x20 => push_u(out, c as u32),
83                // With ensure_ascii Python escapes everything outside space to tilde, DEL included,
84                // and writes chars above the BMP as a surrogate pair.
85                c if self.ensure_ascii && !(' '..='~').contains(&c) => {
86                    let mut buf = [0u16; 2];
87                    for unit in c.encode_utf16(&mut buf) {
88                        push_u(out, u32::from(*unit));
89                    }
90                }
91                c => out.push(c),
92            }
93        }
94        out.push('"');
95    }
96}
97
98fn push_u(out: &mut String, unit: u32) {
99    use std::fmt::Write;
100    let _ = write!(out, "\\u{unit:04x}");
101}
102
103/// Python's `repr(float)`: the shortest digits that round trip, in fixed notation when the decimal
104/// exponent is from -4 to 15 and in scientific notation otherwise, with a sign and at least two
105/// exponent digits. Integral values keep a `.0`.
106///
107/// # Panics
108///
109/// Never. The expects below hold for every string Rust's `{:e}` writes.
110pub fn float_repr(f: f64, out: &mut String) {
111    if f.is_nan() {
112        out.push_str("NaN");
113        return;
114    }
115    if f.is_infinite() {
116        out.push_str(if f > 0.0 { "Infinity" } else { "-Infinity" });
117        return;
118    }
119    // `{:e}` gives the shortest round trip digits as `d.ddde-x`, which has everything we need.
120    let sci = format!("{f:e}");
121    let (mantissa, exp) = sci.split_once('e').expect("{:e} always has an exponent");
122    let exp: i32 = exp.parse().expect("{:e} writes a decimal exponent");
123    let (neg, mantissa) = match mantissa.strip_prefix('-') {
124        Some(m) => (true, m),
125        None => (false, mantissa),
126    };
127    let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
128    if neg {
129        out.push('-');
130    }
131    if (-4..16).contains(&exp) {
132        if exp < 0 {
133            out.push_str("0.");
134            for _ in 0..(-exp - 1) {
135                out.push('0');
136            }
137            out.push_str(&digits);
138        } else {
139            let point = exp as usize + 1;
140            if digits.len() <= point {
141                out.push_str(&digits);
142                for _ in digits.len()..point {
143                    out.push('0');
144                }
145                out.push_str(".0");
146            } else {
147                out.push_str(&digits[..point]);
148                out.push('.');
149                out.push_str(&digits[point..]);
150            }
151        }
152    } else {
153        out.push_str(&digits[..1]);
154        if digits.len() > 1 {
155            out.push('.');
156            out.push_str(&digits[1..]);
157        }
158        out.push('e');
159        out.push(if exp < 0 { '-' } else { '+' });
160        out.push_str(&format!("{:02}", exp.abs()));
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use serde_json::json;
168
169    fn repr(f: f64) -> String {
170        let mut s = String::new();
171        float_repr(f, &mut s);
172        s
173    }
174
175    #[test]
176    fn floats_print_like_python() {
177        // Each right hand side is what CPython 3.12 prints for repr(float).
178        let cases = [
179            (0.0, "0.0"),
180            (-0.0, "-0.0"),
181            (1.0, "1.0"),
182            (1.5, "1.5"),
183            (0.1, "0.1"),
184            (100.0, "100.0"),
185            (1e-5, "1e-05"),
186            (0.0001, "0.0001"),
187            (0.00012, "0.00012"),
188            (1.5e-7, "1.5e-07"),
189            (1e15, "1000000000000000.0"),
190            (1e16, "1e+16"),
191            (1.2345e16, "1.2345e+16"),
192            (123456789.125, "123456789.125"),
193            (1e100, "1e+100"),
194            (-2.5e-300, "-2.5e-300"),
195            (f64::MAX, "1.7976931348623157e+308"),
196            (5e-324, "5e-324"),
197        ];
198        for (f, want) in cases {
199            assert_eq!(repr(f), want, "{f:e}");
200        }
201    }
202
203    #[test]
204    fn dumps_matches_python() {
205        let v = json!({"b": [1, 2.0, true, null], "é": "a\"b\\c\n\u{1}\u{7f}ü😀", "n": -3});
206        assert_eq!(
207            Dumps { ensure_ascii: false }.to_string(&v),
208            "{\"b\": [1, 2.0, true, null], \"é\": \"a\\\"b\\\\c\\n\\u0001\u{7f}ü😀\", \"n\": -3}"
209        );
210        assert_eq!(
211            Dumps { ensure_ascii: true }.to_string(&v),
212            "{\"b\": [1, 2.0, true, null], \"\\u00e9\": \"a\\\"b\\\\c\\n\\u0001\\u007f\\u00fc\\ud83d\\ude00\", \"n\": -3}"
213        );
214    }
215}