Skip to main content

flow_ir_core/
path.rs

1//! Typed context path (`Path`) — parsed, validated IR for the `$.a.b` /
2//! RFC 9535-style bracket path syntax used throughout flow.ir
3//! (`Expr::Path.at`, `Node::Fanout.bind`/`.out`, `Node::Assign.at`,
4//! `Node::Try.err_at`'s inner `Expr::Path`, ...).
5//!
6//! `Path` is the single authority for path syntax: parsing happens exactly
7//! once, at [`FromStr`]/[`Deserialize`] time (parse-don't-validate). `Node`
8//! / `Expr` fields carrying a path store an already-parsed `Path`, so
9//! evaluation (`read`/`write`) walks the same `Vec<Segment>` without ever
10//! re-parsing a string.
11//!
12//! # Syntax
13//!
14//! - `$` — root path (empty segment list); [`Path::read`] returns the whole
15//!   ctx, [`Path::write`] replaces it wholesale.
16//! - `$.a.b.c` — dot-separated object-key segments.
17//! - `$.a["p.md"]` / `$["x.y"]` — RFC 9535 (JSONPath) style bracket segments
18//!   for keys containing a literal `.` (double-quoted, no escape support —
19//!   a key containing `"` cannot be represented in bracket form). Bracket
20//!   segments may chain directly (`$.a["x"]["y"]`) or be followed by a dot
21//!   segment (`$["x.y"].inner`).
22//!
23//! No array-index support (MVP scope, unchanged from the pre-`Path` parser).
24//!
25//! # Uniform rejections (all [`PathParseError`], surfaced as
26//! [`EvalError::InvalidPath`] by the `read_path` / `write_path` compat
27//! wrappers, or as a deserialize error when a `Path` field is parsed from
28//! JSON)
29//!
30//! - anything not starting with `$` followed immediately by `.`, `[`, or
31//!   end-of-string — so `$foo` is rejected (it used to be silently accepted
32//!   as a 1-segment dot path: `"$foo".strip_prefix("$.")` fails, and the old
33//!   parser's `strip_prefix('$')` fallback let it through).
34//! - any empty dot segment: `$.`, `$.a.`, `$.a..b` — the old write-side
35//!   parser silently *dropped* empty segments (`.filter(|s| !s.is_empty())`)
36//!   while the old read-side parser only failed if the ctx happened to have
37//!   no key `""` at that position (`EvalError::PathNotFound`, not
38//!   `EvalError::InvalidPath`) — both were symptoms of the same missing
39//!   parse-time check, and both are now rejected uniformly, up front.
40//! - the existing bracket-notation rejections (unterminated bracket, missing
41//!   `"` after `[`, empty key, empty `[""]`, a bracket segment directly
42//!   followed by an unseparated plain segment).
43
44use std::fmt;
45use std::str::FromStr;
46
47use serde::{Deserialize, Deserializer, Serialize, Serializer};
48use serde_json::Value;
49use thiserror::Error;
50
51use crate::EvalError;
52
53/// One resolved path segment. Currently only object-key segments are
54/// supported; the variant is kept non-exhaustive-in-spirit (private, single
55/// arm) so a future `Index(usize)` (array-index support) is a pure addition.
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57enum Segment {
58    /// An object-key segment (from either dot form or a quoted bracket
59    /// segment) — never empty (the parser rejects empty segments).
60    Key(String),
61}
62
63/// A parsed, validated context path — the canonical IR for the flow.ir
64/// `$.a.b` / RFC 9535-style bracket path syntax. The full syntax and
65/// rejection rules are documented on [`Path::read`] / [`Path::write`] and
66/// the `FromStr` implementation below.
67///
68/// Illegal path syntax cannot be represented by this type: the only way to
69/// construct a `Path` is [`FromStr::from_str`] (equivalently `str::parse`)
70/// or [`Deserialize`], both of which reject malformed input up front. Once
71/// you hold a `Path`, [`Path::read`] / [`Path::write`] never re-derive or
72/// re-validate the segment list.
73#[derive(Debug, Clone, PartialEq, Eq, Hash)]
74pub struct Path(Vec<Segment>);
75
76/// Error returned by [`Path::from_str`] (and therefore surfaced through
77/// `Path`'s [`Deserialize`] impl, and through the `read_path` / `write_path`
78/// compat wrappers as [`EvalError::InvalidPath`]) on malformed path syntax.
79#[derive(Debug, Clone, PartialEq, Eq, Error)]
80#[error("invalid path syntax '{path}': {reason}")]
81pub struct PathParseError {
82    /// The original (unparseable) path string.
83    pub path: String,
84    /// Human-readable reason the path was rejected.
85    pub reason: String,
86}
87
88impl PathParseError {
89    fn new(path: &str, reason: &str) -> Self {
90        Self {
91            path: path.to_string(),
92            reason: reason.to_string(),
93        }
94    }
95}
96
97impl FromStr for Path {
98    type Err = PathParseError;
99
100    fn from_str(path: &str) -> Result<Self, Self::Err> {
101        let rest = path
102            .strip_prefix('$')
103            .ok_or_else(|| PathParseError::new(path, "path must start with '$'"))?;
104        if rest.is_empty() {
105            // Bare `$` — root path, no segments.
106            return Ok(Path(Vec::new()));
107        }
108        let body = match rest.as_bytes()[0] {
109            b'.' => {
110                let after_dot = &rest[1..];
111                if after_dot.is_empty() {
112                    return Err(PathParseError::new(
113                        path,
114                        "trailing '.' with no segment after it",
115                    ));
116                }
117                after_dot
118            }
119            b'[' => rest,
120            _ => {
121                return Err(PathParseError::new(
122                    path,
123                    "expected '.', '[', or end-of-string right after '$'",
124                ))
125            }
126        };
127        let segments = if body.contains('[') {
128            parse_bracket_segments(body, path)?
129        } else {
130            parse_dot_segments(body, path)?
131        };
132        Ok(Path(segments.into_iter().map(Segment::Key).collect()))
133    }
134}
135
136/// Split a (already `$`/`$.`-stripped) bracket-free body on `.`, rejecting
137/// any empty segment (leading/trailing/consecutive dots).
138fn parse_dot_segments(body: &str, original: &str) -> Result<Vec<String>, PathParseError> {
139    let mut segments = Vec::new();
140    for part in body.split('.') {
141        if part.is_empty() {
142            return Err(PathParseError::new(
143                original,
144                "empty path segment (leading, trailing, or consecutive '.')",
145            ));
146        }
147        segments.push(part.to_string());
148    }
149    Ok(segments)
150}
151
152/// Parse a (`$`/`$.`-stripped) body containing at least one `[` into its
153/// object-key segments. Supports:
154///
155/// - plain segment: any run of chars excluding `.` and `[`, non-empty.
156/// - bracket segment: `["<name>"]`, where `<name>` is one or more chars
157///   excluding `"` (no escape support — a key containing `"` is rejected).
158/// - plain segments are `.`-separated; a bracket segment may follow
159///   directly after the previous segment (`a["x"]`) or after a `.`
160///   (`a.["x"]`), and a bracket segment may itself be followed directly by
161///   another bracket (`a["x"]["y"]`) or by a `.` before the next plain
162///   segment (`a["x"].b`).
163///
164/// Any malformed sequence (unterminated bracket, missing quote, empty key,
165/// empty segment, bracket directly followed by an unseparated plain
166/// segment, ...) raises `PathParseError` — this parser never silently
167/// misparses.
168fn parse_bracket_segments(body: &str, original: &str) -> Result<Vec<String>, PathParseError> {
169    fn invalid(original: &str, reason: &str) -> PathParseError {
170        PathParseError::new(original, reason)
171    }
172
173    let bytes = body.as_bytes();
174    let len = bytes.len();
175    let mut segments = Vec::new();
176    let mut i = 0usize;
177    // true at path start and immediately after a `.`: the next byte must
178    // begin a new segment (plain or bracket), not another `.` or EOF.
179    let mut expect_segment_start = true;
180
181    while i < len {
182        match bytes[i] {
183            b'[' => {
184                if i + 1 >= len || bytes[i + 1] != b'"' {
185                    return Err(invalid(original, "expected '\"' after '['"));
186                }
187                let name_start = i + 2;
188                let mut j = name_start;
189                while j < len && bytes[j] != b'"' {
190                    j += 1;
191                }
192                if j >= len {
193                    return Err(invalid(original, "unterminated bracket segment"));
194                }
195                let name = &body[name_start..j];
196                if name.is_empty() {
197                    return Err(invalid(original, "empty bracket key"));
198                }
199                if j + 1 >= len || bytes[j + 1] != b']' {
200                    return Err(invalid(original, "missing closing ']' after key"));
201                }
202                segments.push(name.to_string());
203                i = j + 2;
204                expect_segment_start = false;
205                // Only `.` or another `[` (or EOF) may directly follow a
206                // bracket segment — a bare plain-segment continuation
207                // (`a["x"]b`) is ambiguous and rejected.
208                if i < len && bytes[i] != b'.' && bytes[i] != b'[' {
209                    return Err(invalid(
210                        original,
211                        "expected '.' or '[' after bracket segment",
212                    ));
213                }
214            }
215            b'.' => {
216                if expect_segment_start {
217                    return Err(invalid(original, "empty path segment"));
218                }
219                i += 1;
220                expect_segment_start = true;
221                if i >= len {
222                    return Err(invalid(original, "empty path segment"));
223                }
224            }
225            _ => {
226                let start = i;
227                while i < len && bytes[i] != b'.' && bytes[i] != b'[' {
228                    i += 1;
229                }
230                segments.push(body[start..i].to_string());
231                expect_segment_start = false;
232            }
233        }
234    }
235
236    if expect_segment_start {
237        return Err(invalid(original, "empty path segment"));
238    }
239
240    Ok(segments)
241}
242
243/// A key is safe to render in dot form iff it cannot be confused with a
244/// path delimiter: no literal `.`, `[`, or `]`. Anything else (including a
245/// key containing `"`, which dot form has no trouble with) still round-trips
246/// through dot form.
247fn is_dot_safe(key: &str) -> bool {
248    !key.is_empty() && !key.contains(['.', '[', ']'])
249}
250
251impl fmt::Display for Path {
252    /// Canonical string form: `$` + `.key` for each identifier-safe segment,
253    /// `["key"]` bracket form otherwise. `Path::from_str(path.to_string())`
254    /// always re-parses to an equal `Path` (round-trip law) — the canonical
255    /// form may normalize the *representation* (e.g. a segment reachable
256    /// only via dot form on the way in is still rendered via dot form; a
257    /// segment that required bracket form on the way in is rendered via
258    /// bracket form) without changing the parsed segment list.
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        write!(f, "$")?;
261        for Segment::Key(key) in &self.0 {
262            if is_dot_safe(key) {
263                write!(f, ".{key}")?;
264            } else {
265                write!(f, "[\"{key}\"]")?;
266            }
267        }
268        Ok(())
269    }
270}
271
272impl Serialize for Path {
273    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
274    where
275        S: Serializer,
276    {
277        serializer.serialize_str(&self.to_string())
278    }
279}
280
281impl<'de> Deserialize<'de> for Path {
282    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
283    where
284        D: Deserializer<'de>,
285    {
286        let s = String::deserialize(deserializer)?;
287        s.parse::<Path>().map_err(serde::de::Error::custom)
288    }
289}
290
291impl Path {
292    /// Read the value this path resolves to inside `ctx`.
293    ///
294    /// The root path (`$`) resolves to `ctx` itself. A missing key along the
295    /// way raises [`EvalError::PathNotFound`] — malformed *syntax* is
296    /// rejected earlier, at parse time, so `read` can never raise
297    /// [`EvalError::InvalidPath`].
298    pub fn read<'a>(&self, ctx: &'a Value) -> Result<&'a Value, EvalError> {
299        let mut cur = ctx;
300        for Segment::Key(key) in &self.0 {
301            cur = cur
302                .get(key)
303                .ok_or_else(|| EvalError::PathNotFound(self.to_string()))?;
304        }
305        Ok(cur)
306    }
307
308    /// Write `value` at the location this path resolves to inside `ctx`,
309    /// mutating `ctx` in place.
310    ///
311    /// The root path (`$`) replaces `ctx` wholesale. Missing intermediate
312    /// objects along the way are created automatically (a `null` — or
313    /// altogether absent — intermediate promotes to an empty object, same
314    /// as before this type existed). If an intermediate segment already
315    /// holds a concrete non-object value (a string, number, bool, or
316    /// array), the write is rejected with [`EvalError::TypeError`] instead
317    /// of silently clobbering it; `ctx` is left byte-for-byte unmodified in
318    /// that case (a rejected write never partially applies, because every
319    /// intermediate object promotion this method performs only ever touches
320    /// a freshly-created — previously `null`/absent — subtree, which by
321    /// construction cannot itself contain a pre-existing conflicting value
322    /// further down).
323    pub fn write(&self, ctx: &mut Value, value: Value) -> Result<(), EvalError> {
324        if self.0.is_empty() {
325            *ctx = value;
326            return Ok(());
327        }
328        write_recursive(ctx, &self.0, value, self)
329    }
330}
331
332fn write_recursive(
333    node: &mut Value,
334    keys: &[Segment],
335    value: Value,
336    full_path: &Path,
337) -> Result<(), EvalError> {
338    let Segment::Key(key) = &keys[0];
339    ensure_writable_object(node, full_path, key)?;
340    let obj = node
341        .as_object_mut()
342        .expect("ensure_writable_object guarantees an object here");
343    if keys.len() == 1 {
344        obj.insert(key.clone(), value);
345        Ok(())
346    } else {
347        let entry = obj.entry(key.clone()).or_insert(Value::Null);
348        write_recursive(entry, &keys[1..], value, full_path)
349    }
350}
351
352/// Ensure `node` is writable as an intermediate/leaf object slot: already an
353/// object is a no-op, `null` (missing/uninitialised) promotes to an empty
354/// object, anything else (string/number/bool/array) is rejected — it would
355/// otherwise be silently clobbered.
356fn ensure_writable_object(node: &mut Value, full_path: &Path, key: &str) -> Result<(), EvalError> {
357    if node.is_object() {
358        return Ok(());
359    }
360    if node.is_null() {
361        *node = Value::Object(serde_json::Map::new());
362        return Ok(());
363    }
364    Err(EvalError::TypeError {
365        op: "path.write".into(),
366        msg: format!(
367            "cannot write path '{full_path}' at segment '{key}': existing value at this \
368             position is not an object ({node:?})"
369        ),
370    })
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use serde_json::json;
377
378    // ── accept/reject table ────────────────────────────────────────────
379
380    #[test]
381    fn accepts_root() {
382        let p: Path = "$".parse().unwrap();
383        assert_eq!(p, Path(Vec::new()));
384    }
385
386    #[test]
387    fn accepts_single_dot_segment() {
388        let p: Path = "$.a".parse().unwrap();
389        assert_eq!(p, Path(vec![Segment::Key("a".into())]));
390    }
391
392    #[test]
393    fn accepts_multi_dot_segments() {
394        let p: Path = "$.a.b".parse().unwrap();
395        assert_eq!(
396            p,
397            Path(vec![Segment::Key("a".into()), Segment::Key("b".into())])
398        );
399    }
400
401    #[test]
402    fn accepts_bracket_forms() {
403        assert!("$.a[\"p.md\"]".parse::<Path>().is_ok());
404        assert!("$[\"x.y\"]".parse::<Path>().is_ok());
405        assert!("$[\"x.y\"].inner".parse::<Path>().is_ok());
406        assert!("$.a[\"x\"][\"y\"]".parse::<Path>().is_ok());
407    }
408
409    #[test]
410    fn rejects_missing_dollar_prefix() {
411        assert!("a.b".parse::<Path>().is_err());
412        assert!("".parse::<Path>().is_err());
413    }
414
415    #[test]
416    fn rejects_dollar_foo_no_dot() {
417        // previously silently accepted as a 1-segment dot path
418        let err = "$foo".parse::<Path>().unwrap_err();
419        assert_eq!(err.path, "$foo");
420    }
421
422    #[test]
423    fn rejects_trailing_dot() {
424        assert!("$.".parse::<Path>().is_err());
425        assert!("$.a.".parse::<Path>().is_err());
426    }
427
428    #[test]
429    fn rejects_empty_middle_segment() {
430        assert!("$.a..b".parse::<Path>().is_err());
431    }
432
433    #[test]
434    fn rejects_empty_bracket_key() {
435        assert!("$.a[\"\"]".parse::<Path>().is_err());
436        assert!("$.a[]".parse::<Path>().is_err());
437    }
438
439    #[test]
440    fn rejects_unterminated_bracket() {
441        assert!("$.a[".parse::<Path>().is_err());
442        assert!("$.a[\"x".parse::<Path>().is_err());
443    }
444
445    #[test]
446    fn rejects_unquoted_bracket_key() {
447        assert!("$.a[p.md]".parse::<Path>().is_err());
448    }
449
450    #[test]
451    fn rejects_unseparated_plain_suffix_after_bracket() {
452        assert!("$.a[\"x\"]b".parse::<Path>().is_err());
453    }
454
455    // ── Display round-trip ─────────────────────────────────────────────
456
457    #[test]
458    fn display_round_trip() {
459        for src in [
460            "$",
461            "$.a",
462            "$.a.b.c",
463            "$.a[\"p.md\"]",
464            "$[\"x.y\"]",
465            "$[\"x.y\"].inner",
466            "$.a[\"x\"][\"y\"]",
467        ] {
468            let parsed: Path = src.parse().unwrap();
469            let rendered = parsed.to_string();
470            let reparsed: Path = rendered.parse().unwrap_or_else(|e| {
471                panic!("canonical form '{rendered}' (from '{src}') failed to re-parse: {e}")
472            });
473            assert_eq!(
474                parsed, reparsed,
475                "round-trip mismatch for '{src}' -> '{rendered}'"
476            );
477        }
478    }
479
480    #[test]
481    fn display_dot_safe_key_renders_dot_form() {
482        let p: Path = "$.a".parse().unwrap();
483        assert_eq!(p.to_string(), "$.a");
484    }
485
486    #[test]
487    fn display_dotted_key_renders_bracket_form() {
488        let p: Path = "$.a[\"p.md\"]".parse().unwrap();
489        assert_eq!(p.to_string(), "$.a[\"p.md\"]");
490    }
491
492    // ── read / write ────────────────────────────────────────────────────
493
494    #[test]
495    fn read_root_returns_whole_ctx() {
496        let p: Path = "$".parse().unwrap();
497        let ctx = json!({"a": 1});
498        assert_eq!(p.read(&ctx).unwrap(), &ctx);
499    }
500
501    #[test]
502    fn read_missing_key_errors_path_not_found() {
503        let p: Path = "$.a.missing".parse().unwrap();
504        let ctx = json!({"a": {}});
505        let err = p.read(&ctx).unwrap_err();
506        assert!(matches!(err, EvalError::PathNotFound(_)), "{err:?}");
507    }
508
509    #[test]
510    fn write_root_replaces_whole_ctx() {
511        let p: Path = "$".parse().unwrap();
512        let mut ctx = json!({"a": 1});
513        p.write(&mut ctx, json!({"b": 2})).unwrap();
514        assert_eq!(ctx, json!({"b": 2}));
515    }
516
517    #[test]
518    fn write_creates_missing_intermediate_objects() {
519        let p: Path = "$.a.b.c".parse().unwrap();
520        let mut ctx = json!({});
521        p.write(&mut ctx, json!(42)).unwrap();
522        assert_eq!(ctx, json!({"a": {"b": {"c": 42}}}));
523    }
524
525    #[test]
526    fn write_clobber_of_non_object_intermediate_errors_and_leaves_ctx_unchanged() {
527        let p: Path = "$.a.b".parse().unwrap();
528        let mut ctx = json!({"a": "string"});
529        let before = ctx.clone();
530        let err = p.write(&mut ctx, json!(1)).unwrap_err();
531        assert!(matches!(err, EvalError::TypeError { .. }), "{err:?}");
532        assert_eq!(ctx, before, "ctx must be unchanged after a rejected write");
533    }
534
535    #[test]
536    fn write_top_level_non_object_ctx_errors_and_leaves_ctx_unchanged() {
537        let p: Path = "$.a".parse().unwrap();
538        let mut ctx = json!("scalar");
539        let before = ctx.clone();
540        let err = p.write(&mut ctx, json!(1)).unwrap_err();
541        assert!(matches!(err, EvalError::TypeError { .. }), "{err:?}");
542        assert_eq!(ctx, before);
543    }
544
545    #[test]
546    fn write_existing_null_intermediate_still_promotes() {
547        let p: Path = "$.a.b".parse().unwrap();
548        let mut ctx = json!({"a": null});
549        p.write(&mut ctx, json!(1)).unwrap();
550        assert_eq!(ctx, json!({"a": {"b": 1}}));
551    }
552}