Skip to main content

yaml_edit/path/
parse.rs

1//! Parsing path strings into segments.
2
3use super::{PathError, PathSegment};
4
5/// Error from [`try_parse_path`].
6#[derive(Debug, Clone, PartialEq, Eq)]
7#[non_exhaustive]
8pub enum PathParseError {
9    /// A `[` was not closed by `]`.
10    UnclosedIndex,
11    /// The text between `[` and `]` is not a decimal `usize` index.
12    InvalidIndex(String),
13    /// The path ends with a `\` that has nothing to escape.
14    TrailingBackslash,
15    /// A segment is empty: the path starts or ends with `.`, contains
16    /// `..`, or has a `.` directly before `[`.
17    EmptySegment,
18    /// A `[...]` index is followed by something other than `.`, `[` or
19    /// the end of the path, as in `a[0]b`.
20    MissingSeparator,
21}
22
23impl std::fmt::Display for PathParseError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            PathParseError::UnclosedIndex => {
27                write!(f, "unclosed '[' in path")
28            }
29            PathParseError::InvalidIndex(text) => {
30                write!(f, "invalid path index [{text}]")
31            }
32            PathParseError::TrailingBackslash => {
33                write!(f, "path ends with an unescaped '\\'")
34            }
35            PathParseError::EmptySegment => write!(f, "empty segment in path"),
36            PathParseError::MissingSeparator => {
37                write!(f, "expected '.' or '[' after ']' in path")
38            }
39        }
40    }
41}
42
43impl std::error::Error for PathParseError {}
44
45/// Parse a path string into components.
46///
47/// Supports multiple syntaxes:
48/// - Dot notation: `"server.host"` → `[Key("server"), Key("host")]`
49/// - Array indices with brackets: `"items[0].name"` → `[Key("items"), Index(0), Key("name")]`
50/// - Array indices with dots: `"items.0.name"` → `[Key("items"), Index(0), Key("name")]`
51/// - Escaped dots: `"key\\.with\\.dots"` → `[Key("key.with.dots")]`
52/// - Escaped digits are keys: `"items.\\0"` -> `[Key("items"), Key("0")]`
53/// - Digits with leading zeros are keys: `"zip.01"` -> `[Key("zip"), Key("01")]`
54///
55/// An index is ASCII digits only, so `"items.+1"` is the key `"+1"` and
56/// `"items[+1]"` is an error. A `[...]` index may start the path and may
57/// be followed only by `.`, another `[...]` or the end of the path.
58///
59/// An empty path returns an empty list; any other path yields at least
60/// one segment or an error.
61///
62/// # Examples
63///
64/// ```
65/// use yaml_edit::path::{try_parse_path, PathSegment};
66///
67/// let segments = try_parse_path("server.host").unwrap();
68/// assert_eq!(segments, vec![
69///     PathSegment::Key("server".to_string()),
70///     PathSegment::Key("host".to_string())
71/// ]);
72///
73/// let segments = try_parse_path("items[0].name").unwrap();
74/// assert_eq!(segments, vec![
75///     PathSegment::Key("items".to_string()),
76///     PathSegment::Index(0),
77///     PathSegment::Key("name".to_string())
78/// ]);
79///
80/// let segments = try_parse_path("items.0").unwrap();
81/// assert_eq!(segments, vec![
82///     PathSegment::Key("items".to_string()),
83///     PathSegment::Index(0)
84/// ]);
85/// ```
86///
87/// # Errors
88///
89/// - [`PathParseError::UnclosedIndex`] when a `[` has no matching `]`.
90/// - [`PathParseError::InvalidIndex`] when the brackets do not contain a
91///   decimal `usize`.
92/// - [`PathParseError::TrailingBackslash`] when the path ends in an
93///   unescaped `\\`.
94/// - [`PathParseError::EmptySegment`] for `a..b`, `.a`, `a.` or `a.[0]`.
95/// - [`PathParseError::MissingSeparator`] for `a[0]b`.
96pub fn try_parse_path(path: &str) -> Result<Vec<PathSegment>, PathParseError> {
97    #[derive(PartialEq)]
98    enum State {
99        // At the start of the path, where `[0]` may appear without a key.
100        Start,
101        // Just after a `.`; a key or digits must follow.
102        AfterDot,
103        InSegment,
104        AfterIndex,
105    }
106
107    let mut segments = Vec::new();
108    let mut current = String::new();
109    // A segment with an escaped character is always a key, so `\0`
110    // names the key "0" rather than an index.
111    let mut current_has_escape = false;
112    let mut state = State::Start;
113    let mut chars = path.chars();
114
115    while let Some(ch) = chars.next() {
116        match (ch, &state) {
117            ('.', State::Start | State::AfterDot) | ('[', State::AfterDot) => {
118                return Err(PathParseError::EmptySegment);
119            }
120            ('.', State::InSegment) => {
121                segments.push(dotted_segment(&current, current_has_escape));
122                current.clear();
123                current_has_escape = false;
124                state = State::AfterDot;
125            }
126            ('.', State::AfterIndex) => state = State::AfterDot,
127            ('[', _) => {
128                if state == State::InSegment {
129                    segments.push(PathSegment::Key(std::mem::take(&mut current)));
130                    current_has_escape = false;
131                }
132                let mut index_str = String::new();
133                loop {
134                    match chars.next() {
135                        Some(']') => break,
136                        Some(c) => index_str.push(c),
137                        None => return Err(PathParseError::UnclosedIndex),
138                    }
139                }
140                let index = parse_index(&index_str)
141                    .ok_or_else(|| PathParseError::InvalidIndex(index_str.clone()))?;
142                segments.push(PathSegment::Index(index));
143                state = State::AfterIndex;
144            }
145            (_, State::AfterIndex) => return Err(PathParseError::MissingSeparator),
146            ('\\', _) => {
147                current.push(chars.next().ok_or(PathParseError::TrailingBackslash)?);
148                current_has_escape = true;
149                state = State::InSegment;
150            }
151            _ => {
152                current.push(ch);
153                state = State::InSegment;
154            }
155        }
156    }
157
158    match state {
159        State::AfterDot => return Err(PathParseError::EmptySegment),
160        State::InSegment => segments.push(dotted_segment(&current, current_has_escape)),
161        State::Start | State::AfterIndex => {}
162    }
163
164    Ok(segments)
165}
166
167/// Parse an index made of ASCII digits only; `usize::from_str` would
168/// also accept a leading `+`.
169fn parse_index(text: &str) -> Option<usize> {
170    if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
171        return None;
172    }
173    text.parse().ok()
174}
175
176/// A dot-separated segment is an index when it is a bare number written
177/// without leading zeros (`items.0`, `items.10`), and a key otherwise.
178///
179/// An index on a mapping looks up the number written out again, so a
180/// segment like `01` has to stay a key to reach the key spelled `01`.
181fn dotted_segment(text: &str, has_escape: bool) -> PathSegment {
182    match parse_index(text) {
183        Some(index) if !has_escape && (text == "0" || !text.starts_with('0')) => {
184            PathSegment::Index(index)
185        }
186        _ => PathSegment::Key(text.to_string()),
187    }
188}
189
190/// Parse a path string into components.
191///
192/// Invalid bracket indexes used to return an empty `Vec`, which callers
193/// could not tell from a real empty path. Prefer [`try_parse_path`].
194#[deprecated(note = "use try_parse_path; a bad `[index]` used to return an empty Vec")]
195pub fn parse_path(path: &str) -> Vec<PathSegment> {
196    try_parse_path(path).unwrap_or_default()
197}
198
199/// Parse `path` into a non-empty segment list, mapping the two
200/// failure modes ("unparseable" and "empty") to distinct `PathError`
201/// variants.
202pub(super) fn path_segments_required(path: &str) -> Result<Vec<PathSegment>, PathError> {
203    let segments = try_parse_path(path)?;
204    if segments.is_empty() {
205        Err(PathError::EmptyPath)
206    } else {
207        Ok(segments)
208    }
209}
210
211/// Format a segment for use in a `PathError::TypeMismatch` / `NotFound`
212/// message. Mirrors the input syntax the user would type.
213pub(super) fn segment_display(segment: &PathSegment) -> String {
214    match segment {
215        PathSegment::Key(key) => key.clone(),
216        PathSegment::Index(index) => format!("[{index}]"),
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_parse_path_basic() {
226        assert_eq!(try_parse_path("").unwrap(), Vec::<PathSegment>::new());
227        assert_eq!(
228            try_parse_path("key").unwrap(),
229            vec![PathSegment::Key("key".to_string())]
230        );
231        assert_eq!(
232            try_parse_path("a.b").unwrap(),
233            vec![
234                PathSegment::Key("a".to_string()),
235                PathSegment::Key("b".to_string())
236            ]
237        );
238        assert_eq!(
239            try_parse_path("a.b.c.d").unwrap(),
240            vec![
241                PathSegment::Key("a".to_string()),
242                PathSegment::Key("b".to_string()),
243                PathSegment::Key("c".to_string()),
244                PathSegment::Key("d".to_string())
245            ]
246        );
247    }
248
249    #[test]
250    fn test_try_parse_path_invalid_brackets() {
251        assert_eq!(
252            try_parse_path("items[abc].name"),
253            Err(PathParseError::InvalidIndex("abc".to_string()))
254        );
255        assert_eq!(
256            try_parse_path("items[].name"),
257            Err(PathParseError::InvalidIndex(String::new()))
258        );
259        assert_eq!(
260            try_parse_path("items[0"),
261            Err(PathParseError::UnclosedIndex)
262        );
263    }
264
265    #[test]
266    fn test_try_parse_path_trailing_backslash() {
267        assert_eq!(
268            try_parse_path("a\\"),
269            Err(PathParseError::TrailingBackslash)
270        );
271        assert_eq!(
272            try_parse_path("a.\\"),
273            Err(PathParseError::TrailingBackslash)
274        );
275    }
276
277    #[test]
278    fn test_try_parse_path_empty_segment() {
279        for path in [".", "..", ".a", "a.", "a..b", "a.[0]", "a[0].", "a[0]..b"] {
280            assert_eq!(
281                try_parse_path(path),
282                Err(PathParseError::EmptySegment),
283                "{path:?}"
284            );
285        }
286    }
287
288    #[test]
289    fn test_try_parse_path_missing_separator_after_index() {
290        assert_eq!(
291            try_parse_path("a[0]b"),
292            Err(PathParseError::MissingSeparator)
293        );
294        assert_eq!(
295            try_parse_path("a[0]\\.b"),
296            Err(PathParseError::MissingSeparator)
297        );
298    }
299
300    #[test]
301    fn test_try_parse_path_index_must_be_digits() {
302        assert_eq!(
303            try_parse_path("a[+1]"),
304            Err(PathParseError::InvalidIndex("+1".to_string()))
305        );
306        assert_eq!(
307            try_parse_path("a.+1").unwrap(),
308            vec![
309                PathSegment::Key("a".to_string()),
310                PathSegment::Key("+1".to_string())
311            ]
312        );
313    }
314
315    #[test]
316    fn test_try_parse_path_index_chains() {
317        assert_eq!(
318            try_parse_path("[0][1].a").unwrap(),
319            vec![
320                PathSegment::Index(0),
321                PathSegment::Index(1),
322                PathSegment::Key("a".to_string())
323            ]
324        );
325        assert_eq!(
326            try_parse_path("a[0].1").unwrap(),
327            vec![
328                PathSegment::Key("a".to_string()),
329                PathSegment::Index(0),
330                PathSegment::Index(1)
331            ]
332        );
333    }
334
335    #[test]
336    #[allow(deprecated)]
337    fn test_parse_path_deprecated_wrapper_empty_on_bad_index() {
338        assert_eq!(parse_path("items[abc].name"), Vec::<PathSegment>::new());
339        assert_eq!(parse_path("items[0"), Vec::<PathSegment>::new());
340    }
341
342    #[test]
343    fn test_parse_path_with_array_indices() {
344        assert_eq!(
345            try_parse_path("items[0]").unwrap(),
346            vec![PathSegment::Key("items".to_string()), PathSegment::Index(0)]
347        );
348        assert_eq!(
349            try_parse_path("items[0].name").unwrap(),
350            vec![
351                PathSegment::Key("items".to_string()),
352                PathSegment::Index(0),
353                PathSegment::Key("name".to_string())
354            ]
355        );
356        assert_eq!(
357            try_parse_path("data.items[5].value").unwrap(),
358            vec![
359                PathSegment::Key("data".to_string()),
360                PathSegment::Key("items".to_string()),
361                PathSegment::Index(5),
362                PathSegment::Key("value".to_string())
363            ]
364        );
365    }
366
367    #[test]
368    fn test_parse_path_with_numeric_indices() {
369        assert_eq!(
370            try_parse_path("items.0").unwrap(),
371            vec![PathSegment::Key("items".to_string()), PathSegment::Index(0)]
372        );
373        assert_eq!(
374            try_parse_path("items.0.name").unwrap(),
375            vec![
376                PathSegment::Key("items".to_string()),
377                PathSegment::Index(0),
378                PathSegment::Key("name".to_string())
379            ]
380        );
381    }
382
383    #[test]
384    fn test_parse_path_with_escaping() {
385        assert_eq!(
386            try_parse_path("key\\.with\\.dots").unwrap(),
387            vec![PathSegment::Key("key.with.dots".to_string())]
388        );
389        assert_eq!(
390            try_parse_path("a.key\\.with\\.dots.b").unwrap(),
391            vec![
392                PathSegment::Key("a".to_string()),
393                PathSegment::Key("key.with.dots".to_string()),
394                PathSegment::Key("b".to_string())
395            ]
396        );
397        // An escaped digit is part of a key, not an index.
398        assert_eq!(
399            try_parse_path("a.\\0").unwrap(),
400            vec![
401                PathSegment::Key("a".to_string()),
402                PathSegment::Key("0".to_string())
403            ]
404        );
405        assert_eq!(
406            try_parse_path("\\1.b").unwrap(),
407            vec![
408                PathSegment::Key("1".to_string()),
409                PathSegment::Key("b".to_string())
410            ]
411        );
412    }
413}