Skip to main content

toon/cli/
json_stringify.rs

1use std::fmt::Write;
2
3use crate::JsonValue;
4
5/// Stream JSON stringification chunks for a `JsonValue`.
6/// Returns a Vec with a single string (optimized to avoid many small allocations).
7#[must_use]
8pub fn json_stringify_lines(value: &JsonValue, indent: usize) -> Vec<String> {
9    // Estimate size: rough guess based on value complexity
10    let estimated_size = estimate_json_size(value, indent);
11    let mut buf = String::with_capacity(estimated_size);
12    stringify_value_to_buf(value, 0, indent, &mut buf);
13    vec![buf]
14}
15
16/// Estimate the JSON output size for pre-allocation
17fn estimate_json_size(value: &JsonValue, indent: usize) -> usize {
18    match value {
19        JsonValue::Primitive(p) => match p {
20            crate::StringOrNumberOrBoolOrNull::Null => 4,
21            crate::StringOrNumberOrBoolOrNull::Bool(_) => 5,
22            crate::StringOrNumberOrBoolOrNull::Number(_) => 20,
23            crate::StringOrNumberOrBoolOrNull::String(s) => s.len() + 10,
24        },
25        JsonValue::Array(items) => {
26            let base = items
27                .iter()
28                .map(|v| estimate_json_size(v, indent))
29                .sum::<usize>();
30            base + items.len() * (2 + indent) + 4
31        }
32        JsonValue::Object(entries) => {
33            let base: usize = entries
34                .iter()
35                .map(|(k, v)| k.len() + 4 + estimate_json_size(v, indent))
36                .sum();
37            base + entries.len() * (2 + indent) + 4
38        }
39    }
40}
41
42fn stringify_value_to_buf(value: &JsonValue, depth: usize, indent: usize, buf: &mut String) {
43    match value {
44        JsonValue::Primitive(primitive) => {
45            stringify_primitive_to_buf(primitive, buf);
46        }
47        JsonValue::Array(values) => stringify_array_to_buf(values, depth, indent, buf),
48        JsonValue::Object(entries) => stringify_object_to_buf(entries, depth, indent, buf),
49    }
50}
51
52fn stringify_array_to_buf(values: &[JsonValue], depth: usize, indent: usize, buf: &mut String) {
53    if values.is_empty() {
54        buf.push_str("[]");
55        return;
56    }
57
58    buf.push('[');
59
60    if indent > 0 {
61        for (idx, value) in values.iter().enumerate() {
62            buf.push('\n');
63            push_indent(buf, (depth + 1) * indent);
64            stringify_value_to_buf(value, depth + 1, indent, buf);
65            if idx + 1 < values.len() {
66                buf.push(',');
67            }
68        }
69        buf.push('\n');
70        push_indent(buf, depth * indent);
71    } else {
72        for (idx, value) in values.iter().enumerate() {
73            stringify_value_to_buf(value, depth + 1, indent, buf);
74            if idx + 1 < values.len() {
75                buf.push(',');
76            }
77        }
78    }
79    buf.push(']');
80}
81
82fn stringify_object_to_buf(
83    entries: &[(String, JsonValue)],
84    depth: usize,
85    indent: usize,
86    buf: &mut String,
87) {
88    if entries.is_empty() {
89        buf.push_str("{}");
90        return;
91    }
92
93    buf.push('{');
94
95    if indent > 0 {
96        for (idx, (key, value)) in entries.iter().enumerate() {
97            buf.push('\n');
98            push_indent(buf, (depth + 1) * indent);
99            // Escape key inline
100            push_json_string(buf, key);
101            buf.push_str(": ");
102            stringify_value_to_buf(value, depth + 1, indent, buf);
103            if idx + 1 < entries.len() {
104                buf.push(',');
105            }
106        }
107        buf.push('\n');
108        push_indent(buf, depth * indent);
109    } else {
110        for (idx, (key, value)) in entries.iter().enumerate() {
111            push_json_string(buf, key);
112            buf.push(':');
113            stringify_value_to_buf(value, depth + 1, indent, buf);
114            if idx + 1 < entries.len() {
115                buf.push(',');
116            }
117        }
118    }
119    buf.push('}');
120}
121
122fn stringify_primitive_to_buf(value: &crate::JsonPrimitive, buf: &mut String) {
123    match value {
124        crate::StringOrNumberOrBoolOrNull::Null => buf.push_str("null"),
125        crate::StringOrNumberOrBoolOrNull::Bool(true) => buf.push_str("true"),
126        crate::StringOrNumberOrBoolOrNull::Bool(false) => buf.push_str("false"),
127        crate::StringOrNumberOrBoolOrNull::Number(n) => {
128            if let Some(num) = serde_json::Number::from_f64(*n) {
129                buf.push_str(&num.to_string());
130            } else {
131                buf.push_str("null");
132            }
133        }
134        crate::StringOrNumberOrBoolOrNull::String(s) => {
135            push_json_string(buf, s);
136        }
137    }
138}
139
140/// Push spaces for indentation
141#[inline]
142fn push_indent(buf: &mut String, count: usize) {
143    for _ in 0..count {
144        buf.push(' ');
145    }
146}
147
148/// Push a JSON-escaped string (with quotes) directly to buffer
149fn push_json_string(buf: &mut String, s: &str) {
150    buf.push('"');
151    for c in s.chars() {
152        match c {
153            '"' => buf.push_str("\\\""),
154            '\\' => buf.push_str("\\\\"),
155            '\n' => buf.push_str("\\n"),
156            '\r' => buf.push_str("\\r"),
157            '\t' => buf.push_str("\\t"),
158            c if c.is_control() => {
159                // Use \uXXXX format for control characters
160                let _ = write!(buf, "\\u{:04x}", c as u32);
161            }
162            c => buf.push(c),
163        }
164    }
165    buf.push('"');
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::StringOrNumberOrBoolOrNull;
172
173    fn stringify(value: &JsonValue, indent: usize) -> String {
174        let lines = json_stringify_lines(value, indent);
175        assert_eq!(lines.len(), 1);
176        lines.into_iter().next().unwrap()
177    }
178
179    fn s(v: &str) -> JsonValue {
180        JsonValue::Primitive(StringOrNumberOrBoolOrNull::String(v.to_string()))
181    }
182
183    fn n(v: f64) -> JsonValue {
184        JsonValue::Primitive(StringOrNumberOrBoolOrNull::Number(v))
185    }
186
187    fn b(v: bool) -> JsonValue {
188        JsonValue::Primitive(StringOrNumberOrBoolOrNull::Bool(v))
189    }
190
191    fn null() -> JsonValue {
192        JsonValue::Primitive(StringOrNumberOrBoolOrNull::Null)
193    }
194
195    #[test]
196    fn primitive_null() {
197        assert_eq!(stringify(&null(), 0), "null");
198    }
199
200    #[test]
201    fn primitive_booleans() {
202        assert_eq!(stringify(&b(true), 0), "true");
203        assert_eq!(stringify(&b(false), 0), "false");
204    }
205
206    #[test]
207    fn primitive_number_integer_like() {
208        assert_eq!(stringify(&n(42.0), 0), "42.0");
209    }
210
211    #[test]
212    fn primitive_number_zero() {
213        assert_eq!(stringify(&n(0.0), 0), "0.0");
214    }
215
216    #[test]
217    fn primitive_number_nan_becomes_null() {
218        assert_eq!(stringify(&n(f64::NAN), 0), "null");
219    }
220
221    #[test]
222    fn primitive_number_infinity_becomes_null() {
223        assert_eq!(stringify(&n(f64::INFINITY), 0), "null");
224    }
225
226    #[test]
227    fn primitive_empty_string() {
228        assert_eq!(stringify(&s(""), 0), "\"\"");
229    }
230
231    #[test]
232    fn primitive_simple_string() {
233        assert_eq!(stringify(&s("hello"), 0), "\"hello\"");
234    }
235
236    #[test]
237    fn primitive_string_escapes_quote_and_backslash() {
238        assert_eq!(stringify(&s("a\"b\\c"), 0), "\"a\\\"b\\\\c\"");
239    }
240
241    #[test]
242    fn primitive_string_escapes_whitespace_controls() {
243        assert_eq!(stringify(&s("a\nb\rc\td"), 0), "\"a\\nb\\rc\\td\"");
244    }
245
246    #[test]
247    fn primitive_string_escapes_unicode_control() {
248        // \u{0001} falls into the generic control-character branch.
249        assert_eq!(stringify(&s("\u{0001}x"), 0), "\"\\u0001x\"");
250    }
251
252    #[test]
253    fn empty_array_is_compact() {
254        let v = JsonValue::Array(vec![]);
255        assert_eq!(stringify(&v, 0), "[]");
256        assert_eq!(stringify(&v, 2), "[]");
257    }
258
259    #[test]
260    fn empty_object_is_compact() {
261        let v = JsonValue::Object(vec![]);
262        assert_eq!(stringify(&v, 0), "{}");
263        assert_eq!(stringify(&v, 2), "{}");
264    }
265
266    #[test]
267    fn array_no_indent() {
268        let v = JsonValue::Array(vec![n(1.0), n(2.0), n(3.0)]);
269        assert_eq!(stringify(&v, 0), "[1.0,2.0,3.0]");
270    }
271
272    #[test]
273    fn array_with_indent() {
274        let v = JsonValue::Array(vec![n(1.0), n(2.0)]);
275        assert_eq!(stringify(&v, 2), "[\n  1.0,\n  2.0\n]");
276    }
277
278    #[test]
279    fn object_no_indent() {
280        let v = JsonValue::Object(vec![("a".to_string(), n(1.0)), ("b".to_string(), b(true))]);
281        assert_eq!(stringify(&v, 0), "{\"a\":1.0,\"b\":true}");
282    }
283
284    #[test]
285    fn object_with_indent() {
286        let v = JsonValue::Object(vec![("a".to_string(), n(1.0))]);
287        assert_eq!(stringify(&v, 2), "{\n  \"a\": 1.0\n}");
288    }
289
290    #[test]
291    fn nested_object_array() {
292        let v = JsonValue::Object(vec![(
293            "items".to_string(),
294            JsonValue::Array(vec![s("x"), s("y")]),
295        )]);
296        let out = stringify(&v, 2);
297        assert!(out.contains("\"items\":"));
298        assert!(out.contains("\"x\""));
299        assert!(out.contains("\"y\""));
300    }
301
302    #[test]
303    fn object_key_with_special_chars_is_escaped() {
304        let v = JsonValue::Object(vec![("a\"b".to_string(), n(1.0))]);
305        let out = stringify(&v, 0);
306        assert!(out.contains("\"a\\\"b\""));
307    }
308
309    #[test]
310    fn estimate_size_is_nonzero() {
311        let v = JsonValue::Object(vec![
312            ("a".to_string(), n(1.0)),
313            ("b".to_string(), s("hello")),
314        ]);
315        assert!(estimate_json_size(&v, 0) > 0);
316        assert!(estimate_json_size(&v, 2) >= estimate_json_size(&v, 0));
317    }
318
319    #[test]
320    fn round_trip_via_serde_json_for_objects() {
321        let v = JsonValue::Object(vec![
322            ("name".to_string(), s("Alice")),
323            ("age".to_string(), n(30.0)),
324            ("active".to_string(), b(true)),
325            ("nothing".to_string(), null()),
326        ]);
327        let out = stringify(&v, 0);
328        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
329        assert_eq!(parsed["name"], "Alice");
330        assert_eq!(parsed["age"], 30.0);
331        assert_eq!(parsed["active"], true);
332        assert!(parsed["nothing"].is_null());
333    }
334}