Skip to main content

zyn_core/ext/
meta.rs

1//! Extension trait for `syn::Meta` inspection and nested navigation.
2//!
3//! [`MetaExt`] adds variant predicates, conversions, name checking, and
4//! dot-path querying to `syn::Meta`.
5//!
6//! # Examples
7//!
8//! ```ignore
9//! use zyn::ext::MetaExt;
10//!
11//! // #[serde(rename = "foo", skip)]
12//! if meta.is_list() {
13//!     let rename = meta.get("rename"); // → Some(NameValue meta)
14//!     let skip = meta.get("skip");     // → Some(Path meta)
15//! }
16//! ```
17
18use syn::Meta;
19
20use crate::path::{MetaPath, Segment};
21
22/// Extension methods for a single `syn::Meta`.
23///
24/// Provides variant predicates (`is_path`, `is_list`, `is_name_value`),
25/// conversions (`as_path`, `as_list`, `as_name_value`), name checking,
26/// and dot-path querying into nested meta lists.
27///
28/// # Examples
29///
30/// ```ignore
31/// use zyn::ext::MetaExt;
32///
33/// // Given #[serde(rename = "id", skip)]
34/// assert!(meta.is("serde"));
35/// assert!(meta.is_list());
36///
37/// let rename = meta.get("rename"); // → Some(NameValue meta)
38/// let skip = meta.get("skip");     // → Some(Path meta)
39/// ```
40pub trait MetaExt {
41    /// Returns `true` if this is a `Meta::Path` (bare path like `#[foo]`).
42    fn is_path(&self) -> bool;
43    /// Returns `true` if this is a `Meta::List` (e.g., `#[foo(...)]`).
44    fn is_list(&self) -> bool;
45    /// Returns `true` if this is a `Meta::NameValue` (e.g., `#[foo = expr]`).
46    fn is_name_value(&self) -> bool;
47    /// Returns the inner `syn::Path` if this is a `Meta::Path`.
48    fn as_path(&self) -> Option<&syn::Path>;
49    /// Returns the inner `syn::MetaList` if this is a `Meta::List`.
50    fn as_list(&self) -> Option<&syn::MetaList>;
51    /// Returns the inner `syn::MetaNameValue` if this is a `Meta::NameValue`.
52    fn as_name_value(&self) -> Option<&syn::MetaNameValue>;
53    /// Returns `true` if the meta's path matches the given name.
54    fn is(&self, name: &str) -> bool;
55    /// Navigates nested meta using a dot-separated path with optional index access.
56    ///
57    /// # Examples
58    ///
59    /// ```ignore
60    /// use zyn::ext::MetaExt;
61    ///
62    /// // Given meta for #[serde(rename = "id", skip)]
63    /// let rename = meta.get("rename"); // → Some(NameValue)
64    ///
65    /// // Given meta for #[derive(Clone, Debug)]
66    /// let first = meta.get("[0]"); // → Some(Path for Clone)
67    /// ```
68    fn get(&self, path: &str) -> Option<Meta>;
69    /// Parses the contents of a `Meta::List` as a `Vec<Meta>`.
70    /// Returns `None` if this is not a list variant.
71    ///
72    /// # Examples
73    ///
74    /// ```ignore
75    /// use zyn::ext::MetaExt;
76    ///
77    /// // Given meta for #[derive(Clone, Debug)]
78    /// let items = meta.nested().unwrap();
79    /// // items → [Meta::Path(Clone), Meta::Path(Debug)]
80    /// ```
81    fn nested(&self) -> Option<Vec<Meta>>;
82}
83
84impl MetaExt for Meta {
85    fn is_path(&self) -> bool {
86        matches!(self, Self::Path(_))
87    }
88
89    fn is_list(&self) -> bool {
90        matches!(self, Self::List(_))
91    }
92
93    fn is_name_value(&self) -> bool {
94        matches!(self, Self::NameValue(_))
95    }
96
97    fn as_path(&self) -> Option<&syn::Path> {
98        match self {
99            Self::Path(p) => Some(p),
100            _ => None,
101        }
102    }
103
104    fn as_list(&self) -> Option<&syn::MetaList> {
105        match self {
106            Self::List(l) => Some(l),
107            _ => None,
108        }
109    }
110
111    fn as_name_value(&self) -> Option<&syn::MetaNameValue> {
112        match self {
113            Self::NameValue(nv) => Some(nv),
114            _ => None,
115        }
116    }
117
118    fn is(&self, name: &str) -> bool {
119        self.path().is_ident(name)
120    }
121
122    fn get(&self, path: &str) -> Option<Meta> {
123        let parsed = MetaPath::parse(path).ok()?;
124        self.resolve(&parsed)
125    }
126
127    fn nested(&self) -> Option<Vec<Meta>> {
128        let list = match self {
129            Self::List(list) => list,
130            _ => return None,
131        };
132
133        list.parse_args_with(syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated)
134            .ok()
135            .map(|p| p.into_iter().collect())
136    }
137}
138
139trait MetaExtPrivate {
140    fn resolve(&self, path: &MetaPath) -> Option<Meta>;
141}
142
143impl MetaExtPrivate for Meta {
144    fn resolve(&self, path: &MetaPath) -> Option<Meta> {
145        let seg = path.first()?;
146        let tail = path.tail();
147
148        let list = match self {
149            Self::List(list) => list,
150            _ => return None,
151        };
152
153        let nested: Vec<Meta> = list
154            .parse_args_with(syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated)
155            .ok()?
156            .into_iter()
157            .collect();
158
159        let found = match seg {
160            Segment::Key(name) => nested.iter().find(|m| m.path().is_ident(name))?.clone(),
161            Segment::Index(i) => nested.get(*i)?.clone(),
162        };
163
164        if tail.is_empty() {
165            Some(found)
166        } else {
167            found.resolve(&tail)
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn meta_from(attr_str: &str) -> Meta {
177        let item: syn::ItemStruct = syn::parse_str(&format!("{} struct Foo;", attr_str)).unwrap();
178        item.attrs.into_iter().next().unwrap().meta
179    }
180
181    mod predicates {
182        use super::*;
183
184        #[test]
185        fn path_variant() {
186            let meta = meta_from("#[test]");
187            assert!(meta.is_path());
188            assert!(!meta.is_list());
189            assert!(!meta.is_name_value());
190        }
191
192        #[test]
193        fn list_variant() {
194            let meta = meta_from("#[derive(Clone)]");
195            assert!(!meta.is_path());
196            assert!(meta.is_list());
197            assert!(!meta.is_name_value());
198        }
199
200        #[test]
201        fn name_value_variant() {
202            let meta = meta_from("#[path = \"foo\"]");
203            assert!(!meta.is_path());
204            assert!(!meta.is_list());
205            assert!(meta.is_name_value());
206        }
207    }
208
209    mod conversions {
210        use super::*;
211
212        #[test]
213        fn as_path_some() {
214            let meta = meta_from("#[test]");
215            assert!(meta.as_path().is_some());
216        }
217
218        #[test]
219        fn as_path_none() {
220            let meta = meta_from("#[derive(Clone)]");
221            assert!(meta.as_path().is_none());
222        }
223
224        #[test]
225        fn as_list_some() {
226            let meta = meta_from("#[derive(Clone)]");
227            assert!(meta.as_list().is_some());
228        }
229
230        #[test]
231        fn as_list_none() {
232            let meta = meta_from("#[test]");
233            assert!(meta.as_list().is_none());
234        }
235
236        #[test]
237        fn as_name_value_some() {
238            let meta = meta_from("#[path = \"foo\"]");
239            assert!(meta.as_name_value().is_some());
240        }
241
242        #[test]
243        fn as_name_value_none() {
244            let meta = meta_from("#[test]");
245            assert!(meta.as_name_value().is_none());
246        }
247    }
248
249    mod is {
250        use super::*;
251
252        #[test]
253        fn matching_name() {
254            let meta = meta_from("#[serde(skip)]");
255            assert!(meta.is("serde"));
256        }
257
258        #[test]
259        fn non_matching_name() {
260            let meta = meta_from("#[serde(skip)]");
261            assert!(!meta.is("derive"));
262        }
263    }
264
265    mod get {
266        use super::*;
267
268        #[test]
269        fn find_by_key() {
270            let meta = meta_from("#[serde(rename = \"id\", skip)]");
271            let found = meta.get("skip").unwrap();
272            assert!(found.is_path());
273        }
274
275        #[test]
276        fn find_name_value() {
277            let meta = meta_from("#[serde(rename = \"id\")]");
278            let found = meta.get("rename").unwrap();
279            assert!(found.is_name_value());
280        }
281
282        #[test]
283        fn find_by_index() {
284            let meta = meta_from("#[derive(Clone, Debug)]");
285            let found = meta.get("[1]").unwrap();
286            assert!(found.path().is_ident("Debug"));
287        }
288
289        #[test]
290        fn missing_key() {
291            let meta = meta_from("#[serde(skip)]");
292            assert!(meta.get("rename").is_none());
293        }
294
295        #[test]
296        fn get_on_path_variant() {
297            let meta = meta_from("#[test]");
298            assert!(meta.get("anything").is_none());
299        }
300
301        #[test]
302        fn deep_nested_key_path() {
303            let meta = meta_from("#[cfg(all(feature = \"a\", feature = \"b\"))]");
304            let all = meta.get("all").unwrap();
305            assert!(all.is_list());
306        }
307
308        #[test]
309        fn deep_nested_dot_path() {
310            let meta = meta_from("#[cfg(all(feature = \"a\", feature = \"b\"))]");
311            let feature = meta.get("all.feature").unwrap();
312            assert!(feature.is_name_value());
313        }
314
315        #[test]
316        fn deep_nested_index_path() {
317            let meta = meta_from("#[cfg(all(feature = \"a\", target_os = \"linux\"))]");
318            let second = meta.get("all.[1]").unwrap();
319            assert!(second.is_name_value());
320            assert!(second.path().is_ident("target_os"));
321        }
322
323        #[test]
324        fn three_levels_deep() {
325            let meta = meta_from("#[cfg(all(not(feature = \"a\")))]");
326            let not = meta.get("all.not").unwrap();
327            assert!(not.is_list());
328            let feature = meta.get("all.not.feature").unwrap();
329            assert!(feature.is_name_value());
330        }
331
332        #[test]
333        fn deep_missing_intermediate() {
334            let meta = meta_from("#[cfg(all(feature = \"a\"))]");
335            assert!(meta.get("all.nonexistent.feature").is_none());
336        }
337
338        #[test]
339        fn deep_index_out_of_bounds() {
340            let meta = meta_from("#[cfg(all(feature = \"a\"))]");
341            assert!(meta.get("all.[5]").is_none());
342        }
343
344        #[test]
345        fn deep_index_then_key() {
346            let meta = meta_from("#[cfg(all(not(feature = \"a\"), target_os = \"linux\"))]");
347            let feature = meta.get("all.[0].feature").unwrap();
348            assert!(feature.is_name_value());
349        }
350    }
351
352    mod nested {
353        use super::*;
354
355        #[test]
356        fn list_returns_items() {
357            let meta = meta_from("#[derive(Clone, Debug)]");
358            let items = meta.nested().unwrap();
359            assert_eq!(items.len(), 2);
360        }
361
362        #[test]
363        fn path_returns_none() {
364            let meta = meta_from("#[test]");
365            assert!(meta.nested().is_none());
366        }
367
368        #[test]
369        fn name_value_returns_none() {
370            let meta = meta_from("#[path = \"foo\"]");
371            assert!(meta.nested().is_none());
372        }
373    }
374}