jsonptr_lite/
lib.rs

1//! Tiny JSON Pointer lookups for `serde_json::Value`.
2//!
3//! Supports RFC 6901 escape rules for path tokens:
4//! - `~1` becomes `/`
5//! - `~0` becomes `~`
6//!
7//! This is intentionally minimal: one function, no allocations beyond token unescaping.
8//! Returns `None` for invalid pointers or missing paths.
9//!
10//! # Examples
11//! ```
12//! use serde_json::{json, Value};
13//! use jsonptr_lite::ptr;
14//!
15//! let v = json!({ "a": { "b": 3 }});
16//! assert_eq!(ptr(&v, "/a/b").and_then(Value::as_i64), Some(3));
17//!
18//! // array index
19//! let v = json!({ "items": [10, 20, 30] });
20//! assert_eq!(ptr(&v, "/items/1").and_then(Value::as_i64), Some(20));
21//!
22//! // escaped slash in key name: "/"
23//! let v = json!({ "a/b": 7 });
24//! assert_eq!(ptr(&v, "/a~1b").and_then(Value::as_i64), Some(7));
25//!
26//! // empty pointer returns the whole value
27//! let v = json!(42);
28//! assert_eq!(ptr(&v, "").and_then(Value::as_i64), Some(42));
29//! ```
30
31//! change value via pointer
32//! use serde_json::{json, Value};
33//! use jsonptr_lite::{ptr, ptr_mut};
34//! let mut v = json!({"a":{"b":0}});
35//! *ptr_mut(&mut v, "/a/b").unwrap() = json!(42);
36//! assert_eq!(ptr(&v, "/a/b").and_then(Value::as_i64), Some(42));
37use serde_json::Value;
38
39/// Lookup a JSON value by JSON Pointer (RFC 6901).
40///
41/// - empty string `""` returns the input `value`
42/// - each path segment is separated by `/`
43/// - escape rules in segments: `~1` → `/`, `~0` → `~`
44/// - returns `None` if the path cannot be followed
45pub fn ptr<'a>(value: &'a Value, pointer: &str) -> Option<&'a Value> {
46    // empty pointer means the whole document
47    if pointer.is_empty() {
48        return Some(value);
49    }
50    // pointer must begin with "/" per RFC 6901
51    if !pointer.starts_with('/') {
52        return None;
53    }
54
55    let mut current = value;
56    // skip the leading empty segment before the first "/"
57    for raw_token in pointer.split('/').skip(1) {
58        // decode "~1" -> "/" and "~0" -> "~"
59        let token = unescape_token(raw_token)?;
60
61        match current {
62            Value::Object(map) => {
63                // step into object by key
64                current = map.get(&token)?;
65            }
66            Value::Array(items) => {
67                // step into array by index
68                let idx: usize = token.parse().ok()?;
69                current = items.get(idx)?;
70            }
71            _ => {
72                // cannot descend into primitives
73                return None;
74            }
75        }
76    }
77    Some(current)
78}
79
80// turn token escapes into their characters
81// "~1" becomes "/" and "~0" becomes "~"
82// any other "~" sequence is invalid
83fn unescape_token(token: &str) -> Option<String> {
84    // fast path: no tilde, nothing to unescape
85    if !token.contains('~') {
86        return Some(token.to_owned());
87    }
88
89    let mut out = String::with_capacity(token.len());
90    let mut chars = token.chars();
91    while let Some(c) = chars.next() {
92        if c == '~' {
93            match chars.next() {
94                Some('0') => out.push('~'),
95                Some('1') => out.push('/'),
96                _ => return None,
97            }
98        } else {
99            out.push(c);
100        }
101    }
102    Some(out)
103}
104
105pub fn ptr_mut<'a>(value: &'a mut Value, pointer: &str) -> Option<&'a mut Value> {
106    // empty pointer means the whole document
107    if pointer.is_empty() {
108        return Some(value);
109    }
110    // pointer must begin with "/" per RFC 6901
111    if !pointer.starts_with('/') {
112        return None;
113    }
114
115    let mut current = value;
116    // skip the leading empty segment before the first "/"
117    for raw_token in pointer.split('/').skip(1) {
118        // decode "~1" -> "/" and "~0" -> "~"
119        let token = unescape_token(raw_token)?;
120
121        match current {
122            Value::Object(map) => {
123                // step into object by key
124                current = map.get_mut(&token)?;
125            }
126            Value::Array(items) => {
127                // step into array by index
128                let idx: usize = token.parse().ok()?;
129                current = items.get_mut(idx)?;
130            }
131            _ => {
132                // cannot descend into primitives
133                return None;
134            }
135        }
136    }
137    Some(current)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::ptr;
143    use serde_json::json;
144
145    #[test]
146    fn root_pointer() {
147        let v = json!(123);
148        assert_eq!(ptr(&v, "").and_then(|x| x.as_i64()), Some(123));
149    }
150
151    #[test]
152    fn object_path() {
153        let v = json!({"a":{"b":3}});
154        assert_eq!(ptr(&v, "/a/b").and_then(|x| x.as_i64()), Some(3));
155        assert!(ptr(&v, "/a/x").is_none());
156    }
157
158    #[test]
159    fn array_index() {
160        let v = json!({"items":[10,20,30]});
161        assert_eq!(ptr(&v, "/items/0").and_then(|x| x.as_i64()), Some(10));
162        assert_eq!(ptr(&v, "/items/2").and_then(|x| x.as_i64()), Some(30));
163        assert!(ptr(&v, "/items/3").is_none());
164        assert!(ptr(&v, "/items/-1").is_none());
165    }
166
167    #[test]
168    fn escapes() {
169        let v = json!({"a/b": 7, "x~y": 9});
170        assert_eq!(ptr(&v, "/a~1b").and_then(|x| x.as_i64()), Some(7));
171        assert_eq!(ptr(&v, "/x~0y").and_then(|x| x.as_i64()), Some(9));
172    }
173
174    #[test]
175    fn invalid_pointer() {
176        let v = json!({"a": 1});
177        assert!(ptr(&v, "a").is_none()); // missing leading slash
178        assert!(ptr(&v, "/~").is_none()); // bad escape
179        assert!(ptr(&v, "/a/0").is_none()); // descending into non-container
180    }
181}