Skip to main content

pyro_macro/format/
documentation.rs

1use quote::{format_ident, quote};
2use syn::{Fields, ItemStruct, Path, Type, parse_quote};
3
4use crate::format::DocRec;
5
6/// Holds the parsed structure data required to generate the API docs.
7pub struct MagmaDocumentation {
8    pub ident: syn::Ident,
9    pub generics: syn::Generics,
10    pub doc: Option<String>,
11    pub fields: Vec<MagmaField>,
12}
13
14pub struct MagmaField {
15    pub name: String,
16    pub ty: Type,
17    pub doc: Option<String>,
18}
19
20impl MagmaDocumentation {
21    /// Phase 1: Parse the Item into our intermediate representation.
22    pub fn from_item(s: &ItemStruct, doc_rec: DocRec) -> syn::Result<Self> {
23        // 1. Extract Struct Documentation
24        let struct_docs = extract_docs(&s.attrs);
25
26        // Validation: Check if docs are required but missing
27        if struct_docs.is_none() && doc_rec.need_struct() {
28            return Err(syn::Error::new_spanned(
29                &s.ident,
30                "Client structs must have documentation (///) to generate API specs.",
31            ));
32        }
33
34        // 2. Extract Fields and their Documentation
35        let fields = match &s.fields {
36            Fields::Named(named) => named
37                .named
38                .iter()
39                .map(|f| {
40                    Ok(MagmaField {
41                        name: f.ident.as_ref().unwrap().to_string(),
42                        ty: f.ty.clone(),
43                        doc: extract_docs(&f.attrs),
44                    })
45                })
46                .collect::<syn::Result<Vec<_>>>()?,
47
48            Fields::Unnamed(unnamed) => unnamed
49                .unnamed
50                .iter()
51                .enumerate()
52                .map(|(i, f)| {
53                    Ok(MagmaField {
54                        name: i.to_string(),
55                        ty: f.ty.clone(),
56                        doc: extract_docs(&f.attrs),
57                    })
58                })
59                .collect::<syn::Result<Vec<_>>>()?,
60
61            Fields::Unit => vec![],
62        };
63
64        Ok(Self {
65            ident: s.ident.clone(),
66            generics: s.generics.clone(),
67            doc: struct_docs,
68            fields,
69        })
70    }
71
72    /// Phase 2: Generate the TokenStream from the intermediate representation.
73    pub fn generate(&self, import_location: &Path) -> syn::Result<proc_macro2::TokenStream> {
74        let values_path = quote! { #import_location::format::value };
75        let name = &self.ident;
76
77        // Add `Typeable` bound to all generics: impl<T: Typeable> Typeable for MyStruct<T>
78        let mut generics = self.generics.clone();
79        for param in &mut generics.params {
80            if let syn::GenericParam::Type(ref mut type_param) = *param {
81                type_param.bounds.push(parse_quote!(#values_path::Typeable));
82            }
83        }
84        let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
85
86        // Generate Field Entries
87        let field_entries: Vec<proc_macro2::TokenStream> = self
88            .fields
89            .iter()
90            .map(|f| {
91                let fname = &f.name;
92                let fty = &f.ty;
93
94                let doc_setter = match &f.doc {
95                    Some(doc_str) => {
96                        quote! (.add_docstring(::std::borrow::Cow::Borrowed(#doc_str)))
97                    }
98                    None => quote! {},
99                };
100
101                quote! {
102                    {
103                        let field = #values_path::PyroField::<'static>::new(
104                            #fname,
105                            <#fty as #values_path::Typeable>::pyro_type(),
106                            <#fty as #values_path::Typeable>::is_nullable(),
107                        )#doc_setter;
108                        field
109                    }
110                }
111            })
112            .collect();
113
114        // Handle Struct Documentation
115        let struct_doc_quote = match &self.doc {
116            Some(d) => quote! { Some(::std::borrow::Cow::Borrowed(#d)) },
117            None => quote! { None },
118        };
119
120        Ok(quote! {
121            impl #impl_generics #values_path::TypeableRow for #name #ty_generics #where_clause {
122                fn schema() -> #values_path::PyroSchema<'static> {
123                    #values_path::PyroSchema {
124                        fields: ::std::borrow::Cow::Owned(vec![
125                            #(#field_entries),*
126                        ]),
127                        documentation: #struct_doc_quote,
128                    }
129                }
130            }
131        })
132    }
133
134    /// Same as `generate`, but implements `TypeableRow` for `FooRef<'_>` instead of `Foo`.
135    /// The schema content (fields, types, docs) is identical — only the implementor differs.
136    pub fn generate_for_ref(
137        &self,
138        import_location: &Path,
139    ) -> syn::Result<proc_macro2::TokenStream> {
140        let values_path = quote! { #import_location::format::value };
141        let ref_name = format_ident!("{}Ref", self.ident);
142
143        let field_entries: Vec<proc_macro2::TokenStream> = self
144            .fields
145            .iter()
146            .map(|f| {
147                let fname = &f.name;
148                let fty = &f.ty;
149
150                let doc_setter = match &f.doc {
151                    Some(doc_str) => {
152                        quote! (.add_docstring(::std::borrow::Cow::Borrowed(#doc_str)))
153                    }
154                    None => quote! {},
155                };
156
157                quote! {
158                    {
159                        let mut field = #values_path::PyroField::<'static>::new(
160                            #fname,
161                            <#fty as #values_path::Typeable>::pyro_type(),
162                            <#fty as #values_path::Typeable>::is_nullable(),
163                        )#doc_setter;
164                        field
165                    }
166                }
167            })
168            .collect();
169
170        let struct_doc_quote = match &self.doc {
171            Some(d) => quote! { Some(::std::borrow::Cow::Borrowed(#d)) },
172            None => quote! { None },
173        };
174
175        Ok(quote! {
176            impl #values_path::TypeableRow for #ref_name<'_> {
177                fn schema() -> #values_path::PyroSchema<'static> {
178                    #values_path::PyroSchema {
179                        fields: ::std::borrow::Cow::Owned(vec![
180                            #(#field_entries),*
181                        ]),
182                        documentation: #struct_doc_quote,
183                    }
184                }
185            }
186        })
187    }
188}
189
190/// Generates the `Typeable` impl for the item.
191pub fn generate_documented_impl(
192    item: &ItemStruct,
193    import_location: &Path,
194    doc_rec: DocRec,
195) -> syn::Result<proc_macro2::TokenStream> {
196    // Phase 1: Parse
197    let documentation = MagmaDocumentation::from_item(item, doc_rec)?;
198
199    // Phase 2: Generate
200    documentation.generate(import_location)
201}
202
203/// Generates a `TypeableRow` impl for the `FooRef<'_>` struct produced by `deep_ref`.
204///
205/// The schema is identical to the owned type's schema — field names, types, nullability,
206/// and doc-strings are all sourced from the original struct. Only the implementor changes:
207/// `FooRef<'_>` instead of `Foo`.
208pub fn ref_documentation(
209    item: &ItemStruct,
210    import_location: &Path,
211    doc_rec: DocRec,
212) -> syn::Result<proc_macro2::TokenStream> {
213    let documentation = MagmaDocumentation::from_item(item, doc_rec)?;
214    documentation.generate_for_ref(import_location)
215}
216
217/// Helper: Iterates over attributes to find `#[doc = "..."]` and joins them.
218fn extract_docs(attrs: &[syn::Attribute]) -> Option<String> {
219    let docs: Vec<String> = attrs
220        .iter()
221        .filter(|attr| attr.path().is_ident("doc"))
222        .filter_map(|attr| match &attr.meta {
223            syn::Meta::NameValue(nv) => {
224                if let syn::Expr::Lit(syn::ExprLit {
225                    lit: syn::Lit::Str(lit),
226                    ..
227                }) = &nv.value
228                {
229                    Some(lit.value().trim().to_string())
230                } else {
231                    None
232                }
233            }
234            _ => None,
235        })
236        .collect();
237
238    if docs.is_empty() {
239        None
240    } else {
241        Some(docs.join("\n"))
242    }
243}