Skip to main content

vivacity_core/
phpjson.rs

1//! JSON re-encoding reproducing PHP's `json_encode($data, 0)` applied to data
2//! coming from `json_decode($json, true)` (the JsonFile::parseJson ->
3//! JsonFile::encode pipeline, Composer 2.10.3, see docs/reference/JsonFile.php).
4//!
5//! Byte-exact semantics:
6//! - compact output (`{"k":v}`), insertion order preserved;
7//! - `/` escaped as `\/`, non-ASCII as lowercase-hex `\uXXXX` (surrogate
8//!   pairs beyond the BMP), controls `\b \f \n \r \t` then `\u00XX`;
9//! - assoc quirk: an empty object becomes `[]`, an object whose keys are
10//!   exactly "0".."n-1" in order becomes an array (PHP lost the
11//!   object/array distinction at decode time);
12//! - shortest round-trip floats (serialize_precision=-1): fixed notation
13//!   without a trailing `.0` for integral values (`1.0` -> `1`), exponential
14//!   `d[.ddd|.0]e±X` outside (-4, 17]; see `encode_double`.
15
16use crate::error::{Error, Result};
17use serde_json::Value;
18
19/// Options mirroring the json_encode flags used by Composer.
20#[derive(Debug, Clone, Copy)]
21pub struct EncodeOptions {
22    pub pretty: bool,
23    pub escape_slashes: bool,
24    pub escape_unicode: bool,
25}
26
27/// Flags 0 (content-hash): compact, slashes and unicode escaped.
28pub const FLAGS_ZERO: EncodeOptions = EncodeOptions {
29    pretty: false,
30    escape_slashes: true,
31    escape_unicode: true,
32};
33
34/// JsonFile default (files written by Composer):
35/// JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE.
36pub const FLAGS_JSONFILE: EncodeOptions = EncodeOptions {
37    pretty: true,
38    escape_slashes: false,
39    escape_unicode: false,
40};
41
42pub fn php_json_encode(value: &Value) -> Result<String> {
43    php_json_encode_with(value, FLAGS_ZERO)
44}
45
46/// Sentinel key: an object reduced to this key encodes as `{}` (an empty
47/// `stdClass` on the PHP side, which the array semantics cannot express).
48pub const STDCLASS_MARKER: &str = "\u{0}stdClass";
49
50/// An empty object that will encode as `{}`.
51pub fn empty_stdclass() -> Value {
52    let mut m = serde_json::Map::new();
53    m.insert(STDCLASS_MARKER.to_owned(), Value::Null);
54    Value::Object(m)
55}
56
57pub fn php_json_encode_with(value: &Value, opts: EncodeOptions) -> Result<String> {
58    let mut out = String::new();
59    encode_into(value, &mut out, opts, 0)?;
60    Ok(out)
61}
62
63fn newline_indent(out: &mut String, level: usize) {
64    out.push('\n');
65    for _ in 0..level {
66        out.push_str("    ");
67    }
68}
69
70fn encode_into(value: &Value, out: &mut String, opts: EncodeOptions, level: usize) -> Result<()> {
71    match value {
72        Value::Null => out.push_str("null"),
73        Value::Bool(true) => out.push_str("true"),
74        Value::Bool(false) => out.push_str("false"),
75        Value::Number(n) => encode_number(n, out)?,
76        Value::String(s) => encode_string_with(s, out, opts),
77        Value::Array(items) => encode_list(items.iter(), out, opts, level)?,
78        Value::Object(map) => {
79            if map.len() == 1 && map.contains_key(STDCLASS_MARKER) {
80                // Empty `new \stdClass` (Locker::fixupJsonDataType): `{}` where
81                // an empty array would give `[]`.
82                out.push_str("{}");
83            } else if is_php_list(map) {
84                encode_list(map.values(), out, opts, level)?;
85            } else {
86                out.push('{');
87                for (i, (key, item)) in map.iter().enumerate() {
88                    if i > 0 {
89                        out.push(',');
90                    }
91                    if opts.pretty {
92                        newline_indent(out, level + 1);
93                    }
94                    encode_string_with(key, out, opts);
95                    out.push(':');
96                    if opts.pretty {
97                        out.push(' ');
98                    }
99                    encode_into(item, out, opts, level + 1)?;
100                }
101                if opts.pretty && !map.is_empty() {
102                    newline_indent(out, level);
103                }
104                out.push('}');
105            }
106        }
107    }
108    Ok(())
109}
110
111fn encode_list<'a>(
112    items: impl ExactSizeIterator<Item = &'a Value>,
113    out: &mut String,
114    opts: EncodeOptions,
115    level: usize,
116) -> Result<()> {
117    let len = items.len();
118    out.push('[');
119    for (i, item) in items.enumerate() {
120        if i > 0 {
121            out.push(',');
122        }
123        if opts.pretty {
124            newline_indent(out, level + 1);
125        }
126        encode_into(item, out, opts, level + 1)?;
127    }
128    if opts.pretty && len > 0 {
129        newline_indent(out, level);
130    }
131    out.push(']');
132    Ok(())
133}
134
135/// After `json_decode(..., true)`, PHP encodes as an array any assoc array
136/// whose keys are exactly 0..n-1 in order (an empty object included).
137fn is_php_list(map: &serde_json::Map<String, Value>) -> bool {
138    map.keys()
139        .enumerate()
140        .all(|(i, k)| k.as_str() == i.to_string())
141}
142
143fn encode_number(n: &serde_json::Number, out: &mut String) -> Result<()> {
144    if let Some(i) = n.as_i64() {
145        out.push_str(&i.to_string());
146    } else if let Some(u) = n.as_u64() {
147        // PHP_INT_MAX == i64::MAX: beyond it, json_decode produces a float.
148        encode_double(u as f64, out)?;
149    } else if let Some(f) = n.as_f64() {
150        encode_double(f, out)?;
151    }
152    Ok(())
153}
154
155/// Double formatting of `json_encode` with serialize_precision=-1:
156/// shortest-round-trip digits, fixed notation iff the decimal point lies in
157/// (-4, 17] (bounds measured empirically on PHP 8.5, cf. differential tests),
158/// else exponential `d[.ddd|.0]e±X`. No `.0` on integral values in fixed.
159/// Shortest significant digits that round-trip, and decimal exponent, like
160/// `zend_gcvt` mode 0 (dtoa). Rust's `{:e}` gives the same string except on
161/// an exact tie between two candidates (the value sits halfway, e.g.
162/// 2124202659384827.25 -> "...27.2" or "...27.3"): dtoa rounds to the even
163/// digit, Rust rounds up. Rust's fixed-precision formatting (exact,
164/// half-to-even) decides like dtoa; we keep it if it still round-trips
165/// (always true away from an asymmetric interval edge).
166fn shortest_digits(a: f64) -> (String, String) {
167    let sci = format!("{:e}", a);
168    let (mantissa, exp) = sci.split_once('e').unwrap_or((sci.as_str(), "0"));
169    let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
170    let exact = format!("{:.*e}", digits.len().saturating_sub(1), a);
171    if exact != sci && exact.parse::<f64>() == Ok(a) {
172        let (m, e) = exact.split_once('e').unwrap_or((exact.as_str(), "0"));
173        let mut d: String = m.chars().filter(|c| *c != '.').collect();
174        while d.len() > 1 && d.ends_with('0') {
175            d.pop();
176        }
177        return (d, e.to_owned());
178    }
179    (digits, exp.to_owned())
180}
181
182/// `smart_str_append_double` with `serialize_precision = -1`: the shortest
183/// round-trip form PHP prints for a float (`json_encode` and `serialize`
184/// share it; the latter spells the exponent `E`).
185pub fn php_double(f: f64) -> Result<String> {
186    let mut out = String::new();
187    encode_double(f, &mut out)?;
188    Ok(out)
189}
190
191fn encode_double(f: f64, out: &mut String) -> Result<()> {
192    if !f.is_finite() {
193        return Err(Error::NonFiniteFloat(f));
194    }
195    if f == 0.0 {
196        out.push_str(if f.is_sign_negative() { "-0" } else { "0" });
197        return Ok(());
198    }
199    if f.is_sign_negative() {
200        out.push('-');
201    }
202    let (digits, exp) = shortest_digits(f.abs());
203    let exp: i32 = exp.parse().map_err(|_| Error::NonFiniteFloat(f))?;
204    let dec_point = exp + 1; // value = 0.digits x 10^dec_point
205
206    if dec_point > -4 && dec_point <= 17 {
207        let n = digits.len() as i32;
208        if dec_point <= 0 {
209            out.push_str("0.");
210            for _ in 0..-dec_point {
211                out.push('0');
212            }
213            out.push_str(&digits);
214        } else if dec_point >= n {
215            out.push_str(&digits);
216            for _ in 0..(dec_point - n) {
217                out.push('0');
218            }
219        } else {
220            out.push_str(&digits[..dec_point as usize]);
221            out.push('.');
222            out.push_str(&digits[dec_point as usize..]);
223        }
224    } else {
225        out.push_str(&digits[..1]);
226        out.push('.');
227        if digits.len() > 1 {
228            out.push_str(&digits[1..]);
229        } else {
230            out.push('0');
231        }
232        let e = dec_point - 1;
233        if e >= 0 {
234            out.push_str("e+");
235        } else {
236            out.push_str("e-");
237        }
238        out.push_str(&e.abs().to_string());
239    }
240    Ok(())
241}
242
243fn encode_string_with(s: &str, out: &mut String, opts: EncodeOptions) {
244    out.push('"');
245    for c in s.chars() {
246        match c {
247            '"' => out.push_str("\\\""),
248            '\\' => out.push_str("\\\\"),
249            '/' if opts.escape_slashes => out.push_str("\\/"),
250            '\u{08}' => out.push_str("\\b"),
251            '\u{0c}' => out.push_str("\\f"),
252            '\n' => out.push_str("\\n"),
253            '\r' => out.push_str("\\r"),
254            '\t' => out.push_str("\\t"),
255            c if (c as u32) < 0x20 => {
256                push_unicode_escape(c as u32, out);
257            }
258            c if c.is_ascii() => out.push(c),
259            // Without JSON_UNESCAPED_LINE_TERMINATORS, json_encode escapes
260            // U+2028/U+2029 even with JSON_UNESCAPED_UNICODE.
261            '\u{2028}' => out.push_str("\\u2028"),
262            '\u{2029}' => out.push_str("\\u2029"),
263            c if !opts.escape_unicode => out.push(c),
264            c => {
265                let cp = c as u32;
266                if cp > 0xFFFF {
267                    // UTF-16 surrogate pair, like json_encode.
268                    let v = cp - 0x10000;
269                    push_unicode_escape(0xD800 + (v >> 10), out);
270                    push_unicode_escape(0xDC00 + (v & 0x3FF), out);
271                } else {
272                    push_unicode_escape(cp, out);
273                }
274            }
275        }
276    }
277    out.push('"');
278}
279
280fn push_unicode_escape(cp: u32, out: &mut String) {
281    use std::fmt::Write as _;
282    // write! on a String is infallible.
283    let _ = write!(out, "\\u{cp:04x}");
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use serde_json::json;
290
291    fn enc(v: Value) -> String {
292        php_json_encode(&v).expect("encodable")
293    }
294
295    #[test]
296    fn compact_object_preserves_order() {
297        let v: Value = serde_json::from_str(r#"{"b":1,"a":{"z":true,"y":null}}"#).unwrap();
298        assert_eq!(enc(v), r#"{"b":1,"a":{"z":true,"y":null}}"#);
299    }
300
301    #[test]
302    fn slash_and_unicode_are_escaped() {
303        assert_eq!(enc(json!("a/b")), r#""a\/b""#);
304        assert_eq!(enc(json!("héhé")), "\"h\\u00e9h\\u00e9\"");
305        assert_eq!(enc(json!("🎼")), "\"\\ud83c\\udfbc\"");
306        assert_eq!(enc(json!("tab\tok")), "\"tab\\tok\"");
307        assert_eq!(enc(json!("\u{1}")), "\"\\u0001\"");
308    }
309
310    #[test]
311    fn empty_object_becomes_array() {
312        let v: Value = serde_json::from_str(r#"{"extra":{}}"#).unwrap();
313        assert_eq!(enc(v), r#"{"extra":[]}"#);
314    }
315
316    #[test]
317    fn sequential_numeric_keys_become_array() {
318        let v: Value = serde_json::from_str(r#"{"0":"a","1":"b"}"#).unwrap();
319        assert_eq!(enc(v), r#"["a","b"]"#);
320        // Non-sequential order -> stays an object.
321        let v: Value = serde_json::from_str(r#"{"1":"a","0":"b"}"#).unwrap();
322        assert_eq!(enc(v), r#"{"1":"a","0":"b"}"#);
323        // Gap in the indices -> stays an object.
324        let v: Value = serde_json::from_str(r#"{"0":"a","2":"b"}"#).unwrap();
325        assert_eq!(enc(v), r#"{"0":"a","2":"b"}"#);
326    }
327
328    #[test]
329    fn numbers_round_trip() {
330        assert_eq!(enc(json!(42)), "42");
331        assert_eq!(enc(json!(-7)), "-7");
332        assert_eq!(enc(json!(1.5)), "1.5");
333        // Empirical PHP boundaries (see encode_double + oracle tests).
334        assert_eq!(enc(json!(1.0)), "1");
335        assert_eq!(enc(json!(1.0e-7)), "1.0e-7");
336        assert_eq!(enc(json!(0.0001)), "0.0001");
337        assert_eq!(enc(json!(1.0e-5)), "1.0e-5");
338        assert_eq!(enc(json!(9.9e16)), "99000000000000000");
339        assert_eq!(enc(json!(1.0e17)), "1.0e+17");
340        assert_eq!(enc(json!(1.23e17)), "1.23e+17");
341        assert_eq!(enc(json!(-0.0)), "-0");
342        assert_eq!(enc(json!(5e-324)), "5.0e-324");
343        assert_eq!(
344            enc(json!(1.7976931348623157e308)),
345            "1.7976931348623157e+308"
346        );
347        assert_eq!(
348            enc(json!(12345678901234567890_u64)),
349            "1.2345678901234567e+19"
350        );
351    }
352}