Skip to main content

rustlavel_core/
json.rs

1//! A self-contained JSON implementation: value model, parser, and serializer.
2//!
3//! The framework needs JSON in three places (responses, config, and later the
4//! AI/MCP packages), so it lives in core rather than pulling in serde.
5
6use crate::error::{Error, Result};
7use std::collections::BTreeMap;
8use std::fmt::Write as _;
9
10/// A parsed JSON value.
11///
12/// Object keys are kept in a `BTreeMap` so serialization is deterministic,
13/// which keeps test assertions and HTTP caching stable.
14#[derive(Debug, Clone, PartialEq)]
15pub enum Json {
16    Null,
17    Bool(bool),
18    Number(f64),
19    String(String),
20    Array(Vec<Json>),
21    Object(BTreeMap<String, Json>),
22}
23
24impl Json {
25    /// Build an object from pairs: `Json::object([("id", 1.into())])`.
26    pub fn object<K: Into<String>, I: IntoIterator<Item = (K, Json)>>(pairs: I) -> Self {
27        Json::Object(pairs.into_iter().map(|(k, v)| (k.into(), v)).collect())
28    }
29
30    /// Look up a nested value with a dotted path: `value.get("user.name")`.
31    ///
32    /// Numeric segments index into arrays, so `items.0.id` works too.
33    pub fn get(&self, path: &str) -> Option<&Json> {
34        let mut current = self;
35        for segment in path.split('.') {
36            current = match current {
37                Json::Object(map) => map.get(segment)?,
38                Json::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
39                _ => return None,
40            };
41        }
42        Some(current)
43    }
44
45    pub fn as_str(&self) -> Option<&str> {
46        match self {
47            Json::String(s) => Some(s),
48            _ => None,
49        }
50    }
51
52    pub fn as_f64(&self) -> Option<f64> {
53        match self {
54            Json::Number(n) => Some(*n),
55            _ => None,
56        }
57    }
58
59    pub fn as_i64(&self) -> Option<i64> {
60        self.as_f64().map(|n| n as i64)
61    }
62
63    pub fn as_bool(&self) -> Option<bool> {
64        match self {
65            Json::Bool(b) => Some(*b),
66            _ => None,
67        }
68    }
69
70    pub fn as_array(&self) -> Option<&[Json]> {
71        match self {
72            Json::Array(items) => Some(items),
73            _ => None,
74        }
75    }
76
77    pub fn as_object(&self) -> Option<&BTreeMap<String, Json>> {
78        match self {
79            Json::Object(map) => Some(map),
80            _ => None,
81        }
82    }
83
84    pub fn is_null(&self) -> bool {
85        matches!(self, Json::Null)
86    }
87
88    /// Serialize with two-space indentation, for config files and debug output.
89    pub fn to_string_pretty(&self) -> String {
90        let mut out = String::new();
91        self.write(&mut out, Some(2), 0);
92        out
93    }
94
95    /// A rough byte count, so the buffer is sized once instead of grown.
96    ///
97    /// Deliberately an estimate: an exact count means walking the tree twice,
98    /// and being a little over or under costs one reallocation at worst, where
99    /// starting from nothing costs a dozen.
100    fn estimated_len(&self) -> usize {
101        match self {
102            Json::Null => 4,
103            Json::Bool(true) => 4,
104            Json::Bool(false) => 5,
105            // Enough for any i64, and most numbers are far shorter.
106            Json::Number(_) => 20,
107            // Two quotes, and room for a few escapes without regrowing.
108            Json::String(s) => s.len() + 8,
109            Json::Array(items) => {
110                2 + items.iter().map(Json::estimated_len).sum::<usize>() + items.len()
111            }
112            Json::Object(map) => {
113                2 + map
114                    .iter()
115                    .map(|(key, value)| key.len() + 4 + value.estimated_len())
116                    .sum::<usize>()
117                    + map.len()
118            }
119        }
120    }
121
122    fn write(&self, out: &mut String, indent: Option<usize>, depth: usize) {
123        // Borrowed, not built. The compact form is the one every API response
124        // takes, and the old code allocated three Strings per node at every
125        // depth only to push them as empty.
126        let newline = if indent.is_some() { "\n" } else { "" };
127        let colon = if indent.is_some() { ": " } else { ":" };
128        let (pad, pad_close) = match indent {
129            Some(width) => (" ".repeat(width * (depth + 1)), " ".repeat(width * depth)),
130            None => (String::new(), String::new()),
131        };
132
133        match self {
134            Json::Null => out.push_str("null"),
135            Json::Bool(true) => out.push_str("true"),
136            Json::Bool(false) => out.push_str("false"),
137            Json::Number(n) => {
138                if n.is_finite() {
139                    // Integral values render without a trailing ".0" so ids stay ids.
140                    if n.fract() == 0.0 && n.abs() < 1e15 {
141                        push_integer(out, *n as i64);
142                    } else {
143                        let _ = write!(out, "{n}");
144                    }
145                } else {
146                    out.push_str("null");
147                }
148            }
149            Json::String(s) => escape_into(s, out),
150            Json::Array(items) => {
151                if items.is_empty() {
152                    out.push_str("[]");
153                    return;
154                }
155                out.push('[');
156                for (i, item) in items.iter().enumerate() {
157                    if i > 0 {
158                        out.push(',');
159                    }
160                    out.push_str(newline);
161                    out.push_str(&pad);
162                    item.write(out, indent, depth + 1);
163                }
164                out.push_str(newline);
165                out.push_str(&pad_close);
166                out.push(']');
167            }
168            Json::Object(map) => {
169                if map.is_empty() {
170                    out.push_str("{}");
171                    return;
172                }
173                out.push('{');
174                for (i, (key, value)) in map.iter().enumerate() {
175                    if i > 0 {
176                        out.push(',');
177                    }
178                    out.push_str(newline);
179                    out.push_str(&pad);
180                    escape_into(key, out);
181                    out.push_str(colon);
182                    value.write(out, indent, depth + 1);
183                }
184                out.push_str(newline);
185                out.push_str(&pad_close);
186                out.push('}');
187            }
188        }
189    }
190
191    /// Parse JSON text.
192    pub fn parse(input: &str) -> Result<Json> {
193        let mut parser = Parser { bytes: input.as_bytes(), pos: 0 };
194        parser.skip_whitespace();
195        let value = parser.value()?;
196        parser.skip_whitespace();
197        if parser.pos < parser.bytes.len() {
198            return Err(parser.error("unexpected trailing characters"));
199        }
200        Ok(value)
201    }
202}
203
204/// Compact JSON text. `to_string()` comes from here.
205impl std::fmt::Display for Json {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        let mut out = String::with_capacity(self.estimated_len());
208        self.write(&mut out, None, 0);
209        f.write_str(&out)
210    }
211}
212
213/// `\u0000` through `\u001f`, so a control character costs a lookup rather
214/// than a trip through the formatting machinery.
215const CONTROL_ESCAPES: [&str; 32] = [
216    "\\u0000", "\\u0001", "\\u0002", "\\u0003", "\\u0004", "\\u0005", "\\u0006", "\\u0007",
217    "\\u0008", "\\u0009", "\\u000a", "\\u000b", "\\u000c", "\\u000d", "\\u000e", "\\u000f",
218    "\\u0010", "\\u0011", "\\u0012", "\\u0013", "\\u0014", "\\u0015", "\\u0016", "\\u0017",
219    "\\u0018", "\\u0019", "\\u001a", "\\u001b", "\\u001c", "\\u001d", "\\u001e", "\\u001f",
220];
221
222/// Write a string as a JSON string literal.
223///
224/// Scans bytes and copies whole clean runs, rather than pushing one character
225/// at a time. Every byte that needs escaping is ASCII, and no byte of a
226/// multi-byte UTF-8 sequence is ASCII, so a byte scan cannot split a character
227/// — which is what makes the run-at-a-time copy safe as well as faster.
228fn escape_into(s: &str, out: &mut String) {
229    out.push('"');
230
231    let bytes = s.as_bytes();
232    let mut clean_from = 0;
233
234    for (index, &byte) in bytes.iter().enumerate() {
235        let replacement: &str = match byte {
236            b'"' => "\\\"",
237            b'\\' => "\\\\",
238            b'\n' => "\\n",
239            b'\r' => "\\r",
240            b'\t' => "\\t",
241            0x08 => "\\b",
242            0x0c => "\\f",
243            // Escaping `<` keeps embedded JSON from closing a <script> tag.
244            b'<' => "\\u003c",
245            0x00..=0x1f => CONTROL_ESCAPES[byte as usize],
246            _ => continue,
247        };
248
249        out.push_str(&s[clean_from..index]);
250        out.push_str(replacement);
251        clean_from = index + 1;
252    }
253
254    out.push_str(&s[clean_from..]);
255    out.push('"');
256}
257
258/// Decimal digits without going through `core::fmt`.
259///
260/// Every id, count and timestamp in a JSON response comes through here, and
261/// `write!` costs far more than the arithmetic does.
262fn push_integer(out: &mut String, value: i64) {
263    if value == 0 {
264        out.push('0');
265        return;
266    }
267
268    let mut digits = [0u8; 20];
269    let mut index = digits.len();
270    // Unsigned, so i64::MIN does not overflow when its sign is removed.
271    let mut magnitude = value.unsigned_abs();
272
273    while magnitude > 0 {
274        index -= 1;
275        digits[index] = b'0' + (magnitude % 10) as u8;
276        magnitude /= 10;
277    }
278    if value < 0 {
279        index -= 1;
280        digits[index] = b'-';
281    }
282
283    out.push_str(std::str::from_utf8(&digits[index..]).unwrap_or("0"));
284}
285
286impl From<bool> for Json {
287    fn from(v: bool) -> Self {
288        Json::Bool(v)
289    }
290}
291
292impl From<String> for Json {
293    fn from(v: String) -> Self {
294        Json::String(v)
295    }
296}
297
298impl From<&str> for Json {
299    fn from(v: &str) -> Self {
300        Json::String(v.to_string())
301    }
302}
303
304impl<T: Into<Json>> From<Option<T>> for Json {
305    fn from(v: Option<T>) -> Self {
306        v.map_or(Json::Null, Into::into)
307    }
308}
309
310impl<T: Into<Json>> From<Vec<T>> for Json {
311    fn from(v: Vec<T>) -> Self {
312        Json::Array(v.into_iter().map(Into::into).collect())
313    }
314}
315
316macro_rules! impl_from_number {
317    ($($t:ty),*) => {
318        $(impl From<$t> for Json {
319            fn from(v: $t) -> Self {
320                Json::Number(v as f64)
321            }
322        })*
323    };
324}
325impl_from_number!(i8, i16, i32, i64, isize, u8, u16, u32, u64, usize, f32, f64);
326
327struct Parser<'a> {
328    bytes: &'a [u8],
329    pos: usize,
330}
331
332impl<'a> Parser<'a> {
333    fn error(&self, message: &str) -> Error {
334        // Recomputing line/column on the error path keeps the hot path free of bookkeeping.
335        let consumed = &self.bytes[..self.pos.min(self.bytes.len())];
336        let line = consumed.iter().filter(|b| **b == b'\n').count() + 1;
337        let column = consumed.iter().rposition(|b| *b == b'\n').map_or(self.pos, |i| self.pos - i - 1) + 1;
338        Error::Json { line, column, message: message.to_string() }
339    }
340
341    fn peek(&self) -> Option<u8> {
342        self.bytes.get(self.pos).copied()
343    }
344
345    fn skip_whitespace(&mut self) {
346        while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
347            self.pos += 1;
348        }
349    }
350
351    fn expect(&mut self, byte: u8) -> Result<()> {
352        if self.peek() == Some(byte) {
353            self.pos += 1;
354            Ok(())
355        } else {
356            Err(self.error(&format!("expected `{}`", byte as char)))
357        }
358    }
359
360    fn literal(&mut self, word: &str, value: Json) -> Result<Json> {
361        if self.bytes[self.pos..].starts_with(word.as_bytes()) {
362            self.pos += word.len();
363            Ok(value)
364        } else {
365            Err(self.error("invalid literal"))
366        }
367    }
368
369    fn value(&mut self) -> Result<Json> {
370        match self.peek() {
371            Some(b'n') => self.literal("null", Json::Null),
372            Some(b't') => self.literal("true", Json::Bool(true)),
373            Some(b'f') => self.literal("false", Json::Bool(false)),
374            Some(b'"') => self.string().map(Json::String),
375            Some(b'[') => self.array(),
376            Some(b'{') => self.object(),
377            Some(b'-' | b'0'..=b'9') => self.number(),
378            Some(_) => Err(self.error("unexpected character")),
379            None => Err(self.error("unexpected end of input")),
380        }
381    }
382
383    fn array(&mut self) -> Result<Json> {
384        self.expect(b'[')?;
385        let mut items = Vec::new();
386        self.skip_whitespace();
387        if self.peek() == Some(b']') {
388            self.pos += 1;
389            return Ok(Json::Array(items));
390        }
391        loop {
392            self.skip_whitespace();
393            items.push(self.value()?);
394            self.skip_whitespace();
395            match self.peek() {
396                Some(b',') => self.pos += 1,
397                Some(b']') => {
398                    self.pos += 1;
399                    return Ok(Json::Array(items));
400                }
401                _ => return Err(self.error("expected `,` or `]`")),
402            }
403        }
404    }
405
406    fn object(&mut self) -> Result<Json> {
407        self.expect(b'{')?;
408        let mut map = BTreeMap::new();
409        self.skip_whitespace();
410        if self.peek() == Some(b'}') {
411            self.pos += 1;
412            return Ok(Json::Object(map));
413        }
414        loop {
415            self.skip_whitespace();
416            let key = self.string()?;
417            self.skip_whitespace();
418            self.expect(b':')?;
419            self.skip_whitespace();
420            map.insert(key, self.value()?);
421            self.skip_whitespace();
422            match self.peek() {
423                Some(b',') => self.pos += 1,
424                Some(b'}') => {
425                    self.pos += 1;
426                    return Ok(Json::Object(map));
427                }
428                _ => return Err(self.error("expected `,` or `}`")),
429            }
430        }
431    }
432
433    fn number(&mut self) -> Result<Json> {
434        let start = self.pos;
435        if self.peek() == Some(b'-') {
436            self.pos += 1;
437        }
438        while matches!(self.peek(), Some(b'0'..=b'9' | b'.' | b'e' | b'E' | b'+' | b'-')) {
439            self.pos += 1;
440        }
441        std::str::from_utf8(&self.bytes[start..self.pos])
442            .ok()
443            .and_then(|s| s.parse::<f64>().ok())
444            .map(Json::Number)
445            .ok_or_else(|| self.error("invalid number"))
446    }
447
448    fn string(&mut self) -> Result<String> {
449        self.expect(b'"')?;
450        let mut out = String::new();
451        loop {
452            match self.peek() {
453                None => return Err(self.error("unterminated string")),
454                Some(b'"') => {
455                    self.pos += 1;
456                    return Ok(out);
457                }
458                Some(b'\\') => {
459                    self.pos += 1;
460                    let escape = self.peek().ok_or_else(|| self.error("unterminated escape"))?;
461                    self.pos += 1;
462                    match escape {
463                        b'"' => out.push('"'),
464                        b'\\' => out.push('\\'),
465                        b'/' => out.push('/'),
466                        b'n' => out.push('\n'),
467                        b'r' => out.push('\r'),
468                        b't' => out.push('\t'),
469                        b'b' => out.push('\u{08}'),
470                        b'f' => out.push('\u{0c}'),
471                        b'u' => out.push(self.unicode_escape()?),
472                        _ => return Err(self.error("invalid escape sequence")),
473                    }
474                }
475                Some(_) => {
476                    // Copy the whole UTF-8 sequence in one go.
477                    let start = self.pos;
478                    while let Some(b) = self.peek() {
479                        if b == b'"' || b == b'\\' {
480                            break;
481                        }
482                        self.pos += 1;
483                    }
484                    match std::str::from_utf8(&self.bytes[start..self.pos]) {
485                        Ok(chunk) => out.push_str(chunk),
486                        Err(_) => return Err(self.error("invalid UTF-8 in string")),
487                    }
488                }
489            }
490        }
491    }
492
493    fn unicode_escape(&mut self) -> Result<char> {
494        let high = self.hex4()?;
495        // Surrogate pair: the low half arrives as a second \u escape.
496        if (0xD800..0xDC00).contains(&high) {
497            if self.peek() == Some(b'\\') && self.bytes.get(self.pos + 1) == Some(&b'u') {
498                self.pos += 2;
499                let low = self.hex4()?;
500                if (0xDC00..0xE000).contains(&low) {
501                    let combined = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00);
502                    return char::from_u32(combined).ok_or_else(|| self.error("invalid code point"));
503                }
504            }
505            return Err(self.error("unpaired surrogate"));
506        }
507        char::from_u32(high).ok_or_else(|| self.error("invalid code point"))
508    }
509
510    fn hex4(&mut self) -> Result<u32> {
511        if self.pos + 4 > self.bytes.len() {
512            return Err(self.error("truncated \\u escape"));
513        }
514        let hex = std::str::from_utf8(&self.bytes[self.pos..self.pos + 4])
515            .ok()
516            .and_then(|s| u32::from_str_radix(s, 16).ok())
517            .ok_or_else(|| self.error("invalid \\u escape"))?;
518        self.pos += 4;
519        Ok(hex)
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn parses_and_reserializes_a_document() {
529        let source = r#"{"name":"Rustlavel","stars":42,"tags":["web","rust"],"nested":{"ok":true,"nothing":null}}"#;
530        let value = Json::parse(source).unwrap();
531
532        assert_eq!(value.get("name").unwrap().as_str(), Some("Rustlavel"));
533        assert_eq!(value.get("stars").unwrap().as_i64(), Some(42));
534        assert_eq!(value.get("tags.1").unwrap().as_str(), Some("rust"));
535        assert_eq!(value.get("nested.ok").unwrap().as_bool(), Some(true));
536        assert!(value.get("nested.nothing").unwrap().is_null());
537        assert!(value.get("nested.missing").is_none());
538
539        // Keys come back sorted, not in source order — serialization is
540        // deterministic so tests and HTTP caching stay stable.
541        assert_eq!(
542            value.to_string(),
543            r#"{"name":"Rustlavel","nested":{"nothing":null,"ok":true},"stars":42,"tags":["web","rust"]}"#
544        );
545        assert_eq!(Json::parse(&value.to_string()).unwrap(), value);
546    }
547
548    #[test]
549    fn handles_escapes_and_surrogate_pairs() {
550        let value = Json::parse(r#""line\nbreak é 🚀""#).unwrap();
551        assert_eq!(value.as_str(), Some("line\nbreak é 🚀"));
552
553        // `<` is escaped so JSON can be embedded in HTML without closing a script tag.
554        let embedded = Json::from("</script>").to_string();
555        assert_eq!(embedded, "\"\\u003c/script>\"");
556        assert_eq!(Json::parse(&embedded).unwrap().as_str(), Some("</script>"));
557    }
558
559    #[test]
560    fn integral_numbers_do_not_grow_a_decimal_point() {
561        assert_eq!(Json::from(42).to_string(), "42");
562        assert_eq!(Json::from(1.5).to_string(), "1.5");
563    }
564
565    #[test]
566    fn reports_position_of_a_syntax_error() {
567        let err = Json::parse("{\n  \"a\": tru\n}").unwrap_err();
568        match err {
569            Error::Json { line, .. } => assert_eq!(line, 2),
570            other => panic!("expected a JSON error, got {other:?}"),
571        }
572    }
573
574    #[test]
575    fn pretty_printing_round_trips() {
576        let value = Json::parse(r#"{"a":[1,2],"b":{}}"#).unwrap();
577        let pretty = value.to_string_pretty();
578        assert!(pretty.contains("\n  \"a\": ["));
579        assert_eq!(Json::parse(&pretty).unwrap(), value);
580    }
581}