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