Skip to main content

oneyaml_core/
path.rs

1//! Paths to nodes, e.g. `spec.ports[2].port`.
2
3/// One step of a [`Path`].
4#[non_exhaustive]
5#[derive(Clone, Debug, PartialEq, Eq, Hash)]
6pub enum PathSegment {
7    /// A mapping key, as text.
8    Key(String),
9    /// A sequence index.
10    Index(usize),
11}
12
13/// A path from the document root to a node.
14///
15/// A flat list of segments: no recursion anywhere in its impls.
16///
17/// ```
18/// use oneyaml_core::{Path, PathSegment};
19///
20/// let p = Path::new().with_key("spec").with_key("ports").with_index(2);
21/// assert_eq!(p.to_string(), "spec.ports[2]");
22/// assert_eq!(p.segments().last(), Some(&PathSegment::Index(2)));
23/// let mut q = p.clone();
24/// q.push_key("port");
25/// assert_eq!((q.len(), q.pop()), (4, Some(PathSegment::Key("port".into()))));
26/// q.push_index(0);
27/// assert_eq!(q.to_string(), "spec.ports[2][0]");
28/// assert!(!q.is_empty() && Path::new().is_empty());
29/// ```
30#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
31pub struct Path {
32    segments: Vec<PathSegment>,
33}
34
35impl Path {
36    /// The root path (no segments).
37    #[must_use]
38    pub const fn new() -> Self {
39        Self {
40            segments: Vec::new(),
41        }
42    }
43
44    /// Appends a mapping-key segment.
45    pub fn push_key(&mut self, key: impl Into<String>) {
46        self.segments.push(PathSegment::Key(key.into()));
47    }
48
49    /// Appends a sequence-index segment.
50    pub fn push_index(&mut self, index: usize) {
51        self.segments.push(PathSegment::Index(index));
52    }
53
54    /// Chainable [`Path::push_key`].
55    #[must_use]
56    pub fn with_key(mut self, key: impl Into<String>) -> Self {
57        self.push_key(key);
58        self
59    }
60
61    /// Chainable [`Path::push_index`].
62    #[must_use]
63    pub fn with_index(mut self, index: usize) -> Self {
64        self.push_index(index);
65        self
66    }
67
68    /// Removes and returns the last segment.
69    pub fn pop(&mut self) -> Option<PathSegment> {
70        self.segments.pop()
71    }
72
73    /// The segments, root first.
74    #[must_use]
75    pub fn segments(&self) -> &[PathSegment] {
76        &self.segments
77    }
78
79    /// `true` for the root path.
80    #[must_use]
81    pub fn is_empty(&self) -> bool {
82        self.segments.is_empty()
83    }
84
85    /// Number of segments.
86    #[must_use]
87    pub fn len(&self) -> usize {
88        self.segments.len()
89    }
90}
91
92/// Builds a [`Path`] from keys and indices:
93/// `path!["spec", "containers", 0, "image"]`.
94///
95/// String-like segments (`&str`, `String`) are keys; integers are indices.
96/// A negative index names no item (it becomes `usize::MAX`).
97///
98/// ```
99/// use oneyaml_core::{path, Path};
100/// let p = path!["spec", "ports", 2, "port"];
101/// assert_eq!(p.to_string(), "spec.ports[2].port");
102/// assert_eq!(path![], Path::new());
103/// ```
104#[macro_export]
105macro_rules! path {
106    () => { $crate::Path::new() };
107    ($($segment:expr),+ $(,)?) => {{
108        let mut p = $crate::Path::new();
109        $( $crate::__private::push_segment(&mut p, $segment); )+
110        p
111    }};
112}
113
114/// Support for [`path!`]; not public API.
115#[doc(hidden)]
116pub mod __private {
117    use super::{Path, PathSegment};
118
119    /// A value `path!` accepts as a segment.
120    pub trait IntoSegment {
121        /// The segment.
122        fn into_segment(self) -> PathSegment;
123    }
124
125    impl IntoSegment for &str {
126        fn into_segment(self) -> PathSegment {
127            PathSegment::Key(self.to_owned())
128        }
129    }
130    impl IntoSegment for String {
131        fn into_segment(self) -> PathSegment {
132            PathSegment::Key(self)
133        }
134    }
135    impl IntoSegment for &String {
136        fn into_segment(self) -> PathSegment {
137            PathSegment::Key(self.clone())
138        }
139    }
140    impl IntoSegment for usize {
141        fn into_segment(self) -> PathSegment {
142            PathSegment::Index(self)
143        }
144    }
145    // Unsuffixed integer literals are `i32`.
146    impl IntoSegment for i32 {
147        fn into_segment(self) -> PathSegment {
148            PathSegment::Index(usize::try_from(self).unwrap_or(usize::MAX))
149        }
150    }
151    impl IntoSegment for u32 {
152        fn into_segment(self) -> PathSegment {
153            PathSegment::Index(usize::try_from(self).unwrap_or(usize::MAX))
154        }
155    }
156    impl IntoSegment for u64 {
157        fn into_segment(self) -> PathSegment {
158            PathSegment::Index(usize::try_from(self).unwrap_or(usize::MAX))
159        }
160    }
161
162    /// Appends one segment.
163    pub fn push_segment(path: &mut Path, segment: impl IntoSegment) {
164        path.segments.push(segment.into_segment());
165    }
166}
167
168/// Why a path string is not a [`Path`] (see [`Path::parse`]).
169#[derive(Clone, Debug, PartialEq, Eq, Hash)]
170pub struct ParsePathError {
171    offset: usize,
172    reason: &'static str,
173}
174
175impl ParsePathError {
176    /// The UTF-8 byte offset in the path string where it went wrong.
177    #[must_use]
178    pub fn offset(&self) -> usize {
179        self.offset
180    }
181
182    /// What went wrong.
183    #[must_use]
184    pub fn reason(&self) -> &'static str {
185        self.reason
186    }
187}
188
189impl core::fmt::Display for ParsePathError {
190    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
191        write!(f, "invalid path at byte {}: {}", self.offset, self.reason)
192    }
193}
194
195impl std::error::Error for ParsePathError {}
196
197/// A character a bare (unquoted) key may hold: what [`Path`]'s `Display`
198/// writes unquoted.
199fn bare_char(c: char) -> bool {
200    !(matches!(c, '.' | '[' | ']' | '"' | '\\') || c.is_whitespace() || c.is_control())
201}
202
203impl Path {
204    /// Parses a path string: the inverse of `Display`, so
205    /// `Path::parse(&p.to_string()) == Ok(p)` for every path.
206    ///
207    /// The syntax:
208    ///
209    /// * keys are separated by `.`: `spec.template.metadata`;
210    /// * `[n]` (decimal digits) is a sequence index: `containers[0]`,
211    ///   `[0]` at the start, `m[1][2]` for nested sequences;
212    /// * a key holding `.`, `[`, `]`, `"`, `\`, white space or a control
213    ///   character, or an empty key, is written in double quotes, where
214    ///   `\"` is a quote, `\\` a backslash and `\u{hex}` any character:
215    ///   `a."b.c"[0]`, `""` (the empty key), `"say \"hi\""`;
216    /// * `.` alone is the root path (what `Display` writes for it); the
217    ///   empty string is an error, so an empty key from user input never
218    ///   names the whole document.
219    ///
220    /// Every key is text: `[n]` is always an index, and `a.1` is the key
221    /// `"1"`.
222    ///
223    /// ```
224    /// use oneyaml_core::{Path, path};
225    ///
226    /// let p = Path::parse("spec.containers[0].\"app.kubernetes.io/name\"").unwrap();
227    /// assert_eq!(p, path!["spec", "containers", 0, "app.kubernetes.io/name"]);
228    /// assert_eq!(Path::parse(&p.to_string()), Ok(p));
229    /// assert_eq!(Path::parse("."), Ok(Path::new()));
230    /// assert_eq!(
231    ///     Path::parse("").unwrap_err().reason(),
232    ///     "an empty path: write `.` for the root"
233    /// );
234    /// let err = Path::parse("a..b").unwrap_err();
235    /// assert_eq!(err.offset(), 2);
236    /// assert_eq!(err.reason(), "expected a key after `.`");
237    /// ```
238    ///
239    /// # Errors
240    ///
241    /// [`ParsePathError`], with the byte offset and the reason, for
242    /// anything else: the empty string, an empty segment (`a..b`, `a.`, `.a`), a bare key
243    /// with a reserved character (`a b`, `a]`), an unclosed quote or `[`,
244    /// a non-digit or overflowing index, an unknown escape, or text right
245    /// after a quoted key or an index (`"a"b`, `[0]x`).
246    pub fn parse(s: &str) -> Result<Path, ParsePathError> {
247        let err = |offset: usize, reason: &'static str| ParsePathError { offset, reason };
248        let mut path = Path::new();
249        // `""` is not the root, so an empty user key handed to
250        // `set_path` cannot replace the whole document; `Display` writes the
251        // root as `.`, so the round trip is unaffected.
252        if s.is_empty() {
253            return Err(err(0, "an empty path: write `.` for the root"));
254        }
255        if s == "." {
256            return Ok(path);
257        }
258        let mut chars = s.char_indices().peekable();
259        // One loop over the characters, no recursion.
260        let mut first = true;
261        while let Some(&(at, c)) = chars.peek() {
262            match c {
263                '[' => {
264                    chars.next();
265                    let mut index: usize = 0;
266                    let mut digits = 0usize;
267                    loop {
268                        match chars.next() {
269                            Some((_, ']')) if digits > 0 => break,
270                            Some((_, ']')) => return Err(err(at, "an index needs digits: `[0]`")),
271                            Some((p, d)) => {
272                                let Some(v) = d.to_digit(10) else {
273                                    return Err(err(p, "an index is decimal digits only"));
274                                };
275                                index = index
276                                    .checked_mul(10)
277                                    .and_then(|i| i.checked_add(v as usize))
278                                    .ok_or_else(|| err(at, "the index is too large"))?;
279                                digits = digits.saturating_add(1);
280                            }
281                            None => return Err(err(at, "unclosed `[`")),
282                        }
283                    }
284                    path.push_index(index);
285                }
286                '.' if !first => {
287                    chars.next();
288                    let next = chars.peek().copied();
289                    match next {
290                        Some((_, '"')) => path.push_key(quoted(&mut chars)?),
291                        Some((p, c)) if bare_char(c) => {
292                            path.push_key(bare(s, p, &mut chars));
293                        }
294                        Some((p, _)) => {
295                            return Err(err(p, "expected a key after `.`"));
296                        }
297                        None => return Err(err(s.len(), "expected a key after `.`")),
298                    }
299                }
300                '"' if first => path.push_key(quoted(&mut chars)?),
301                c if first && bare_char(c) => path.push_key(bare(s, at, &mut chars)),
302                '.' => return Err(err(at, "a path starts with a key or an index")),
303                _ if first => {
304                    return Err(err(at, "quote this key: `\"…\"`"));
305                }
306                _ => return Err(err(at, "expected `.` or `[` after a segment")),
307            }
308            first = false;
309        }
310        Ok(path)
311    }
312}
313
314impl core::str::FromStr for Path {
315    type Err = ParsePathError;
316
317    /// As [`Path::parse`].
318    fn from_str(s: &str) -> Result<Self, Self::Err> {
319        Path::parse(s)
320    }
321}
322
323type Chars<'a> = core::iter::Peekable<core::str::CharIndices<'a>>;
324
325/// A bare key starting at `start` (its first character is a bare one).
326fn bare(s: &str, start: usize, chars: &mut Chars<'_>) -> String {
327    let mut end = s.len();
328    while let Some(&(p, c)) = chars.peek() {
329        if !bare_char(c) {
330            end = p;
331            break;
332        }
333        chars.next();
334    }
335    s.get(start..end).unwrap_or_default().to_owned()
336}
337
338/// A quoted key; the next character is its opening `"`.
339fn quoted(chars: &mut Chars<'_>) -> Result<String, ParsePathError> {
340    let err = |offset: usize, reason: &'static str| ParsePathError { offset, reason };
341    let open = chars.next().map_or(0, |(p, _)| p);
342    let mut key = String::new();
343    loop {
344        match chars.next() {
345            Some((_, '"')) => return Ok(key),
346            Some((p, '\\')) => match chars.next() {
347                Some((_, '"')) => key.push('"'),
348                Some((_, '\\')) => key.push('\\'),
349                Some((_, 'u')) => {
350                    if !matches!(chars.next(), Some((_, '{'))) {
351                        return Err(err(p, "write `\\u{hex}`"));
352                    }
353                    let mut code: u32 = 0;
354                    let mut digits = 0usize;
355                    loop {
356                        match chars.next() {
357                            Some((_, '}')) if digits > 0 => break,
358                            Some((_, h)) if digits < 6 && h.is_ascii_hexdigit() => {
359                                let v = h.to_digit(16).unwrap_or(0);
360                                code = code.wrapping_mul(16).wrapping_add(v);
361                                digits = digits.saturating_add(1);
362                            }
363                            _ => return Err(err(p, "write `\\u{hex}` with 1 to 6 hex digits")),
364                        }
365                    }
366                    let c = char::from_u32(code)
367                        .ok_or_else(|| err(p, "`\\u{…}` names no character"))?;
368                    key.push(c);
369                }
370                _ => return Err(err(p, "unknown escape: use `\\\"`, `\\\\` or `\\u{hex}`")),
371            },
372            Some((_, c)) => key.push(c),
373            None => return Err(err(open, "unclosed `\"`")),
374        }
375    }
376}
377
378fn needs_quotes(key: &str) -> bool {
379    key.is_empty()
380        || key.chars().any(|c| {
381            matches!(c, '.' | '[' | ']' | '"' | '\\') || c.is_whitespace() || c.is_control()
382        })
383}
384
385impl core::fmt::Display for Path {
386    /// `a.b[2].c`; keys that are empty or contain `.[]"\`, whitespace, or
387    /// control characters are written as `"…"` with `\"`/`\\` escaped; the
388    /// root path is written as `.`.
389    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
390        if self.segments.is_empty() {
391            return f.write_str(".");
392        }
393        for (i, segment) in self.segments.iter().enumerate() {
394            match segment {
395                PathSegment::Index(n) => write!(f, "[{n}]")?,
396                PathSegment::Key(key) => {
397                    if i != 0 {
398                        f.write_str(".")?;
399                    }
400                    if needs_quotes(key) {
401                        f.write_str("\"")?;
402                        for c in key.chars() {
403                            match c {
404                                '"' => f.write_str("\\\"")?,
405                                '\\' => f.write_str("\\\\")?,
406                                c if c.is_control() => write!(f, "\\u{{{:x}}}", u32::from(c))?,
407                                c => write!(f, "{c}")?,
408                            }
409                        }
410                        f.write_str("\"")?;
411                    } else {
412                        f.write_str(key)?;
413                    }
414                }
415            }
416        }
417        Ok(())
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
424
425    use super::*;
426
427    #[test]
428    fn display() {
429        assert_eq!(Path::new().to_string(), ".");
430        let p = Path::new()
431            .with_key("spec")
432            .with_key("ports")
433            .with_index(2)
434            .with_key("port");
435        assert_eq!(p.to_string(), "spec.ports[2].port");
436        assert_eq!(Path::new().with_index(0).to_string(), "[0]");
437        assert_eq!(
438            Path::new()
439                .with_key("a.b")
440                .with_key("")
441                .with_key("q\"\\\n")
442                .to_string(),
443            "\"a.b\".\"\".\"q\\\"\\\\\\u{a}\""
444        );
445    }
446
447    #[test]
448    fn path_macro() {
449        let key = String::from("k");
450        let p = crate::path!["a", 0, &key, key.clone(), 3usize, 4u32, 5u64, -1];
451        assert_eq!(
452            p.segments(),
453            &[
454                PathSegment::Key("a".into()),
455                PathSegment::Index(0),
456                PathSegment::Key("k".into()),
457                PathSegment::Key("k".into()),
458                PathSegment::Index(3),
459                PathSegment::Index(4),
460                PathSegment::Index(5),
461                PathSegment::Index(usize::MAX),
462            ]
463        );
464        assert_eq!(crate::path![], Path::new());
465        assert_eq!(crate::path!["x",], Path::new().with_key("x"));
466    }
467
468    #[test]
469    fn parse_round_trips_display() {
470        let keys = [
471            "a", "spec", "a.b", "", "q\"\\\n", "[0]", "a]", "x y", "\u{85}", "été", "1", "-", "\t",
472            "\\", "\"",
473        ];
474        for k in keys {
475            for p in [
476                Path::new().with_key(k),
477                Path::new().with_key(k).with_index(3).with_key(k),
478                Path::new().with_index(0).with_key(k).with_key("z"),
479                Path::new().with_index(1).with_index(22),
480            ] {
481                assert_eq!(Path::parse(&p.to_string()), Ok(p.clone()), "{p}");
482                assert_eq!(p.to_string().parse::<Path>(), Ok(p));
483            }
484        }
485        assert_eq!(Path::parse("."), Ok(Path::new()));
486        assert_eq!(Path::parse(&Path::new().to_string()), Ok(Path::new()));
487        assert_eq!(
488            Path::parse("spec.template.containers[0].image"),
489            Ok(crate::path!["spec", "template", "containers", 0, "image"])
490        );
491        assert_eq!(Path::parse("a.\"b.c\"[0]"), Ok(crate::path!["a", "b.c", 0]));
492        assert_eq!(Path::parse("a.1"), Ok(crate::path!["a", "1"]));
493        assert_eq!(Path::parse("[007]"), Ok(crate::path![7]));
494        assert_eq!(Path::parse("\"\\u{1F600}\""), Ok(crate::path!["\u{1F600}"]));
495    }
496
497    #[test]
498    fn parse_errors() {
499        let cases: [(&str, usize); 23] = [
500            ("", 0),
501            ("a..b", 2),
502            ("a.", 2),
503            (".a", 0),
504            ("..", 0),
505            ("a b", 1),
506            ("a]", 1),
507            ("]", 0),
508            ("a[", 1),
509            ("a[]", 1),
510            ("a[x]", 2),
511            ("a[-1]", 2),
512            ("a[1", 1),
513            ("a[99999999999999999999999]", 1),
514            ("\"a", 0),
515            ("\"a\"b", 3),
516            ("[0]x", 3),
517            ("\"\\x\"", 1),
518            ("\"\\u{110000}\"", 1),
519            ("\"\\u{}\"", 1),
520            ("\"\\u{1234567}\"", 1),
521            ("\"\\u41\"", 1),
522            (" a", 0),
523        ];
524        for (s, offset) in cases {
525            let e = Path::parse(s).unwrap_err();
526            assert_eq!(e.offset(), offset, "{s:?}: {e}");
527            assert!(!e.reason().is_empty());
528            assert!(e.to_string().starts_with("invalid path at byte "));
529        }
530    }
531
532    #[test]
533    fn push_pop_and_accessors() {
534        let mut p = Path::new();
535        assert!(p.is_empty());
536        p.push_key("a");
537        p.push_index(3);
538        assert_eq!(p.len(), 2);
539        assert_eq!(
540            p.segments(),
541            &[PathSegment::Key("a".into()), PathSegment::Index(3)]
542        );
543        assert_eq!(p.pop(), Some(PathSegment::Index(3)));
544        assert_eq!(p.pop(), Some(PathSegment::Key("a".into())));
545        assert_eq!(p.pop(), None);
546        assert_eq!(p, Path::default());
547    }
548}