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    fn write(&self, out: &mut String, indent: Option<usize>, depth: usize) {
96        let (newline, pad, pad_close) = match indent {
97            Some(width) => (
98                "\n".to_string(),
99                " ".repeat(width * (depth + 1)),
100                " ".repeat(width * depth),
101            ),
102            None => (String::new(), String::new(), String::new()),
103        };
104        let colon = if indent.is_some() { ": " } else { ":" };
105
106        match self {
107            Json::Null => out.push_str("null"),
108            Json::Bool(true) => out.push_str("true"),
109            Json::Bool(false) => out.push_str("false"),
110            Json::Number(n) => {
111                if n.is_finite() {
112                    // Integral values render without a trailing ".0" so ids stay ids.
113                    if n.fract() == 0.0 && n.abs() < 1e15 {
114                        let _ = write!(out, "{}", *n as i64);
115                    } else {
116                        let _ = write!(out, "{n}");
117                    }
118                } else {
119                    out.push_str("null");
120                }
121            }
122            Json::String(s) => escape_into(s, out),
123            Json::Array(items) => {
124                if items.is_empty() {
125                    out.push_str("[]");
126                    return;
127                }
128                out.push('[');
129                for (i, item) in items.iter().enumerate() {
130                    if i > 0 {
131                        out.push(',');
132                    }
133                    out.push_str(&newline);
134                    out.push_str(&pad);
135                    item.write(out, indent, depth + 1);
136                }
137                out.push_str(&newline);
138                out.push_str(&pad_close);
139                out.push(']');
140            }
141            Json::Object(map) => {
142                if map.is_empty() {
143                    out.push_str("{}");
144                    return;
145                }
146                out.push('{');
147                for (i, (key, value)) in map.iter().enumerate() {
148                    if i > 0 {
149                        out.push(',');
150                    }
151                    out.push_str(&newline);
152                    out.push_str(&pad);
153                    escape_into(key, out);
154                    out.push_str(colon);
155                    value.write(out, indent, depth + 1);
156                }
157                out.push_str(&newline);
158                out.push_str(&pad_close);
159                out.push('}');
160            }
161        }
162    }
163
164    /// Parse JSON text.
165    pub fn parse(input: &str) -> Result<Json> {
166        let mut parser = Parser { bytes: input.as_bytes(), pos: 0 };
167        parser.skip_whitespace();
168        let value = parser.value()?;
169        parser.skip_whitespace();
170        if parser.pos < parser.bytes.len() {
171            return Err(parser.error("unexpected trailing characters"));
172        }
173        Ok(value)
174    }
175}
176
177/// Compact JSON text. `to_string()` comes from here.
178impl std::fmt::Display for Json {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        let mut out = String::new();
181        self.write(&mut out, None, 0);
182        f.write_str(&out)
183    }
184}
185
186fn escape_into(s: &str, out: &mut String) {
187    out.push('"');
188    for ch in s.chars() {
189        match ch {
190            '"' => out.push_str("\\\""),
191            '\\' => out.push_str("\\\\"),
192            '\n' => out.push_str("\\n"),
193            '\r' => out.push_str("\\r"),
194            '\t' => out.push_str("\\t"),
195            '\u{08}' => out.push_str("\\b"),
196            '\u{0c}' => out.push_str("\\f"),
197            // Escaping `<` and `/` keeps embedded JSON from closing a <script> tag.
198            '<' => out.push_str("\\u003c"),
199            c if (c as u32) < 0x20 => {
200                let _ = write!(out, "\\u{:04x}", c as u32);
201            }
202            c => out.push(c),
203        }
204    }
205    out.push('"');
206}
207
208impl From<bool> for Json {
209    fn from(v: bool) -> Self {
210        Json::Bool(v)
211    }
212}
213
214impl From<String> for Json {
215    fn from(v: String) -> Self {
216        Json::String(v)
217    }
218}
219
220impl From<&str> for Json {
221    fn from(v: &str) -> Self {
222        Json::String(v.to_string())
223    }
224}
225
226impl<T: Into<Json>> From<Option<T>> for Json {
227    fn from(v: Option<T>) -> Self {
228        v.map_or(Json::Null, Into::into)
229    }
230}
231
232impl<T: Into<Json>> From<Vec<T>> for Json {
233    fn from(v: Vec<T>) -> Self {
234        Json::Array(v.into_iter().map(Into::into).collect())
235    }
236}
237
238macro_rules! impl_from_number {
239    ($($t:ty),*) => {
240        $(impl From<$t> for Json {
241            fn from(v: $t) -> Self {
242                Json::Number(v as f64)
243            }
244        })*
245    };
246}
247impl_from_number!(i8, i16, i32, i64, isize, u8, u16, u32, u64, usize, f32, f64);
248
249struct Parser<'a> {
250    bytes: &'a [u8],
251    pos: usize,
252}
253
254impl<'a> Parser<'a> {
255    fn error(&self, message: &str) -> Error {
256        // Recomputing line/column on the error path keeps the hot path free of bookkeeping.
257        let consumed = &self.bytes[..self.pos.min(self.bytes.len())];
258        let line = consumed.iter().filter(|b| **b == b'\n').count() + 1;
259        let column = consumed.iter().rposition(|b| *b == b'\n').map_or(self.pos, |i| self.pos - i - 1) + 1;
260        Error::Json { line, column, message: message.to_string() }
261    }
262
263    fn peek(&self) -> Option<u8> {
264        self.bytes.get(self.pos).copied()
265    }
266
267    fn skip_whitespace(&mut self) {
268        while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
269            self.pos += 1;
270        }
271    }
272
273    fn expect(&mut self, byte: u8) -> Result<()> {
274        if self.peek() == Some(byte) {
275            self.pos += 1;
276            Ok(())
277        } else {
278            Err(self.error(&format!("expected `{}`", byte as char)))
279        }
280    }
281
282    fn literal(&mut self, word: &str, value: Json) -> Result<Json> {
283        if self.bytes[self.pos..].starts_with(word.as_bytes()) {
284            self.pos += word.len();
285            Ok(value)
286        } else {
287            Err(self.error("invalid literal"))
288        }
289    }
290
291    fn value(&mut self) -> Result<Json> {
292        match self.peek() {
293            Some(b'n') => self.literal("null", Json::Null),
294            Some(b't') => self.literal("true", Json::Bool(true)),
295            Some(b'f') => self.literal("false", Json::Bool(false)),
296            Some(b'"') => self.string().map(Json::String),
297            Some(b'[') => self.array(),
298            Some(b'{') => self.object(),
299            Some(b'-' | b'0'..=b'9') => self.number(),
300            Some(_) => Err(self.error("unexpected character")),
301            None => Err(self.error("unexpected end of input")),
302        }
303    }
304
305    fn array(&mut self) -> Result<Json> {
306        self.expect(b'[')?;
307        let mut items = Vec::new();
308        self.skip_whitespace();
309        if self.peek() == Some(b']') {
310            self.pos += 1;
311            return Ok(Json::Array(items));
312        }
313        loop {
314            self.skip_whitespace();
315            items.push(self.value()?);
316            self.skip_whitespace();
317            match self.peek() {
318                Some(b',') => self.pos += 1,
319                Some(b']') => {
320                    self.pos += 1;
321                    return Ok(Json::Array(items));
322                }
323                _ => return Err(self.error("expected `,` or `]`")),
324            }
325        }
326    }
327
328    fn object(&mut self) -> Result<Json> {
329        self.expect(b'{')?;
330        let mut map = BTreeMap::new();
331        self.skip_whitespace();
332        if self.peek() == Some(b'}') {
333            self.pos += 1;
334            return Ok(Json::Object(map));
335        }
336        loop {
337            self.skip_whitespace();
338            let key = self.string()?;
339            self.skip_whitespace();
340            self.expect(b':')?;
341            self.skip_whitespace();
342            map.insert(key, self.value()?);
343            self.skip_whitespace();
344            match self.peek() {
345                Some(b',') => self.pos += 1,
346                Some(b'}') => {
347                    self.pos += 1;
348                    return Ok(Json::Object(map));
349                }
350                _ => return Err(self.error("expected `,` or `}`")),
351            }
352        }
353    }
354
355    fn number(&mut self) -> Result<Json> {
356        let start = self.pos;
357        if self.peek() == Some(b'-') {
358            self.pos += 1;
359        }
360        while matches!(self.peek(), Some(b'0'..=b'9' | b'.' | b'e' | b'E' | b'+' | b'-')) {
361            self.pos += 1;
362        }
363        std::str::from_utf8(&self.bytes[start..self.pos])
364            .ok()
365            .and_then(|s| s.parse::<f64>().ok())
366            .map(Json::Number)
367            .ok_or_else(|| self.error("invalid number"))
368    }
369
370    fn string(&mut self) -> Result<String> {
371        self.expect(b'"')?;
372        let mut out = String::new();
373        loop {
374            match self.peek() {
375                None => return Err(self.error("unterminated string")),
376                Some(b'"') => {
377                    self.pos += 1;
378                    return Ok(out);
379                }
380                Some(b'\\') => {
381                    self.pos += 1;
382                    let escape = self.peek().ok_or_else(|| self.error("unterminated escape"))?;
383                    self.pos += 1;
384                    match escape {
385                        b'"' => out.push('"'),
386                        b'\\' => out.push('\\'),
387                        b'/' => out.push('/'),
388                        b'n' => out.push('\n'),
389                        b'r' => out.push('\r'),
390                        b't' => out.push('\t'),
391                        b'b' => out.push('\u{08}'),
392                        b'f' => out.push('\u{0c}'),
393                        b'u' => out.push(self.unicode_escape()?),
394                        _ => return Err(self.error("invalid escape sequence")),
395                    }
396                }
397                Some(_) => {
398                    // Copy the whole UTF-8 sequence in one go.
399                    let start = self.pos;
400                    while let Some(b) = self.peek() {
401                        if b == b'"' || b == b'\\' {
402                            break;
403                        }
404                        self.pos += 1;
405                    }
406                    match std::str::from_utf8(&self.bytes[start..self.pos]) {
407                        Ok(chunk) => out.push_str(chunk),
408                        Err(_) => return Err(self.error("invalid UTF-8 in string")),
409                    }
410                }
411            }
412        }
413    }
414
415    fn unicode_escape(&mut self) -> Result<char> {
416        let high = self.hex4()?;
417        // Surrogate pair: the low half arrives as a second \u escape.
418        if (0xD800..0xDC00).contains(&high) {
419            if self.peek() == Some(b'\\') && self.bytes.get(self.pos + 1) == Some(&b'u') {
420                self.pos += 2;
421                let low = self.hex4()?;
422                if (0xDC00..0xE000).contains(&low) {
423                    let combined = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00);
424                    return char::from_u32(combined).ok_or_else(|| self.error("invalid code point"));
425                }
426            }
427            return Err(self.error("unpaired surrogate"));
428        }
429        char::from_u32(high).ok_or_else(|| self.error("invalid code point"))
430    }
431
432    fn hex4(&mut self) -> Result<u32> {
433        if self.pos + 4 > self.bytes.len() {
434            return Err(self.error("truncated \\u escape"));
435        }
436        let hex = std::str::from_utf8(&self.bytes[self.pos..self.pos + 4])
437            .ok()
438            .and_then(|s| u32::from_str_radix(s, 16).ok())
439            .ok_or_else(|| self.error("invalid \\u escape"))?;
440        self.pos += 4;
441        Ok(hex)
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn parses_and_reserializes_a_document() {
451        let source = r#"{"name":"Rustlavel","stars":42,"tags":["web","rust"],"nested":{"ok":true,"nothing":null}}"#;
452        let value = Json::parse(source).unwrap();
453
454        assert_eq!(value.get("name").unwrap().as_str(), Some("Rustlavel"));
455        assert_eq!(value.get("stars").unwrap().as_i64(), Some(42));
456        assert_eq!(value.get("tags.1").unwrap().as_str(), Some("rust"));
457        assert_eq!(value.get("nested.ok").unwrap().as_bool(), Some(true));
458        assert!(value.get("nested.nothing").unwrap().is_null());
459        assert!(value.get("nested.missing").is_none());
460
461        // Keys come back sorted, not in source order — serialization is
462        // deterministic so tests and HTTP caching stay stable.
463        assert_eq!(
464            value.to_string(),
465            r#"{"name":"Rustlavel","nested":{"nothing":null,"ok":true},"stars":42,"tags":["web","rust"]}"#
466        );
467        assert_eq!(Json::parse(&value.to_string()).unwrap(), value);
468    }
469
470    #[test]
471    fn handles_escapes_and_surrogate_pairs() {
472        let value = Json::parse(r#""line\nbreak é 🚀""#).unwrap();
473        assert_eq!(value.as_str(), Some("line\nbreak é 🚀"));
474
475        // `<` is escaped so JSON can be embedded in HTML without closing a script tag.
476        let embedded = Json::from("</script>").to_string();
477        assert_eq!(embedded, "\"\\u003c/script>\"");
478        assert_eq!(Json::parse(&embedded).unwrap().as_str(), Some("</script>"));
479    }
480
481    #[test]
482    fn integral_numbers_do_not_grow_a_decimal_point() {
483        assert_eq!(Json::from(42).to_string(), "42");
484        assert_eq!(Json::from(1.5).to_string(), "1.5");
485    }
486
487    #[test]
488    fn reports_position_of_a_syntax_error() {
489        let err = Json::parse("{\n  \"a\": tru\n}").unwrap_err();
490        match err {
491            Error::Json { line, .. } => assert_eq!(line, 2),
492            other => panic!("expected a JSON error, got {other:?}"),
493        }
494    }
495
496    #[test]
497    fn pretty_printing_round_trips() {
498        let value = Json::parse(r#"{"a":[1,2],"b":{}}"#).unwrap();
499        let pretty = value.to_string_pretty();
500        assert!(pretty.contains("\n  \"a\": ["));
501        assert_eq!(Json::parse(&pretty).unwrap(), value);
502    }
503}