Skip to main content

agent_first_data/document/
path.rs

1//! The single path grammar used by every traversal operation.
2//!
3//! A dot separates segments. `\.` embeds a dot in a key, `\\` embeds a
4//! backslash, and `\*` embeds a star. Every other escape is rejected so a path
5//! is reversible.
6//!
7//! A bare `*` segment is reserved: it means "each child here" to the commands
8//! that accept a pattern, and [`parse_path`] refuses it rather than reading it
9//! as a key. `*` is a legal key in JSON, so [`join_path`] escapes it — a
10//! document carrying one is still addressable, as `\*`.
11//!
12//! A segment may be empty, because `""` is a legal key in JSON and YAML — npm
13//! writes one into every `package-lock.json` as the root package. Rejecting it
14//! did not make it unreachable, only unspeakable: [`join_path`] still rendered
15//! `["packages", ""]` as `packages.`, so `paths` emitted addresses that `value`
16//! and `set` then refused, and the reversibility this grammar exists to
17//! guarantee did not hold.
18//!
19//! The one sequence with no spelling is `[""]` on its own: it renders as the
20//! empty string, which names no path at all. A `""` key at the document root is
21//! therefore unaddressable, and that is the only hole left.
22
23use crate::document::{DocumentError, DocumentResult};
24
25pub fn parse_path(path: &str) -> DocumentResult<Vec<String>> {
26    if path.is_empty() {
27        return Err(DocumentError::EmptyPath);
28    }
29    let mut segments = Vec::new();
30    let mut segment = String::new();
31    let mut escaped = false;
32    // `\*` and `*` both parse to the same string, so the reservation below has
33    // to know which one was written.
34    let mut wrote_bare_star = false;
35    let mut literal = false;
36    for character in path.chars() {
37        if escaped {
38            match character {
39                '.' | '\\' | '*' => {
40                    segment.push(character);
41                    literal = true;
42                }
43                other => {
44                    return Err(DocumentError::ParseError {
45                        format: "path".to_string(),
46                        detail: format!("invalid escape `\\{other}`"),
47                    });
48                }
49            }
50            escaped = false;
51        } else {
52            match character {
53                '\\' => escaped = true,
54                '.' => {
55                    if segment == "*" && !literal {
56                        wrote_bare_star = true;
57                    }
58                    segments.push(std::mem::take(&mut segment));
59                    literal = false;
60                }
61                other => segment.push(other),
62            }
63        }
64    }
65    if escaped {
66        return Err(DocumentError::ParseError {
67            format: "path".to_string(),
68            detail: "trailing path escape".to_string(),
69        });
70    }
71    if segment == "*" && !literal {
72        wrote_bare_star = true;
73    }
74    segments.push(segment);
75    // A bare `*` means "each child" wherever a path is written, so refuse it
76    // here rather than let it read as a key in one command and expand in
77    // another. The literal spelling is `\*`, which reaches this point as the
78    // same string and must still be accepted.
79    if wrote_bare_star {
80        return Err(DocumentError::ParseError {
81            format: "path".to_string(),
82            detail: "a bare `*` segment is a pattern; write `\\*` for a literal star key, or use \
83                     a command that expands patterns"
84                .to_string(),
85        });
86    }
87    Ok(segments)
88}
89
90pub fn join_path(segments: &[String]) -> String {
91    segments
92        .iter()
93        .map(|segment| {
94            segment
95                .replace('\\', "\\\\")
96                .replace('.', "\\.")
97                .replace('*', "\\*")
98        })
99        .collect::<Vec<_>>()
100        .join(".")
101}
102
103/// One segment of a path that may address many nodes.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum PatternSegment {
106    /// A literal key or array index.
107    Key(String),
108    /// A bare `*`: every child of the container at this point.
109    Wildcard,
110}
111
112/// Parse a path in which a bare `*` segment means "each child here".
113///
114/// Reading one field across a collection was three steps — enumerate the
115/// children, append the field to each address, then read them — and the middle
116/// step was string surgery on this grammar's own output. A pattern says it
117/// directly: `package.*.name`.
118///
119/// A literal star is still addressable as `\*`, so no document becomes
120/// unreachable by reserving the bare form.
121pub fn parse_path_pattern(path: &str) -> DocumentResult<Vec<PatternSegment>> {
122    if path.is_empty() {
123        return Err(DocumentError::EmptyPath);
124    }
125    let mut segments = Vec::new();
126    let mut segment = String::new();
127    let mut escaped = false;
128    let mut literal = false;
129    for character in path.chars() {
130        if escaped {
131            match character {
132                '.' | '\\' | '*' => {
133                    segment.push(character);
134                    literal = true;
135                }
136                other => {
137                    return Err(DocumentError::ParseError {
138                        format: "path".to_string(),
139                        detail: format!("invalid escape `\\{other}`"),
140                    });
141                }
142            }
143            escaped = false;
144        } else {
145            match character {
146                '\\' => escaped = true,
147                '.' => {
148                    segments.push(finish_pattern_segment(
149                        std::mem::take(&mut segment),
150                        literal,
151                    ));
152                    literal = false;
153                }
154                other => segment.push(other),
155            }
156        }
157    }
158    if escaped {
159        return Err(DocumentError::ParseError {
160            format: "path".to_string(),
161            detail: "trailing path escape".to_string(),
162        });
163    }
164    segments.push(finish_pattern_segment(segment, literal));
165    Ok(segments)
166}
167
168fn finish_pattern_segment(segment: String, literal: bool) -> PatternSegment {
169    if segment == "*" && !literal {
170        PatternSegment::Wildcard
171    } else {
172        PatternSegment::Key(segment)
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    /// The property this grammar exists to have: every key sequence has one
181    /// spelling, and that spelling reads back as the same sequence.
182    #[test]
183    fn every_key_sequence_round_trips_through_its_spelling() {
184        let cases: &[&[&str]] = &[
185            &["packages", "", "version"],
186            &["packages", ""],
187            &["", "a"],
188            &["a", "", "", "b"],
189            &["plain"],
190            &["a.dotted.key", "b"],
191            &["back\\slash"],
192            &["node_modules/@esbuild/darwin-arm64"],
193        ];
194        for segments in cases {
195            let owned: Vec<String> = segments.iter().map(|s| (*s).to_string()).collect();
196            let spelling = join_path(&owned);
197            let parsed = parse_path(&spelling)
198                .unwrap_or_else(|error| panic!("{owned:?} spelled `{spelling}`: {error}"));
199            assert_eq!(parsed, owned, "spelled `{spelling}`");
200        }
201    }
202
203    #[test]
204    fn an_empty_segment_is_a_key_not_an_error() {
205        // npm writes `""` as the root package of every package-lock.json.
206        // Rejecting it did not make it unreachable, only unspeakable.
207        assert_eq!(
208            parse_path("packages..version").unwrap(),
209            vec!["packages".to_string(), String::new(), "version".to_string()]
210        );
211        assert_eq!(
212            parse_path("packages.").unwrap(),
213            vec!["packages".to_string(), String::new()]
214        );
215        assert_eq!(
216            parse_path(".leading").unwrap(),
217            vec![String::new(), "leading".to_string()]
218        );
219    }
220
221    #[test]
222    fn a_star_key_survives_the_wildcard_reservation() {
223        // `*` is a legal JSON key. Reserving the bare form for patterns must
224        // not make a document carrying one unaddressable — the same mistake
225        // the empty-segment rejection made.
226        assert_eq!(join_path(&["*".to_string()]), r"\*");
227        assert_eq!(parse_path(r"\*").unwrap(), vec!["*".to_string()]);
228        assert_eq!(
229            parse_path(r"a.\*.b").unwrap(),
230            vec!["a".to_string(), "*".to_string(), "b".to_string()]
231        );
232        // Round-trip, which is the property that matters.
233        let segments = vec!["a".to_string(), "*".to_string()];
234        assert_eq!(parse_path(&join_path(&segments)).unwrap(), segments);
235    }
236
237    #[test]
238    fn a_bare_star_is_reserved_for_patterns() {
239        // One spelling, one meaning: it must not read as a key here and expand
240        // somewhere else.
241        assert!(parse_path("*").is_err());
242        assert!(parse_path("package.*.name").is_err());
243        // The pattern parser is where it means something.
244        assert_eq!(
245            parse_path_pattern("package.*.name").unwrap(),
246            vec![
247                PatternSegment::Key("package".to_string()),
248                PatternSegment::Wildcard,
249                PatternSegment::Key("name".to_string()),
250            ]
251        );
252        // And escaped, it is a key even there.
253        assert_eq!(
254            parse_path_pattern(r"a.\*").unwrap(),
255            vec![
256                PatternSegment::Key("a".to_string()),
257                PatternSegment::Key("*".to_string()),
258            ]
259        );
260    }
261
262    #[test]
263    fn the_empty_string_still_names_no_path() {
264        // The one sequence with no spelling. `[""]` renders as "", which is
265        // how a caller says "I gave you no path" — so a `""` key at the
266        // document root stays unaddressable, and that is the only hole.
267        assert!(matches!(parse_path(""), Err(DocumentError::EmptyPath)));
268        assert_eq!(join_path(&[String::new()]), "");
269    }
270
271    #[test]
272    fn malformed_escapes_are_still_refused() {
273        assert!(parse_path(r"trailing\").is_err());
274        assert!(parse_path(r"bad\qescape").is_err());
275    }
276}