Skip to main content

hypen_engine/portable/
path.rs

1//! Dotted-path JSON operations.
2//!
3//! Four pure functions over `serde_json::Value`:
4//!
5//! * [`path_get`]   — read the value at a dotted path, `None` if missing
6//! * [`path_set`]   — write a value at a dotted path, auto-vivifying
7//!                    objects and growing arrays with `null` padding
8//! * [`path_has`]   — test for presence
9//! * [`path_delete` — remove a key/index; returns `true` if something was removed
10//!
11//! Numeric segments are interpreted as array indices when the current
12//! node is an array; otherwise they're treated as object keys (so
13//! `map.0.name` is a valid path into `{"0": {"name": …}}`).
14//!
15//! Used by `__hypen_bind` (renderer → state) and by every host SDK's
16//! `ObservableState` implementation. Centralising here removes four
17//! hand-ports of the same auto-vivification semantics.
18
19use serde_json::Value;
20
21/// Read the value at a dotted path. Returns `None` if any segment
22/// fails to resolve.
23pub fn path_get(value: &Value, path: &str) -> Option<Value> {
24    if path.is_empty() {
25        return Some(value.clone());
26    }
27    let mut current = value;
28    for part in path.split('.') {
29        match current {
30            Value::Object(map) => {
31                current = map.get(part)?;
32            }
33            Value::Array(arr) => {
34                let idx: usize = part.parse().ok()?;
35                current = arr.get(idx)?;
36            }
37            _ => return None,
38        }
39    }
40    Some(current.clone())
41}
42
43/// Test whether a dotted path resolves to a value.
44pub fn path_has(value: &Value, path: &str) -> bool {
45    path_get(value, path).is_some()
46}
47
48/// Write `new_value` at `path` inside `target`. Intermediate objects
49/// are created as needed; arrays are extended with `Value::Null` up to
50/// the target index. Numeric segments map to array indices only when
51/// the current node is already an array.
52///
53/// An empty path is a no-op (the caller should replace `target`
54/// directly if they want to overwrite the root).
55pub fn path_set(target: &mut Value, path: &str, new_value: Value) {
56    if path.is_empty() {
57        return;
58    }
59    let parts: Vec<&str> = path.split('.').collect();
60    let mut current = target;
61
62    // Walk every segment except the last.
63    for part in &parts[..parts.len() - 1] {
64        // Array index?
65        if let Ok(idx) = part.parse::<usize>() {
66            if let Value::Array(arr) = current {
67                while arr.len() <= idx {
68                    arr.push(Value::Null);
69                }
70                current = &mut arr[idx];
71                continue;
72            }
73        }
74        // Otherwise treat as object key. Auto-vivify.
75        if !current.is_object() {
76            *current = Value::Object(serde_json::Map::new());
77        }
78        if let Value::Object(map) = current {
79            if !map.contains_key(*part) {
80                map.insert(part.to_string(), Value::Object(serde_json::Map::new()));
81            }
82            current = map.get_mut(*part).unwrap();
83        }
84    }
85
86    // Final segment.
87    let last = parts[parts.len() - 1];
88    if let Ok(idx) = last.parse::<usize>() {
89        if let Value::Array(arr) = current {
90            while arr.len() <= idx {
91                arr.push(Value::Null);
92            }
93            arr[idx] = new_value;
94            return;
95        }
96    }
97    if !current.is_object() {
98        *current = Value::Object(serde_json::Map::new());
99    }
100    if let Value::Object(map) = current {
101        map.insert(last.to_string(), new_value);
102    }
103}
104
105/// Remove whatever lives at `path`. Returns `true` if a key/index was
106/// actually removed, `false` if the path didn't resolve. Array indices
107/// are removed by splicing (the array shrinks by one). Empty path is a
108/// no-op that returns `false`.
109pub fn path_delete(target: &mut Value, path: &str) -> bool {
110    if path.is_empty() {
111        return false;
112    }
113    let parts: Vec<&str> = path.split('.').collect();
114    let mut current = target;
115
116    for part in &parts[..parts.len() - 1] {
117        if let Ok(idx) = part.parse::<usize>() {
118            if let Value::Array(arr) = current {
119                if let Some(next) = arr.get_mut(idx) {
120                    current = next;
121                    continue;
122                }
123                return false;
124            }
125        }
126        match current {
127            Value::Object(map) => match map.get_mut(*part) {
128                Some(next) => current = next,
129                None => return false,
130            },
131            _ => return false,
132        }
133    }
134
135    let last = parts[parts.len() - 1];
136    if let Ok(idx) = last.parse::<usize>() {
137        if let Value::Array(arr) = current {
138            if idx < arr.len() {
139                arr.remove(idx);
140                return true;
141            }
142            return false;
143        }
144    }
145    if let Value::Object(map) = current {
146        return map.remove(last).is_some();
147    }
148    false
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use serde_json::json;
155
156    // ── path_get ────────────────────────────────────────────────
157
158    #[test]
159    fn get_nested_object() {
160        let v = json!({"user": {"name": "Alice", "age": 30}});
161        assert_eq!(path_get(&v, "user.name"), Some(json!("Alice")));
162        assert_eq!(path_get(&v, "user.age"), Some(json!(30)));
163    }
164
165    #[test]
166    fn get_array_index() {
167        let v = json!({"items": ["a", "b", "c"]});
168        assert_eq!(path_get(&v, "items.1"), Some(json!("b")));
169        assert_eq!(path_get(&v, "items.10"), None);
170    }
171
172    #[test]
173    fn get_missing_returns_none() {
174        let v = json!({"a": 1});
175        assert_eq!(path_get(&v, "b"), None);
176        assert_eq!(path_get(&v, "a.b"), None); // a is not an object
177    }
178
179    #[test]
180    fn get_empty_path_returns_root() {
181        let v = json!({"a": 1});
182        assert_eq!(path_get(&v, ""), Some(v.clone()));
183    }
184
185    // ── path_has ────────────────────────────────────────────────
186
187    #[test]
188    fn has_matches_get() {
189        let v = json!({"user": {"name": "Alice"}});
190        assert!(path_has(&v, "user"));
191        assert!(path_has(&v, "user.name"));
192        assert!(!path_has(&v, "user.age"));
193        assert!(!path_has(&v, "other"));
194    }
195
196    // ── path_set ────────────────────────────────────────────────
197
198    #[test]
199    fn set_creates_intermediate_objects() {
200        let mut v = json!({});
201        path_set(&mut v, "a.b.c", json!(42));
202        assert_eq!(v, json!({"a": {"b": {"c": 42}}}));
203    }
204
205    #[test]
206    fn set_overwrites_existing() {
207        let mut v = json!({"a": 1});
208        path_set(&mut v, "a", json!(2));
209        assert_eq!(v, json!({"a": 2}));
210    }
211
212    #[test]
213    fn set_extends_array_with_nulls() {
214        let mut v = json!({"items": [1, 2]});
215        path_set(&mut v, "items.5", json!("X"));
216        assert_eq!(v, json!({"items": [1, 2, null, null, null, "X"]}));
217    }
218
219    #[test]
220    fn set_numeric_segment_on_object_is_key_not_index() {
221        // `{"0": "x"}` is a valid object; a numeric key isn't magically
222        // an index unless the parent is already an array.
223        let mut v = json!({});
224        path_set(&mut v, "0", json!("x"));
225        assert_eq!(v, json!({"0": "x"}));
226    }
227
228    #[test]
229    fn set_past_nine_uses_full_decimal() {
230        // Same class of bug as diff_paths had in Go — make sure we
231        // don't get cute with character arithmetic anywhere.
232        let mut v = json!({"items": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]});
233        path_set(&mut v, "items.10", json!("ten"));
234        assert_eq!(v["items"], json!([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, "ten"]));
235    }
236
237    #[test]
238    fn set_on_non_object_replaces_with_object() {
239        let mut v = json!("scalar");
240        path_set(&mut v, "a.b", json!(1));
241        assert_eq!(v, json!({"a": {"b": 1}}));
242    }
243
244    #[test]
245    fn set_empty_path_is_noop() {
246        let mut v = json!({"a": 1});
247        path_set(&mut v, "", json!(99));
248        assert_eq!(v, json!({"a": 1}));
249    }
250
251    // ── path_delete ──────────────────────────────────────────────
252
253    #[test]
254    fn delete_object_key() {
255        let mut v = json!({"a": 1, "b": 2});
256        assert!(path_delete(&mut v, "a"));
257        assert_eq!(v, json!({"b": 2}));
258    }
259
260    #[test]
261    fn delete_array_index_splices() {
262        let mut v = json!({"items": ["a", "b", "c"]});
263        assert!(path_delete(&mut v, "items.1"));
264        assert_eq!(v, json!({"items": ["a", "c"]}));
265    }
266
267    #[test]
268    fn delete_missing_returns_false() {
269        let mut v = json!({"a": 1});
270        assert!(!path_delete(&mut v, "b"));
271        assert!(!path_delete(&mut v, "a.nested"));
272        assert_eq!(v, json!({"a": 1}));
273    }
274
275    #[test]
276    fn delete_nested() {
277        let mut v = json!({"user": {"name": "Alice", "age": 30}});
278        assert!(path_delete(&mut v, "user.age"));
279        assert_eq!(v, json!({"user": {"name": "Alice"}}));
280    }
281
282    #[test]
283    fn delete_array_out_of_bounds() {
284        let mut v = json!({"items": ["a"]});
285        assert!(!path_delete(&mut v, "items.5"));
286    }
287}