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 and `\\` embeds a
4//! backslash. Every other escape is rejected so a path is reversible.
5//!
6//! A segment may be empty, because `""` is a legal key in JSON and YAML — npm
7//! writes one into every `package-lock.json` as the root package. Rejecting it
8//! did not make it unreachable, only unspeakable: [`join_path`] still rendered
9//! `["packages", ""]` as `packages.`, so `paths` emitted addresses that `value`
10//! and `set` then refused, and the reversibility this grammar exists to
11//! guarantee did not hold.
12//!
13//! The one sequence with no spelling is `[""]` on its own: it renders as the
14//! empty string, which names no path at all. A `""` key at the document root is
15//! therefore unaddressable, and that is the only hole left.
16
17use crate::document::{DocumentError, DocumentResult};
18
19pub fn parse_path(path: &str) -> DocumentResult<Vec<String>> {
20    if path.is_empty() {
21        return Err(DocumentError::EmptyPath);
22    }
23    let mut segments = Vec::new();
24    let mut segment = String::new();
25    let mut escaped = false;
26    for character in path.chars() {
27        if escaped {
28            match character {
29                '.' | '\\' => segment.push(character),
30                other => {
31                    return Err(DocumentError::ParseError {
32                        format: "path".to_string(),
33                        detail: format!("invalid escape `\\{other}`"),
34                    });
35                }
36            }
37            escaped = false;
38        } else {
39            match character {
40                '\\' => escaped = true,
41                '.' => segments.push(std::mem::take(&mut segment)),
42                other => segment.push(other),
43            }
44        }
45    }
46    if escaped {
47        return Err(DocumentError::ParseError {
48            format: "path".to_string(),
49            detail: "trailing path escape".to_string(),
50        });
51    }
52    segments.push(segment);
53    Ok(segments)
54}
55
56pub fn join_path(segments: &[String]) -> String {
57    segments
58        .iter()
59        .map(|segment| segment.replace('\\', "\\\\").replace('.', "\\."))
60        .collect::<Vec<_>>()
61        .join(".")
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    /// The property this grammar exists to have: every key sequence has one
69    /// spelling, and that spelling reads back as the same sequence.
70    #[test]
71    fn every_key_sequence_round_trips_through_its_spelling() {
72        let cases: &[&[&str]] = &[
73            &["packages", "", "version"],
74            &["packages", ""],
75            &["", "a"],
76            &["a", "", "", "b"],
77            &["plain"],
78            &["a.dotted.key", "b"],
79            &["back\\slash"],
80            &["node_modules/@esbuild/darwin-arm64"],
81        ];
82        for segments in cases {
83            let owned: Vec<String> = segments.iter().map(|s| (*s).to_string()).collect();
84            let spelling = join_path(&owned);
85            let parsed = parse_path(&spelling)
86                .unwrap_or_else(|error| panic!("{owned:?} spelled `{spelling}`: {error}"));
87            assert_eq!(parsed, owned, "spelled `{spelling}`");
88        }
89    }
90
91    #[test]
92    fn an_empty_segment_is_a_key_not_an_error() {
93        // npm writes `""` as the root package of every package-lock.json.
94        // Rejecting it did not make it unreachable, only unspeakable.
95        assert_eq!(
96            parse_path("packages..version").unwrap(),
97            vec!["packages".to_string(), String::new(), "version".to_string()]
98        );
99        assert_eq!(
100            parse_path("packages.").unwrap(),
101            vec!["packages".to_string(), String::new()]
102        );
103        assert_eq!(
104            parse_path(".leading").unwrap(),
105            vec![String::new(), "leading".to_string()]
106        );
107    }
108
109    #[test]
110    fn the_empty_string_still_names_no_path() {
111        // The one sequence with no spelling. `[""]` renders as "", which is
112        // how a caller says "I gave you no path" — so a `""` key at the
113        // document root stays unaddressable, and that is the only hole.
114        assert!(matches!(parse_path(""), Err(DocumentError::EmptyPath)));
115        assert_eq!(join_path(&[String::new()]), "");
116    }
117
118    #[test]
119    fn malformed_escapes_are_still_refused() {
120        assert!(parse_path(r"trailing\").is_err());
121        assert!(parse_path(r"bad\qescape").is_err());
122    }
123}