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    /// Returns the span of this meta item.
83    fn span(&self) -> proc_macro2::Span;
84}
85
86impl MetaExt for Meta {
87    fn is_path(&self) -> bool {
88        matches!(self, Self::Path(_))
89    }
90
91    fn is_list(&self) -> bool {
92        matches!(self, Self::List(_))
93    }
94
95    fn is_name_value(&self) -> bool {
96        matches!(self, Self::NameValue(_))
97    }
98
99    fn as_path(&self) -> Option<&syn::Path> {
100        match self {
101            Self::Path(p) => Some(p),
102            _ => None,
103        }
104    }
105
106    fn as_list(&self) -> Option<&syn::MetaList> {
107        match self {
108            Self::List(l) => Some(l),
109            _ => None,
110        }
111    }
112
113    fn as_name_value(&self) -> Option<&syn::MetaNameValue> {
114        match self {
115            Self::NameValue(nv) => Some(nv),
116            _ => None,
117        }
118    }
119
120    fn is(&self, name: &str) -> bool {
121        self.path().is_ident(name)
122    }
123
124    fn get(&self, path: &str) -> Option<Meta> {
125        let parsed = MetaPath::parse(path).ok()?;
126        self.resolve(&parsed)
127    }
128
129    fn nested(&self) -> Option<Vec<Meta>> {
130        let list = match self {
131            Self::List(list) => list,
132            _ => return None,
133        };
134
135        list.parse_args_with(syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated)
136            .ok()
137            .map(|p| p.into_iter().collect())
138    }
139
140    fn span(&self) -> proc_macro2::Span {
141        use syn::spanned::Spanned;
142        Spanned::span(self)
143    }
144}
145
146trait MetaExtPrivate {
147    fn resolve(&self, path: &MetaPath) -> Option<Meta>;
148}
149
150impl MetaExtPrivate for Meta {
151    fn resolve(&self, path: &MetaPath) -> Option<Meta> {
152        let seg = path.first()?;
153        let tail = path.tail();
154
155        let list = match self {
156            Self::List(list) => list,
157            _ => return None,
158        };
159
160        let nested: Vec<Meta> = list
161            .parse_args_with(syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated)
162            .ok()?
163            .into_iter()
164            .collect();
165
166        let found = match seg {
167            Segment::Key(name) => nested.iter().find(|m| m.path().is_ident(name))?.clone(),
168            Segment::Index(i) => nested.get(*i)?.clone(),
169        };
170
171        if tail.is_empty() {
172            Some(found)
173        } else {
174            found.resolve(&tail)
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn meta_from(attr_str: &str) -> Meta {
184        let item: syn::ItemStruct = syn::parse_str(&format!("{} struct Foo;", attr_str)).unwrap();
185        item.attrs.into_iter().next().unwrap().meta
186    }
187
188    mod predicates {
189        use super::*;
190
191        #[test]
192        fn path_variant() {
193            let meta = meta_from("#[test]");
194            assert!(meta.is_path());
195            assert!(!meta.is_list());
196            assert!(!meta.is_name_value());
197        }
198
199        #[test]
200        fn list_variant() {
201            let meta = meta_from("#[derive(Clone)]");
202            assert!(!meta.is_path());
203            assert!(meta.is_list());
204            assert!(!meta.is_name_value());
205        }
206
207        #[test]
208        fn name_value_variant() {
209            let meta = meta_from("#[path = \"foo\"]");
210            assert!(!meta.is_path());
211            assert!(!meta.is_list());
212            assert!(meta.is_name_value());
213        }
214    }
215
216    mod conversions {
217        use super::*;
218
219        #[test]
220        fn as_path_some() {
221            let meta = meta_from("#[test]");
222            assert!(meta.as_path().is_some());
223        }
224
225        #[test]
226        fn as_path_none() {
227            let meta = meta_from("#[derive(Clone)]");
228            assert!(meta.as_path().is_none());
229        }
230
231        #[test]
232        fn as_list_some() {
233            let meta = meta_from("#[derive(Clone)]");
234            assert!(meta.as_list().is_some());
235        }
236
237        #[test]
238        fn as_list_none() {
239            let meta = meta_from("#[test]");
240            assert!(meta.as_list().is_none());
241        }
242
243        #[test]
244        fn as_name_value_some() {
245            let meta = meta_from("#[path = \"foo\"]");
246            assert!(meta.as_name_value().is_some());
247        }
248
249        #[test]
250        fn as_name_value_none() {
251            let meta = meta_from("#[test]");
252            assert!(meta.as_name_value().is_none());
253        }
254    }
255
256    mod is {
257        use super::*;
258
259        #[test]
260        fn matching_name() {
261            let meta = meta_from("#[serde(skip)]");
262            assert!(meta.is("serde"));
263        }
264
265        #[test]
266        fn non_matching_name() {
267            let meta = meta_from("#[serde(skip)]");
268            assert!(!meta.is("derive"));
269        }
270    }
271
272    mod get {
273        use super::*;
274
275        #[test]
276        fn find_by_key() {
277            let meta = meta_from("#[serde(rename = \"id\", skip)]");
278            let found = meta.get("skip").unwrap();
279            assert!(found.is_path());
280        }
281
282        #[test]
283        fn find_name_value() {
284            let meta = meta_from("#[serde(rename = \"id\")]");
285            let found = meta.get("rename").unwrap();
286            assert!(found.is_name_value());
287        }
288
289        #[test]
290        fn find_by_index() {
291            let meta = meta_from("#[derive(Clone, Debug)]");
292            let found = meta.get("[1]").unwrap();
293            assert!(found.path().is_ident("Debug"));
294        }
295
296        #[test]
297        fn missing_key() {
298            let meta = meta_from("#[serde(skip)]");
299            assert!(meta.get("rename").is_none());
300        }
301
302        #[test]
303        fn get_on_path_variant() {
304            let meta = meta_from("#[test]");
305            assert!(meta.get("anything").is_none());
306        }
307
308        #[test]
309        fn deep_nested_key_path() {
310            let meta = meta_from("#[cfg(all(feature = \"a\", feature = \"b\"))]");
311            let all = meta.get("all").unwrap();
312            assert!(all.is_list());
313        }
314
315        #[test]
316        fn deep_nested_dot_path() {
317            let meta = meta_from("#[cfg(all(feature = \"a\", feature = \"b\"))]");
318            let feature = meta.get("all.feature").unwrap();
319            assert!(feature.is_name_value());
320        }
321
322        #[test]
323        fn deep_nested_index_path() {
324            let meta = meta_from("#[cfg(all(feature = \"a\", target_os = \"linux\"))]");
325            let second = meta.get("all.[1]").unwrap();
326            assert!(second.is_name_value());
327            assert!(second.path().is_ident("target_os"));
328        }
329
330        #[test]
331        fn three_levels_deep() {
332            let meta = meta_from("#[cfg(all(not(feature = \"a\")))]");
333            let not = meta.get("all.not").unwrap();
334            assert!(not.is_list());
335            let feature = meta.get("all.not.feature").unwrap();
336            assert!(feature.is_name_value());
337        }
338
339        #[test]
340        fn deep_missing_intermediate() {
341            let meta = meta_from("#[cfg(all(feature = \"a\"))]");
342            assert!(meta.get("all.nonexistent.feature").is_none());
343        }
344
345        #[test]
346        fn deep_index_out_of_bounds() {
347            let meta = meta_from("#[cfg(all(feature = \"a\"))]");
348            assert!(meta.get("all.[5]").is_none());
349        }
350
351        #[test]
352        fn deep_index_then_key() {
353            let meta = meta_from("#[cfg(all(not(feature = \"a\"), target_os = \"linux\"))]");
354            let feature = meta.get("all.[0].feature").unwrap();
355            assert!(feature.is_name_value());
356        }
357    }
358
359    mod nested {
360        use super::*;
361
362        #[test]
363        fn list_returns_items() {
364            let meta = meta_from("#[derive(Clone, Debug)]");
365            let items = meta.nested().unwrap();
366            assert_eq!(items.len(), 2);
367        }
368
369        #[test]
370        fn path_returns_none() {
371            let meta = meta_from("#[test]");
372            assert!(meta.nested().is_none());
373        }
374
375        #[test]
376        fn name_value_returns_none() {
377            let meta = meta_from("#[path = \"foo\"]");
378            assert!(meta.nested().is_none());
379        }
380    }
381}