Skip to main content

face_core/
path.rs

1//! jq-like path resolution against `serde_json::Value`.
2//!
3//! Supports dotted segments and bracketed integer indices, e.g.
4//! `data.path.text`, `data.lines[0].text`, or `.[0]` for top-level
5//! array access. The leading `.` is optional and treated as
6//! identity-then-lookup. The bare path `.` resolves to the input
7//! value as-is.
8//!
9//! Quoted segments (e.g. `."key with dot"`) are intentionally **not**
10//! supported in this slice — keys containing dots are out of scope
11//! until a later slice raises the need.
12//!
13//! # Error shape
14//!
15//! [`resolve`] returns `Result<&Value, FaceError>`. The chosen variant
16//! is [`FaceError::UnknownItemsPath`] — `path::resolve` does not know
17//! whether the path came from `--items` or `--score`, so the
18//! `UnknownItemsPath` form is used as the generic shape. Callers that
19//! distinguish the two flags re-wrap into [`FaceError::UnknownScorePath`]
20//! when appropriate (the score-resolution call site has the context).
21//! This is the simpler call shape — callers that don't care about the
22//! distinction propagate the error as-is.
23
24use serde_json::Value;
25
26use crate::FaceError;
27
28/// Resolve a jq-like dotted/bracketed path against a [`Value`].
29///
30/// # Path grammar
31///
32/// - `.` — identity (returns the input unchanged).
33/// - `.foo.bar` — nested object lookup.
34/// - `.foo[0]` — array index after a key.
35/// - `.[0]` — array index at the root.
36/// - `foo.bar` — leading `.` is optional.
37/// - `.foo[0].bar[1]` — index chains accepted between segments.
38///
39/// # Errors
40///
41/// Returns [`FaceError::UnknownItemsPath`] when the path cannot be
42/// resolved. The variant carries the original path string so the user
43/// can identify which lookup failed; the variant choice is generic —
44/// the score path code site re-wraps to
45/// [`FaceError::UnknownScorePath`] when relevant.
46///
47/// # Examples
48///
49/// ```
50/// use face_core::path;
51/// use serde_json::json;
52///
53/// let v = json!({"data": {"path": {"text": "src/lib.rs"}}});
54/// let leaf = path::resolve(&v, "data.path.text").unwrap();
55/// assert_eq!(leaf, &json!("src/lib.rs"));
56///
57/// let identity = path::resolve(&v, ".").unwrap();
58/// assert_eq!(identity, &v);
59/// ```
60pub fn resolve<'v>(value: &'v Value, path: &str) -> Result<&'v Value, FaceError> {
61    resolve_inner(value, path).map_err(|_| FaceError::UnknownItemsPath {
62        path: path.to_string(),
63    })
64}
65
66/// Resolve and clone the result, useful when the caller needs an owned
67/// [`Value`] (e.g. to feed it across an API boundary).
68///
69/// # Errors
70///
71/// See [`resolve`].
72///
73/// # Examples
74///
75/// ```
76/// use face_core::path;
77/// use serde_json::json;
78///
79/// let v = json!({"hits": [10, 20, 30]});
80/// let owned = path::resolve_owned(&v, ".hits[1]").unwrap();
81/// assert_eq!(owned, json!(20));
82/// ```
83pub fn resolve_owned(value: &Value, path: &str) -> Result<Value, FaceError> {
84    resolve(value, path).cloned()
85}
86
87/// Inner resolver returning a static-string error so the parser logic
88/// stays free of allocation. The public wrapper turns the static
89/// reason into a [`FaceError`] carrying the original path.
90fn resolve_inner<'v>(value: &'v Value, path: &str) -> Result<&'v Value, &'static str> {
91    let trimmed = path.strip_prefix('.').unwrap_or(path);
92    if trimmed.is_empty() {
93        return Ok(value);
94    }
95
96    let mut current = value;
97    for segment in parse_segments(trimmed)? {
98        current = step(current, segment)?;
99    }
100    Ok(current)
101}
102
103/// One parsed path segment.
104enum Segment<'a> {
105    /// `.foo` — object key lookup.
106    Key(&'a str),
107    /// `[N]` — array index.
108    Index(usize),
109}
110
111/// Parse a path body (already stripped of any leading `.`) into segments.
112///
113/// The grammar is: optional leading `[N]` chain, then identifiers
114/// separated by `.` with optional `[N]` suffixes that may chain
115/// (`foo[0][1]`). We do not allow whitespace, quoted segments, or
116/// negative indices.
117fn parse_segments(body: &str) -> Result<Vec<Segment<'_>>, &'static str> {
118    let mut out = Vec::new();
119    let mut rest = body;
120
121    // Leading `[N]` chain (top-level array index, e.g. `.[1]`).
122    while let Some(after_open) = rest.strip_prefix('[') {
123        let close = after_open.find(']').ok_or("unterminated `[`")?;
124        let index_str = &after_open[..close];
125        if index_str.is_empty() {
126            return Err("empty array index");
127        }
128        let index: usize = index_str.parse().map_err(|_| "non-integer array index")?;
129        out.push(Segment::Index(index));
130        rest = &after_open[close + 1..];
131    }
132
133    // Skip a `.` between leading indices and the first key.
134    if let Some(after_dot) = rest.strip_prefix('.') {
135        if after_dot.is_empty() {
136            return Err("trailing `.`");
137        }
138        rest = after_dot;
139    }
140
141    while !rest.is_empty() {
142        // Read the key portion up to the next `.` or `[`.
143        let key_end = rest.find(['.', '[']).unwrap_or(rest.len());
144        let key = &rest[..key_end];
145        if key.is_empty() {
146            // Two `.` in a row, or a `[` immediately following another `[`.
147            return Err("empty segment");
148        }
149        out.push(Segment::Key(key));
150        rest = &rest[key_end..];
151
152        // Read any chained `[N]` indices after the key.
153        while let Some(after_open) = rest.strip_prefix('[') {
154            let close = after_open.find(']').ok_or("unterminated `[`")?;
155            let index_str = &after_open[..close];
156            if index_str.is_empty() {
157                return Err("empty array index");
158            }
159            let index: usize = index_str.parse().map_err(|_| "non-integer array index")?;
160            out.push(Segment::Index(index));
161            rest = &after_open[close + 1..];
162        }
163
164        // Consume the separating `.` between segments, if any.
165        if let Some(after_dot) = rest.strip_prefix('.') {
166            if after_dot.is_empty() {
167                return Err("trailing `.`");
168            }
169            rest = after_dot;
170        }
171    }
172
173    Ok(out)
174}
175
176/// Walk one segment from the current value.
177fn step<'v>(current: &'v Value, segment: Segment<'_>) -> Result<&'v Value, &'static str> {
178    match segment {
179        Segment::Key(key) => current
180            .as_object()
181            .ok_or("not an object")?
182            .get(key)
183            .ok_or("missing object key"),
184        Segment::Index(idx) => current
185            .as_array()
186            .ok_or("not an array")?
187            .get(idx)
188            .ok_or("index out of range"),
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use serde_json::json;
196
197    fn err_path(e: &FaceError) -> &str {
198        match e {
199            FaceError::UnknownItemsPath { path } => path,
200            other => panic!("expected UnknownItemsPath, got {other:?}"),
201        }
202    }
203
204    #[test]
205    fn resolves_identity() {
206        let v = json!({"a": 1});
207        assert_eq!(resolve(&v, ".").unwrap(), &v);
208        assert_eq!(resolve(&v, "").unwrap(), &v);
209    }
210
211    #[test]
212    fn resolves_nested_dotted() {
213        let v = json!({"data": {"path": {"text": "src/cli.rs"}}});
214        let got = resolve(&v, "data.path.text").unwrap();
215        assert_eq!(got, &json!("src/cli.rs"));
216        // Leading `.` is also accepted.
217        let got2 = resolve(&v, ".data.path.text").unwrap();
218        assert_eq!(got2, &json!("src/cli.rs"));
219    }
220
221    #[test]
222    fn resolves_array_index() {
223        let v = json!({"hits": [{"score": 10}, {"score": 20}]});
224        let got = resolve(&v, ".hits[1].score").unwrap();
225        assert_eq!(got, &json!(20));
226    }
227
228    #[test]
229    fn resolves_chained_indices() {
230        let v = json!({"matrix": [[1, 2], [3, 4]]});
231        let got = resolve(&v, ".matrix[1][0]").unwrap();
232        assert_eq!(got, &json!(3));
233    }
234
235    #[test]
236    fn resolves_top_level_array_index() {
237        let v = json!([10, 20, 30]);
238        let got = resolve(&v, ".[1]").unwrap();
239        assert_eq!(got, &json!(20));
240    }
241
242    #[test]
243    fn missing_path_errors() {
244        let v = json!({"a": 1});
245        let err = resolve(&v, ".b").unwrap_err();
246        assert_eq!(err_path(&err), ".b");
247        let err = resolve(&v, ".a.b").unwrap_err();
248        assert_eq!(err_path(&err), ".a.b");
249    }
250
251    #[test]
252    fn out_of_range_index_errors() {
253        let v = json!({"a": [1, 2]});
254        let err = resolve(&v, ".a[5]").unwrap_err();
255        assert_eq!(err_path(&err), ".a[5]");
256    }
257
258    #[test]
259    fn rejects_malformed_paths() {
260        let v = json!({"a": 1});
261        assert!(resolve(&v, "..").is_err());
262        assert!(resolve(&v, "a.").is_err());
263        assert!(resolve(&v, ".a[").is_err());
264        assert!(resolve(&v, ".a[]").is_err());
265        assert!(resolve(&v, ".a[abc]").is_err());
266    }
267
268    #[test]
269    fn resolve_owned_clones() {
270        let v = json!({"x": [1, 2, 3]});
271        let got = resolve_owned(&v, ".x").unwrap();
272        assert_eq!(got, json!([1, 2, 3]));
273    }
274}