Skip to main content

zyn_core/path/
mod.rs

1//! Dot-separated path type for navigating nested `syn` metadata.
2//!
3//! [`MetaPath`] parses strings like `"serde.rename"` or `"derive[0]"` into
4//! a sequence of [`Segment`]s that can be used to drill into nested
5//! `syn::Meta` structures.
6//!
7//! # Syntax
8//!
9//! - `.` separates key segments
10//! - `[N]` is a positional index into list items
11//! - The first segment is always a key
12//!
13//! # Examples
14//!
15//! ```ignore
16//! use zyn::path::MetaPath;
17//!
18//! let path: MetaPath = "serde.rename".parse().unwrap();
19//! assert_eq!(path.segments().len(), 2);
20//!
21//! let path: MetaPath = "derive[0]".parse().unwrap();
22//! // → [Key("derive"), Index(0)]
23//!
24//! let path: MetaPath = "serde.container[1].value".parse().unwrap();
25//! // → [Key("serde"), Key("container"), Index(1), Key("value")]
26//! ```
27
28mod error;
29mod segment;
30
31pub use error::*;
32pub use segment::*;
33
34/// A parsed dot-separated path for navigating nested `syn` metadata.
35///
36/// Construct via [`FromStr`](std::str::FromStr) or [`MetaPath::parse`].
37///
38/// # Examples
39///
40/// ```ignore
41/// use zyn::path::MetaPath;
42///
43/// // Simple dotted path
44/// let path: MetaPath = "serde.rename".parse().unwrap();
45/// assert_eq!(path.len(), 2);
46///
47/// // With index access
48/// let path: MetaPath = "derive[0]".parse().unwrap();
49/// assert_eq!(path.len(), 2);
50/// assert_eq!(path.to_string(), "derive[0]");
51/// ```
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct MetaPath {
54    segments: Vec<Segment>,
55}
56
57impl MetaPath {
58    /// Parses a dot-separated path string.
59    ///
60    /// # Examples
61    ///
62    /// ```ignore
63    /// use zyn::path::MetaPath;
64    ///
65    /// let path = MetaPath::parse("serde.rename").unwrap();
66    /// // → [Key("serde"), Key("rename")]
67    ///
68    /// let path = MetaPath::parse("a.b[2].c").unwrap();
69    /// // → [Key("a"), Key("b"), Index(2), Key("c")]
70    /// ```
71    pub fn parse(s: &str) -> Result<Self, ParseError> {
72        if s.is_empty() {
73            return Err(ParseError::Empty);
74        }
75
76        let mut segments = Vec::new();
77        let mut chars = s.chars().peekable();
78        let mut buf = String::new();
79
80        while let Some(&ch) = chars.peek() {
81            match ch {
82                '.' => {
83                    chars.next();
84
85                    if !buf.is_empty() {
86                        segments.push(Segment::Key(std::mem::take(&mut buf)));
87                    }
88                }
89                '[' => {
90                    chars.next();
91
92                    if !buf.is_empty() {
93                        segments.push(Segment::Key(std::mem::take(&mut buf)));
94                    }
95
96                    let mut num = String::new();
97                    let mut closed = false;
98
99                    while let Some(&c) = chars.peek() {
100                        if c == ']' {
101                            chars.next();
102                            closed = true;
103                            break;
104                        }
105
106                        num.push(c);
107                        chars.next();
108                    }
109
110                    if !closed {
111                        return Err(ParseError::UnclosedBracket);
112                    }
113
114                    let index = num
115                        .parse::<usize>()
116                        .map_err(|_| ParseError::InvalidIndex(num))?;
117
118                    segments.push(Segment::Index(index));
119                }
120                _ => {
121                    buf.push(ch);
122                    chars.next();
123                }
124            }
125        }
126
127        if !buf.is_empty() {
128            segments.push(Segment::Key(buf));
129        }
130
131        Ok(Self { segments })
132    }
133
134    /// Returns the path segments.
135    pub fn segments(&self) -> &[Segment] {
136        &self.segments
137    }
138
139    /// Returns the number of segments.
140    pub fn len(&self) -> usize {
141        self.segments.len()
142    }
143
144    /// Returns `true` if the path has no segments.
145    pub fn is_empty(&self) -> bool {
146        self.segments.is_empty()
147    }
148
149    /// Returns the first segment, or `None` if the path is empty.
150    pub fn first(&self) -> Option<&Segment> {
151        self.segments.first()
152    }
153
154    /// Returns a new path with the first segment removed.
155    ///
156    /// # Examples
157    ///
158    /// ```ignore
159    /// use zyn::path::MetaPath;
160    ///
161    /// let path = MetaPath::parse("serde.rename.value").unwrap();
162    /// let tail = path.tail();
163    /// // tail → [Key("rename"), Key("value")]
164    /// ```
165    pub fn tail(&self) -> Self {
166        Self {
167            segments: self.segments.get(1..).unwrap_or_default().to_vec(),
168        }
169    }
170}
171
172impl std::str::FromStr for MetaPath {
173    type Err = ParseError;
174
175    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
176        Self::parse(s)
177    }
178}
179
180impl std::fmt::Display for MetaPath {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        let mut first = true;
183
184        for seg in &self.segments {
185            match seg {
186                Segment::Key(k) => {
187                    if !first {
188                        write!(f, ".")?;
189                    }
190
191                    write!(f, "{}", k)?;
192                }
193                Segment::Index(i) => {
194                    write!(f, "[{}]", i)?;
195                }
196            }
197
198            first = false;
199        }
200
201        Ok(())
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    mod parse {
210        use super::*;
211
212        #[test]
213        fn single_key() {
214            let path = MetaPath::parse("serde").unwrap();
215            assert_eq!(path.segments(), &[Segment::Key("serde".into())]);
216        }
217
218        #[test]
219        fn dotted_keys() {
220            let path = MetaPath::parse("serde.rename").unwrap();
221            assert_eq!(
222                path.segments(),
223                &[Segment::Key("serde".into()), Segment::Key("rename".into()),],
224            );
225        }
226
227        #[test]
228        fn index_after_key() {
229            let path = MetaPath::parse("derive[0]").unwrap();
230            assert_eq!(
231                path.segments(),
232                &[Segment::Key("derive".into()), Segment::Index(0)],
233            );
234        }
235
236        #[test]
237        fn mixed_path() {
238            let path = MetaPath::parse("a.b[2].c").unwrap();
239            assert_eq!(
240                path.segments(),
241                &[
242                    Segment::Key("a".into()),
243                    Segment::Key("b".into()),
244                    Segment::Index(2),
245                    Segment::Key("c".into()),
246                ],
247            );
248        }
249
250        #[test]
251        fn empty_string_is_err() {
252            assert_eq!(MetaPath::parse(""), Err(ParseError::Empty));
253        }
254
255        #[test]
256        fn unclosed_bracket_is_err() {
257            assert_eq!(MetaPath::parse("a[0"), Err(ParseError::UnclosedBracket));
258        }
259
260        #[test]
261        fn invalid_index_is_err() {
262            assert!(matches!(
263                MetaPath::parse("a[abc]"),
264                Err(ParseError::InvalidIndex(_)),
265            ));
266        }
267    }
268
269    mod display {
270        use super::*;
271
272        #[test]
273        fn round_trip_dotted() {
274            let path = MetaPath::parse("serde.rename").unwrap();
275            assert_eq!(path.to_string(), "serde.rename");
276        }
277
278        #[test]
279        fn round_trip_indexed() {
280            let path = MetaPath::parse("derive[0]").unwrap();
281            assert_eq!(path.to_string(), "derive[0]");
282        }
283
284        #[test]
285        fn round_trip_mixed() {
286            let path = MetaPath::parse("a.b[2].c").unwrap();
287            assert_eq!(path.to_string(), "a.b[2].c");
288        }
289    }
290
291    mod tail {
292        use super::*;
293
294        #[test]
295        fn removes_first_segment() {
296            let path = MetaPath::parse("a.b.c").unwrap();
297            let tail = path.tail();
298            assert_eq!(
299                tail.segments(),
300                &[Segment::Key("b".into()), Segment::Key("c".into())],
301            );
302        }
303
304        #[test]
305        fn single_segment_gives_empty() {
306            let path = MetaPath::parse("a").unwrap();
307            let tail = path.tail();
308            assert!(tail.is_empty());
309        }
310    }
311}