syncdoc_core/
doc_injector.rs

1//! Procedural macro attributes for automatically injecting documentation from files.
2
3use proc_macro2::TokenStream;
4use quote::quote;
5use unsynn::*;
6
7use crate::parse::{FnSig, SyncDocArg, SyncDocInner};
8
9pub fn syncdoc_impl(
10    args: TokenStream,
11    item: TokenStream,
12) -> core::result::Result<TokenStream, TokenStream> {
13    // Parse the syncdoc arguments
14    let syncdoc_args = match parse_syncdoc_args(&mut args.to_token_iter()) {
15        Ok(args) => args,
16        Err(e) => {
17            // Return both error and original item to preserve valid syntax
18            return Ok(quote! {
19                compile_error!(#e);
20                #item
21            });
22        }
23    };
24
25    // Parse the function
26    let mut item_iter = item.to_token_iter();
27    let func = match parse_simple_function(&mut item_iter) {
28        Ok(func) => func,
29        Err(e) => {
30            return Ok(quote! {
31                compile_error!(#e);
32                #item
33            });
34        }
35    };
36
37    Ok(generate_documented_function(syncdoc_args, func))
38}
39
40#[derive(Debug)]
41struct SyncDocArgs {
42    base_path: String,
43    name: Option<String>,
44    cfg_attr: Option<String>,
45}
46
47struct SimpleFunction {
48    attrs: Vec<TokenStream>,
49    vis: Option<TokenStream>,
50    const_kw: Option<TokenStream>,
51    async_kw: Option<TokenStream>,
52    unsafe_kw: Option<TokenStream>,
53    extern_kw: Option<TokenStream>,
54    fn_name: proc_macro2::Ident,
55    generics: Option<TokenStream>,
56    params: TokenStream,
57    ret_type: Option<TokenStream>,
58    where_clause: Option<TokenStream>,
59    body: TokenStream,
60}
61
62fn parse_syncdoc_args(input: &mut TokenIter) -> core::result::Result<SyncDocArgs, String> {
63    match input.parse::<SyncDocInner>() {
64        Ok(parsed) => {
65            let mut args = SyncDocArgs {
66                base_path: String::new(),
67                name: None,
68                cfg_attr: None,
69            };
70
71            if let Some(arg_list) = parsed.args {
72                for arg in arg_list.0 {
73                    match arg.value {
74                        SyncDocArg::Path(path_arg) => {
75                            args.base_path = path_arg.value.as_str().to_string();
76                        }
77                        SyncDocArg::Name(name_arg) => {
78                            args.name = Some(name_arg.value.as_str().to_string());
79                        }
80                        SyncDocArg::CfgAttr(cfg_attr_arg) => {
81                            args.cfg_attr = Some(cfg_attr_arg.value.as_str().to_string());
82                        }
83                    }
84                }
85            }
86
87            if args.base_path.is_empty() || args.cfg_attr.is_none() {
88                // If macro path and TOML docs-path both unset, we don't know where to find the docs
89                if args.base_path.is_empty() {
90                    // Get the call site's file path if there might be config we could use there
91                    let call_site = proc_macro2::Span::call_site();
92                    let source_file = call_site
93                        .local_file()
94                        .ok_or("Could not determine source file location")?
95                        .to_string_lossy()
96                        .to_string();
97
98                    args.base_path = crate::config::get_docs_path(&source_file)
99                        .map_err(|e| format!("Failed to get docs path from config: {}", e))?;
100                }
101
102                // We don't error on unconfigured cfg_attr, it's optional
103                if let Ok(cfg) = crate::config::get_cfg_attr() {
104                    args.cfg_attr = cfg;
105                }
106            }
107
108            Ok(args)
109        }
110        Err(e) => Err(format!("Failed to parse syncdoc args: {}", e)),
111    }
112}
113
114fn parse_simple_function(input: &mut TokenIter) -> core::result::Result<SimpleFunction, String> {
115    match input.parse::<FnSig>() {
116        Ok(parsed) => {
117            // Handle attributes
118            let attrs = if let Some(attr_list) = parsed.attributes {
119                attr_list
120                    .0
121                    .into_iter()
122                    .map(|attr| {
123                        let mut tokens = TokenStream::new();
124                        unsynn::ToTokens::to_tokens(&attr, &mut tokens);
125                        tokens
126                    })
127                    .collect()
128            } else {
129                Vec::new()
130            };
131
132            // Handle visibility
133            let vis = parsed.visibility.map(|v| {
134                let mut tokens = TokenStream::new();
135                quote::ToTokens::to_tokens(&v, &mut tokens);
136                tokens
137            });
138
139            // Handle const keyword
140            let const_kw = parsed.const_kw.map(|k| {
141                let mut tokens = TokenStream::new();
142                unsynn::ToTokens::to_tokens(&k, &mut tokens);
143                tokens
144            });
145
146            // Handle async keyword
147            let async_kw = parsed.async_kw.map(|k| {
148                let mut tokens = TokenStream::new();
149                unsynn::ToTokens::to_tokens(&k, &mut tokens);
150                tokens
151            });
152
153            // Handle unsafe keyword
154            let unsafe_kw = parsed.unsafe_kw.map(|k| {
155                let mut tokens = TokenStream::new();
156                unsynn::ToTokens::to_tokens(&k, &mut tokens);
157                tokens
158            });
159
160            // Handle extern keyword
161            let extern_kw = parsed.extern_kw.map(|k| {
162                let mut tokens = TokenStream::new();
163                unsynn::ToTokens::to_tokens(&k, &mut tokens);
164                tokens
165            });
166
167            let fn_name = parsed.name;
168
169            let generics = parsed.generics.map(|g| {
170                let mut tokens = TokenStream::new();
171                unsynn::ToTokens::to_tokens(&g, &mut tokens);
172                tokens
173            });
174
175            let mut params = TokenStream::new();
176            unsynn::ToTokens::to_tokens(&parsed.params, &mut params);
177
178            let ret_type = parsed.return_type.map(|rt| {
179                let mut tokens = TokenStream::new();
180                unsynn::ToTokens::to_tokens(&rt, &mut tokens);
181                tokens
182            });
183
184            let where_clause = parsed.where_clause.map(|wc| {
185                let mut tokens = TokenStream::new();
186                unsynn::ToTokens::to_tokens(&wc, &mut tokens);
187                tokens
188            });
189
190            let mut body = TokenStream::new();
191            unsynn::ToTokens::to_tokens(&parsed.body, &mut body);
192
193            Ok(SimpleFunction {
194                attrs,
195                vis,
196                const_kw,
197                async_kw,
198                unsafe_kw,
199                extern_kw,
200                fn_name,
201                generics,
202                params,
203                ret_type,
204                where_clause,
205                body,
206            })
207        }
208        Err(e) => Err(format!("Failed to parse function: {}", e)),
209    }
210}
211
212fn generate_documented_function(args: SyncDocArgs, func: SimpleFunction) -> TokenStream {
213    let SimpleFunction {
214        attrs,
215        vis,
216        const_kw,
217        async_kw,
218        unsafe_kw,
219        extern_kw,
220        fn_name,
221        generics,
222        params,
223        ret_type,
224        where_clause,
225        body,
226    } = func;
227
228    // Construct the doc path
229    let doc_file_name = args.name.unwrap_or_else(|| fn_name.to_string());
230    let doc_path = if args.base_path.ends_with(".md") {
231        // Direct file path provided
232        args.base_path
233    } else {
234        // Directory path provided, append function name
235        format!("{}/{}.md", args.base_path, doc_file_name)
236    };
237
238    // Generate tokens for all the modifiers
239    let vis_tokens = vis.unwrap_or_default();
240    let const_tokens = const_kw.unwrap_or_default();
241    let async_tokens = async_kw.unwrap_or_default();
242    let unsafe_tokens = unsafe_kw.unwrap_or_default();
243    let extern_tokens = extern_kw.unwrap_or_default();
244    let generics_tokens = generics.unwrap_or_default();
245    let ret_tokens = ret_type.unwrap_or_default();
246    let where_tokens = where_clause.unwrap_or_default();
247
248    // Generate the documented function
249    let doc_attr = if let Some(cfg_value) = args.cfg_attr {
250        let cfg_ident = proc_macro2::Ident::new(&cfg_value, proc_macro2::Span::call_site());
251        quote! { #[cfg_attr(#cfg_ident, doc = include_str!(#doc_path))] }
252    } else {
253        quote! { #[doc = include_str!(#doc_path)] }
254    };
255
256    quote! {
257        #(#attrs)*
258        #doc_attr
259        #vis_tokens #const_tokens #async_tokens #unsafe_tokens #extern_tokens fn #fn_name #generics_tokens #params #ret_tokens #where_tokens #body
260    }
261}
262
263/// Injects a doc attribute without parsing the item structure
264pub fn inject_doc_attr(
265    doc_path: String,
266    cfg_attr: Option<String>,
267    item: TokenStream,
268) -> TokenStream {
269    if let Some(cfg_value) = cfg_attr {
270        let cfg_ident = proc_macro2::Ident::new(&cfg_value, proc_macro2::Span::call_site());
271        quote! {
272            #[cfg_attr(#cfg_ident, doc = include_str!(#doc_path))]
273            #item
274        }
275    } else {
276        quote! {
277            #[doc = include_str!(#doc_path)]
278            #item
279        }
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use rust_format::{Formatter, RustFmt};
287
288    fn format_and_print(tokens: proc_macro2::TokenStream) -> String {
289        let fmt_str = RustFmt::default()
290            .format_tokens(tokens)
291            .unwrap_or_else(|e| panic!("Format error: {}", e));
292        println!("Generated code: {}", fmt_str);
293        fmt_str
294    }
295
296    #[test]
297    fn test_basic_doc_injection() {
298        let args = quote!(path = "../docs");
299        let item = quote! {
300            fn test_function(x: u32) -> u32 {
301                x + 1
302            }
303        };
304
305        let result = syncdoc_impl(args, item);
306        assert!(result.is_ok());
307
308        let output = result.unwrap();
309        let output_str = format_and_print(output);
310
311        assert!(output_str.replace(" ", "").contains("include_str!"));
312        assert!(output_str.contains("../docs/test_function.md"));
313        assert!(output_str.contains("fn test_function"));
314    }
315
316    #[test]
317    fn test_custom_name() {
318        let args = quote!(path = "../docs", name = "custom");
319        let item = quote! {
320            fn test_function() {}
321        };
322
323        let result = syncdoc_impl(args, item);
324        assert!(result.is_ok());
325
326        let output = result.unwrap();
327        let output_str = format_and_print(output);
328
329        assert!(output_str.contains("../docs/custom.md"));
330    }
331
332    #[test]
333    fn test_async_function_doc() {
334        let args = quote!(path = "../docs");
335        let item = quote! {
336            async fn test_async() {
337                println!("async test");
338            }
339        };
340
341        let result = syncdoc_impl(args, item);
342        assert!(result.is_ok());
343
344        let output = result.unwrap();
345        let output_str = format_and_print(output);
346
347        assert!(output_str.contains("async fn test_async"));
348        assert!(output_str.replace(" ", "").contains("include_str!"));
349    }
350
351    #[test]
352    fn test_unsafe_function_doc() {
353        let args = quote!(path = "../docs");
354        let item = quote! {
355            unsafe fn test_unsafe() {
356                println!("unsafe test");
357            }
358        };
359
360        let result = syncdoc_impl(args, item);
361        assert!(result.is_ok());
362
363        let output = result.unwrap();
364        let output_str = format_and_print(output);
365
366        assert!(output_str.contains("unsafe fn test_unsafe"));
367        assert!(output_str.replace(" ", "").contains("include_str!"));
368    }
369
370    #[test]
371    fn test_pub_async_function_doc() {
372        let args = quote!(path = "../docs");
373        let item = quote! {
374            pub async fn test_pub_async() {
375                println!("pub async test");
376            }
377        };
378
379        let result = syncdoc_impl(args, item);
380        assert!(result.is_ok());
381
382        let output = result.unwrap();
383        let output_str = format_and_print(output);
384
385        assert!(output_str.contains("pub async fn test_pub_async"));
386        assert!(output_str.replace(" ", "").contains("include_str!"));
387    }
388
389    #[test]
390    fn test_direct_file_path() {
391        let args = quote!(path = "../docs/special.md");
392        let item = quote! {
393            fn test_function() {}
394        };
395
396        let result = syncdoc_impl(args, item);
397        assert!(result.is_ok());
398
399        let output = result.unwrap();
400        let output_str = format_and_print(output);
401
402        assert!(output_str.contains("../docs/special.md"));
403        assert!(!output_str.contains("test_function.md"));
404    }
405}