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