Skip to main content

spg_engine/
json.rs

1// Recursive-descent JSON parser. Several lints are inherent to the
2// hand-rolled byte-scan style and don't add clarity here.
3#![allow(
4    clippy::cast_lossless,
5    clippy::cast_possible_truncation,
6    clippy::cast_possible_wrap,
7    clippy::cast_sign_loss,
8    clippy::doc_markdown,
9    clippy::format_push_string,
10    clippy::needless_continue,
11    clippy::needless_range_loop,
12    clippy::single_match,
13    clippy::uninlined_format_args
14)]
15
16//! v4.14 minimal JSON parser for the `->` / `->>` operators.
17//!
18//! Hand-rolled, no external dep — same policy as the rest of the
19//! engine. Supports the JSON grammar from RFC 8259: objects,
20//! arrays, strings (with `\"` / `\\` / `\/` / `\b` / `\f` / `\n`
21//! / `\r` / `\t` / `\uXXXX` escapes), numbers, true / false /
22//! null. The parser returns a tree we walk by key (object) or
23//! integer index (array); accesses that miss return `Value::Null`
24//! per PG semantics.
25//!
26//! `path_get(doc, key, as_text)` is the public entry. When
27//! `as_text` is true (`->>` operator), JSON strings unwrap to
28//! raw text and other scalars render as their canonical text;
29//! when false (`->`), the result is wrapped back into a Json
30//! value (the inner subtree rendered to its canonical JSON
31//! string form).
32
33use alloc::string::{String, ToString};
34use alloc::vec::Vec;
35
36use spg_storage::Value;
37
38use crate::eval::EvalError;
39
40#[derive(Debug, Clone, PartialEq)]
41pub enum JsonValue {
42    Null,
43    Bool(bool),
44    Number(f64),
45    /// Original numeric text, so integer round-trips don't drift to
46    /// `1.0`. We render either the raw lexeme (when present) or
47    /// `Number`'s default formatting.
48    NumberText(String),
49    String(String),
50    Array(Vec<JsonValue>),
51    Object(Vec<(String, JsonValue)>),
52}
53
54impl JsonValue {
55    fn as_text(&self) -> String {
56        match self {
57            Self::Null => "null".into(),
58            Self::Bool(b) => if *b { "true" } else { "false" }.into(),
59            Self::Number(x) => alloc::format!("{x}"),
60            Self::NumberText(s) | Self::String(s) => s.clone(),
61            Self::Array(_) | Self::Object(_) => self.to_json_text(),
62        }
63    }
64
65    fn to_json_text(&self) -> String {
66        let mut out = String::new();
67        write_json(self, &mut out);
68        out
69    }
70}
71
72fn write_json(v: &JsonValue, out: &mut String) {
73    match v {
74        JsonValue::Null => out.push_str("null"),
75        JsonValue::Bool(true) => out.push_str("true"),
76        JsonValue::Bool(false) => out.push_str("false"),
77        JsonValue::Number(x) => out.push_str(&alloc::format!("{x}")),
78        JsonValue::NumberText(s) => out.push_str(s),
79        JsonValue::String(s) => {
80            out.push('"');
81            for c in s.chars() {
82                match c {
83                    '"' => out.push_str("\\\""),
84                    '\\' => out.push_str("\\\\"),
85                    '\n' => out.push_str("\\n"),
86                    '\r' => out.push_str("\\r"),
87                    '\t' => out.push_str("\\t"),
88                    c if (c as u32) < 0x20 => {
89                        out.push_str(&alloc::format!("\\u{:04x}", c as u32));
90                    }
91                    c => out.push(c),
92                }
93            }
94            out.push('"');
95        }
96        JsonValue::Array(items) => {
97            out.push('[');
98            for (i, it) in items.iter().enumerate() {
99                if i > 0 {
100                    out.push(',');
101                }
102                write_json(it, out);
103            }
104            out.push(']');
105        }
106        JsonValue::Object(entries) => {
107            out.push('{');
108            for (i, (k, val)) in entries.iter().enumerate() {
109                if i > 0 {
110                    out.push(',');
111                }
112                write_json(&JsonValue::String(k.clone()), out);
113                out.push(':');
114                write_json(val, out);
115            }
116            out.push('}');
117        }
118    }
119}
120
121/// v6.4.5 — PG `json #> path_text` / `json #>> path_text`. The
122/// right-hand side is a PG text-array literal `'{a,0,b}'` whose
123/// elements are walked left-to-right; each element is either an
124/// object key or (when it parses as a non-negative integer) an
125/// array index. Missing or non-existent steps return `Value::Null`.
126pub fn path_walk(lhs: &Value, rhs: &Value, as_text: bool) -> Result<Value, EvalError> {
127    let src = match lhs {
128        Value::Json(s) | Value::Text(s) => s.as_str(),
129        Value::Null => return Ok(Value::Null),
130        other => {
131            return Err(EvalError::TypeMismatch {
132                detail: alloc::format!(
133                    "JSON path walk: left side must be JSON or TEXT, got {:?}",
134                    other.data_type()
135                ),
136            });
137        }
138    };
139    let path_text = match rhs {
140        Value::Text(s) | Value::Json(s) => s.as_str(),
141        Value::Null => return Ok(Value::Null),
142        other => {
143            return Err(EvalError::TypeMismatch {
144                detail: alloc::format!(
145                    "JSON path walk: right side must be TEXT, got {:?}",
146                    other.data_type()
147                ),
148            });
149        }
150    };
151    let path = parse_text_array(path_text)?;
152    let mut cur = parse(src).map_err(|e| EvalError::TypeMismatch {
153        detail: alloc::format!("invalid JSON for path walk: {e}"),
154    })?;
155    for step in &path {
156        let next = match (&cur, step.as_str()) {
157            (JsonValue::Object(entries), key) => entries
158                .iter()
159                .find(|(k, _)| k == key)
160                .map(|(_, v)| v.clone()),
161            (JsonValue::Array(items), key) => {
162                let Ok(idx) = key.parse::<i64>() else {
163                    return Ok(Value::Null);
164                };
165                if idx >= 0 {
166                    items.get(idx as usize).cloned()
167                } else {
168                    let from_end = items.len() as i64 + idx;
169                    if from_end >= 0 {
170                        items.get(from_end as usize).cloned()
171                    } else {
172                        None
173                    }
174                }
175            }
176            _ => return Ok(Value::Null),
177        };
178        cur = match next {
179            None => return Ok(Value::Null),
180            Some(v) => v,
181        };
182    }
183    if matches!(cur, JsonValue::Null) {
184        return Ok(Value::Null);
185    }
186    if as_text {
187        Ok(Value::Text(cur.as_text()))
188    } else {
189        Ok(Value::Json(cur.to_json_text()))
190    }
191}
192
193/// v6.4.5 — PG `json @> sub_json` containment. Returns BOOL.
194/// `lhs @> rhs` is true when every member of `rhs` is structurally
195/// contained in `lhs`:
196///   - Scalars: equal
197///   - Objects: every (key, value) in rhs exists in lhs with a
198///     containing value
199///   - Arrays: every element in rhs has a containing element in lhs
200pub fn contains(lhs: &Value, rhs: &Value) -> Result<Value, EvalError> {
201    let lhs_text = match lhs {
202        Value::Json(s) | Value::Text(s) => s.as_str(),
203        Value::Null => return Ok(Value::Null),
204        other => {
205            return Err(EvalError::TypeMismatch {
206                detail: alloc::format!(
207                    "JSON @>: left side must be JSON or TEXT, got {:?}",
208                    other.data_type()
209                ),
210            });
211        }
212    };
213    let rhs_text = match rhs {
214        Value::Json(s) | Value::Text(s) => s.as_str(),
215        Value::Null => return Ok(Value::Null),
216        other => {
217            return Err(EvalError::TypeMismatch {
218                detail: alloc::format!(
219                    "JSON @>: right side must be JSON or TEXT, got {:?}",
220                    other.data_type()
221                ),
222            });
223        }
224    };
225    let lhs_doc = parse(lhs_text).map_err(|e| EvalError::TypeMismatch {
226        detail: alloc::format!("invalid JSON on left of @>: {e}"),
227    })?;
228    let rhs_doc = parse(rhs_text).map_err(|e| EvalError::TypeMismatch {
229        detail: alloc::format!("invalid JSON on right of @>: {e}"),
230    })?;
231    Ok(Value::Bool(json_contains(&lhs_doc, &rhs_doc)))
232}
233
234fn json_contains(lhs: &JsonValue, rhs: &JsonValue) -> bool {
235    match (lhs, rhs) {
236        (JsonValue::Object(l), JsonValue::Object(r)) => r.iter().all(|(rk, rv)| {
237            l.iter()
238                .any(|(lk, lv)| lk == rk && json_contains(lv, rv))
239        }),
240        (JsonValue::Array(l), JsonValue::Array(r)) => r
241            .iter()
242            .all(|rv| l.iter().any(|lv| json_contains(lv, rv))),
243        _ => json_eq(lhs, rhs),
244    }
245}
246
247fn json_eq(a: &JsonValue, b: &JsonValue) -> bool {
248    match (a, b) {
249        (JsonValue::Null, JsonValue::Null) => true,
250        (JsonValue::Bool(x), JsonValue::Bool(y)) => x == y,
251        (JsonValue::String(x), JsonValue::String(y)) => x == y,
252        (JsonValue::Number(x), JsonValue::Number(y)) => (x - y).abs() < 1e-12,
253        (JsonValue::NumberText(x), JsonValue::NumberText(y)) => x == y,
254        (JsonValue::NumberText(x), JsonValue::Number(y))
255        | (JsonValue::Number(y), JsonValue::NumberText(x)) => {
256            x.parse::<f64>().is_ok_and(|xn| (xn - y).abs() < 1e-12)
257        }
258        (JsonValue::Array(x), JsonValue::Array(y)) => {
259            x.len() == y.len() && x.iter().zip(y).all(|(a, b)| json_eq(a, b))
260        }
261        (JsonValue::Object(x), JsonValue::Object(y)) => {
262            x.len() == y.len()
263                && x.iter().all(|(k, v)| {
264                    y.iter().any(|(k2, v2)| k == k2 && json_eq(v, v2))
265                })
266        }
267        _ => false,
268    }
269}
270
271/// Parse PG's text-array literal `'{a,b,c}'` into a Vec<String>.
272/// Whitespace around elements is trimmed; quoted elements (`"x,y"`)
273/// preserve embedded commas (minimal support — full PG array
274/// escaping is OOS).
275fn parse_text_array(s: &str) -> Result<Vec<String>, EvalError> {
276    let trimmed = s.trim();
277    let inner = if let Some(stripped) = trimmed.strip_prefix('{').and_then(|s| s.strip_suffix('}'))
278    {
279        stripped
280    } else {
281        return Err(EvalError::TypeMismatch {
282            detail: alloc::format!("path walk: expected PG array literal `{{…}}`, got {s:?}"),
283        });
284    };
285    if inner.trim().is_empty() {
286        return Ok(Vec::new());
287    }
288    let mut out = Vec::new();
289    let mut cur = String::new();
290    let mut in_quotes = false;
291    let mut chars = inner.chars().peekable();
292    while let Some(c) = chars.next() {
293        match c {
294            '"' => in_quotes = !in_quotes,
295            ',' if !in_quotes => {
296                out.push(cur.trim().to_string());
297                cur = String::new();
298            }
299            '\\' => {
300                if let Some(&next) = chars.peek() {
301                    cur.push(next);
302                    chars.next();
303                }
304            }
305            _ => cur.push(c),
306        }
307    }
308    out.push(cur.trim().to_string());
309    Ok(out)
310}
311
312/// PG `json -> key` / `json ->> key`. `lhs` must be JSON or TEXT
313/// containing JSON. `rhs` is either a TEXT key (object access) or
314/// an INT index (array access). `as_text=true` for `->>` (returns
315/// `Value::Text`); `false` for `->` (returns `Value::Json`).
316pub fn path_get(lhs: &Value, rhs: &Value, as_text: bool) -> Result<Value, EvalError> {
317    let src = match lhs {
318        Value::Json(s) | Value::Text(s) => s.as_str(),
319        Value::Null => return Ok(Value::Null),
320        other => {
321            return Err(EvalError::TypeMismatch {
322                detail: alloc::format!(
323                    "JSON path operator: left side must be JSON or TEXT, got {:?}",
324                    other.data_type()
325                ),
326            });
327        }
328    };
329    let doc = parse(src).map_err(|e| EvalError::TypeMismatch {
330        detail: alloc::format!("invalid JSON for path access: {e}"),
331    })?;
332    let inner = match (&doc, rhs) {
333        (JsonValue::Object(entries), Value::Text(k)) => entries
334            .iter()
335            .find(|(name, _)| name == k)
336            .map(|(_, v)| v.clone()),
337        (JsonValue::Array(items), Value::Int(idx)) => {
338            let n = *idx;
339            if n >= 0 {
340                items.get(n as usize).cloned()
341            } else {
342                let from_end = items.len() as i64 + i64::from(n);
343                if from_end >= 0 {
344                    items.get(from_end as usize).cloned()
345                } else {
346                    None
347                }
348            }
349        }
350        (JsonValue::Array(items), Value::BigInt(idx)) => {
351            let n = *idx;
352            if n >= 0 {
353                items.get(n as usize).cloned()
354            } else {
355                let from_end = items.len() as i64 + n;
356                if from_end >= 0 {
357                    items.get(from_end as usize).cloned()
358                } else {
359                    None
360                }
361            }
362        }
363        (_, Value::Null) => return Ok(Value::Null),
364        _ => None,
365    };
366    match inner {
367        None | Some(JsonValue::Null) => Ok(Value::Null),
368        Some(v) => {
369            if as_text {
370                Ok(Value::Text(v.as_text()))
371            } else {
372                Ok(Value::Json(v.to_json_text()))
373            }
374        }
375    }
376}
377
378// ---- Tiny recursive-descent JSON parser ----
379
380#[derive(Debug)]
381pub enum ParseError {
382    Unexpected(char, usize),
383    Truncated,
384    InvalidEscape(usize),
385    InvalidNumber(usize),
386}
387
388impl core::fmt::Display for ParseError {
389    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
390        match self {
391            Self::Unexpected(c, p) => write!(f, "unexpected {c:?} at offset {p}"),
392            Self::Truncated => f.write_str("unexpected end of JSON input"),
393            Self::InvalidEscape(p) => write!(f, "invalid string escape at offset {p}"),
394            Self::InvalidNumber(p) => write!(f, "invalid number at offset {p}"),
395        }
396    }
397}
398
399pub fn parse(src: &str) -> Result<JsonValue, ParseError> {
400    let bytes = src.as_bytes();
401    let mut p = 0;
402    skip_ws(bytes, &mut p);
403    let value = parse_value(bytes, &mut p)?;
404    skip_ws(bytes, &mut p);
405    if p != bytes.len() {
406        return Err(ParseError::Unexpected(bytes[p] as char, p));
407    }
408    Ok(value)
409}
410
411fn skip_ws(bytes: &[u8], p: &mut usize) {
412    while *p < bytes.len() && matches!(bytes[*p], b' ' | b'\t' | b'\n' | b'\r') {
413        *p += 1;
414    }
415}
416
417fn parse_value(bytes: &[u8], p: &mut usize) -> Result<JsonValue, ParseError> {
418    skip_ws(bytes, p);
419    if *p >= bytes.len() {
420        return Err(ParseError::Truncated);
421    }
422    match bytes[*p] {
423        b'{' => parse_object(bytes, p),
424        b'[' => parse_array(bytes, p),
425        b'"' => parse_string(bytes, p).map(JsonValue::String),
426        b't' | b'f' => parse_bool(bytes, p),
427        b'n' => parse_null(bytes, p),
428        b'-' | b'0'..=b'9' => parse_number(bytes, p),
429        c => Err(ParseError::Unexpected(c as char, *p)),
430    }
431}
432
433fn parse_object(bytes: &[u8], p: &mut usize) -> Result<JsonValue, ParseError> {
434    debug_assert_eq!(bytes[*p], b'{');
435    *p += 1;
436    let mut entries = Vec::new();
437    skip_ws(bytes, p);
438    if *p < bytes.len() && bytes[*p] == b'}' {
439        *p += 1;
440        return Ok(JsonValue::Object(entries));
441    }
442    loop {
443        skip_ws(bytes, p);
444        if *p >= bytes.len() || bytes[*p] != b'"' {
445            return Err(ParseError::Unexpected(
446                bytes.get(*p).copied().unwrap_or(0) as char,
447                *p,
448            ));
449        }
450        let key = parse_string(bytes, p)?;
451        skip_ws(bytes, p);
452        if *p >= bytes.len() || bytes[*p] != b':' {
453            return Err(ParseError::Unexpected(
454                bytes.get(*p).copied().unwrap_or(0) as char,
455                *p,
456            ));
457        }
458        *p += 1;
459        let value = parse_value(bytes, p)?;
460        entries.push((key, value));
461        skip_ws(bytes, p);
462        if *p >= bytes.len() {
463            return Err(ParseError::Truncated);
464        }
465        match bytes[*p] {
466            b',' => {
467                *p += 1;
468                continue;
469            }
470            b'}' => {
471                *p += 1;
472                return Ok(JsonValue::Object(entries));
473            }
474            c => return Err(ParseError::Unexpected(c as char, *p)),
475        }
476    }
477}
478
479fn parse_array(bytes: &[u8], p: &mut usize) -> Result<JsonValue, ParseError> {
480    debug_assert_eq!(bytes[*p], b'[');
481    *p += 1;
482    let mut items = Vec::new();
483    skip_ws(bytes, p);
484    if *p < bytes.len() && bytes[*p] == b']' {
485        *p += 1;
486        return Ok(JsonValue::Array(items));
487    }
488    loop {
489        items.push(parse_value(bytes, p)?);
490        skip_ws(bytes, p);
491        if *p >= bytes.len() {
492            return Err(ParseError::Truncated);
493        }
494        match bytes[*p] {
495            b',' => {
496                *p += 1;
497                continue;
498            }
499            b']' => {
500                *p += 1;
501                return Ok(JsonValue::Array(items));
502            }
503            c => return Err(ParseError::Unexpected(c as char, *p)),
504        }
505    }
506}
507
508fn parse_string(bytes: &[u8], p: &mut usize) -> Result<String, ParseError> {
509    debug_assert_eq!(bytes[*p], b'"');
510    *p += 1;
511    let mut out = String::new();
512    while *p < bytes.len() {
513        match bytes[*p] {
514            b'"' => {
515                *p += 1;
516                return Ok(out);
517            }
518            b'\\' => {
519                let start = *p;
520                *p += 1;
521                if *p >= bytes.len() {
522                    return Err(ParseError::Truncated);
523                }
524                match bytes[*p] {
525                    b'"' => {
526                        out.push('"');
527                        *p += 1;
528                    }
529                    b'\\' => {
530                        out.push('\\');
531                        *p += 1;
532                    }
533                    b'/' => {
534                        out.push('/');
535                        *p += 1;
536                    }
537                    b'b' => {
538                        out.push('\u{08}');
539                        *p += 1;
540                    }
541                    b'f' => {
542                        out.push('\u{0c}');
543                        *p += 1;
544                    }
545                    b'n' => {
546                        out.push('\n');
547                        *p += 1;
548                    }
549                    b'r' => {
550                        out.push('\r');
551                        *p += 1;
552                    }
553                    b't' => {
554                        out.push('\t');
555                        *p += 1;
556                    }
557                    b'u' => {
558                        if *p + 5 > bytes.len() {
559                            return Err(ParseError::Truncated);
560                        }
561                        let hex = &bytes[*p + 1..*p + 5];
562                        let n = u32::from_str_radix(
563                            core::str::from_utf8(hex)
564                                .map_err(|_| ParseError::InvalidEscape(start))?,
565                            16,
566                        )
567                        .map_err(|_| ParseError::InvalidEscape(start))?;
568                        out.push(char::from_u32(n).ok_or(ParseError::InvalidEscape(start))?);
569                        *p += 5;
570                    }
571                    _ => return Err(ParseError::InvalidEscape(start)),
572                }
573            }
574            c if c < 0x20 => return Err(ParseError::Unexpected(c as char, *p)),
575            _ => {
576                // Multi-byte UTF-8: consume the whole codepoint.
577                let s = core::str::from_utf8(&bytes[*p..])
578                    .map_err(|_| ParseError::Unexpected(bytes[*p] as char, *p))?;
579                let c = s.chars().next().unwrap();
580                out.push(c);
581                *p += c.len_utf8();
582            }
583        }
584    }
585    Err(ParseError::Truncated)
586}
587
588fn parse_bool(bytes: &[u8], p: &mut usize) -> Result<JsonValue, ParseError> {
589    if bytes[*p..].starts_with(b"true") {
590        *p += 4;
591        Ok(JsonValue::Bool(true))
592    } else if bytes[*p..].starts_with(b"false") {
593        *p += 5;
594        Ok(JsonValue::Bool(false))
595    } else {
596        Err(ParseError::Unexpected(bytes[*p] as char, *p))
597    }
598}
599
600fn parse_null(bytes: &[u8], p: &mut usize) -> Result<JsonValue, ParseError> {
601    if bytes[*p..].starts_with(b"null") {
602        *p += 4;
603        Ok(JsonValue::Null)
604    } else {
605        Err(ParseError::Unexpected(bytes[*p] as char, *p))
606    }
607}
608
609fn parse_number(bytes: &[u8], p: &mut usize) -> Result<JsonValue, ParseError> {
610    let start = *p;
611    if bytes[*p] == b'-' {
612        *p += 1;
613    }
614    while *p < bytes.len() && bytes[*p].is_ascii_digit() {
615        *p += 1;
616    }
617    if *p < bytes.len() && bytes[*p] == b'.' {
618        *p += 1;
619        while *p < bytes.len() && bytes[*p].is_ascii_digit() {
620            *p += 1;
621        }
622    }
623    if *p < bytes.len() && matches!(bytes[*p], b'e' | b'E') {
624        *p += 1;
625        if *p < bytes.len() && matches!(bytes[*p], b'+' | b'-') {
626            *p += 1;
627        }
628        while *p < bytes.len() && bytes[*p].is_ascii_digit() {
629            *p += 1;
630        }
631    }
632    let text = core::str::from_utf8(&bytes[start..*p])
633        .map_err(|_| ParseError::InvalidNumber(start))?
634        .to_string();
635    // Validate the parse so the wire side can trust the value.
636    if text.parse::<f64>().is_err() {
637        return Err(ParseError::InvalidNumber(start));
638    }
639    Ok(JsonValue::NumberText(text))
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    #[test]
647    fn parse_atoms() {
648        assert_eq!(parse("null").unwrap(), JsonValue::Null);
649        assert_eq!(parse("true").unwrap(), JsonValue::Bool(true));
650        assert_eq!(parse("false").unwrap(), JsonValue::Bool(false));
651        assert_eq!(
652            parse("\"hello\"").unwrap(),
653            JsonValue::String("hello".into())
654        );
655        assert!(matches!(
656            parse("42").unwrap(),
657            JsonValue::NumberText(ref s) if s == "42"
658        ));
659    }
660
661    #[test]
662    fn parse_nested() {
663        let doc = parse(r#"{"a":1,"b":[true,null,"x"]}"#).unwrap();
664        let JsonValue::Object(entries) = doc else {
665            panic!("expected object");
666        };
667        assert_eq!(entries.len(), 2);
668        assert_eq!(entries[0].0, "a");
669        assert_eq!(entries[1].0, "b");
670    }
671
672    #[test]
673    fn parse_string_escapes() {
674        let s = parse(r#""he said \"hi\" and\\then\n""#).unwrap();
675        assert_eq!(s, JsonValue::String("he said \"hi\" and\\then\n".into()));
676    }
677
678    #[test]
679    fn parse_unicode_escape() {
680        assert_eq!(parse(r#""é""#).unwrap(), JsonValue::String("é".into()));
681    }
682
683    #[test]
684    fn path_object_key_returns_value() {
685        let doc = Value::Json(r#"{"name":"alice","age":30}"#.into());
686        let key = Value::Text("name".into());
687        let v = path_get(&doc, &key, true).unwrap();
688        assert_eq!(v, Value::Text("alice".into()));
689        let v = path_get(&doc, &key, false).unwrap();
690        assert_eq!(v, Value::Json("\"alice\"".into()));
691    }
692
693    #[test]
694    fn path_array_index_supports_negative() {
695        let doc = Value::Json("[10,20,30]".into());
696        let v = path_get(&doc, &Value::Int(1), true).unwrap();
697        assert_eq!(v, Value::Text("20".into()));
698        let v = path_get(&doc, &Value::Int(-1), true).unwrap();
699        assert_eq!(v, Value::Text("30".into()));
700    }
701
702    #[test]
703    fn path_missing_key_returns_null() {
704        let doc = Value::Json(r#"{"a":1}"#.into());
705        let v = path_get(&doc, &Value::Text("missing".into()), true).unwrap();
706        assert_eq!(v, Value::Null);
707    }
708
709    #[test]
710    fn path_get_nested_subtree_renders_back() {
711        let doc = Value::Json(r#"{"k":{"x":[1,2]}}"#.into());
712        let v = path_get(&doc, &Value::Text("k".into()), false).unwrap();
713        assert_eq!(v, Value::Json("{\"x\":[1,2]}".into()));
714    }
715}