Skip to main content

derive_defs/
codegen.rs

1//! Proc-macro code generation.
2//!
3//! This module generates the actual proc-macro code from resolved definitions.
4//!
5//! # Generated Code Structure
6//!
7//! The generated code creates attribute macros that:
8//! 1. Parse the input item (struct/enum)
9//! 2. Apply configured derives and attributes
10//! 3. Handle runtime modifiers (`add`, `omit`, `omit_attrs`)
11//!
12//! # Example Output
13//!
14//! For a definition like:
15//! ```toml
16//! [defs.serialization]
17//! traits = ["Clone", "Serialize", "Deserialize"]
18//! attrs = ['#[serde(rename_all = "camelCase")]']
19//! ```
20//!
21//! The generated code will be:
22//! ```rust,ignore
23//! #[proc_macro_attribute]
24//! pub fn serialization(
25//!     args: TokenStream,
26//!     input: TokenStream,
27//! ) -> TokenStream {
28//!     // Parse modifiers from args
29//!     // Apply derives and attrs to input
30//! }
31//! ```
32
33use std::path::Path;
34
35use crate::resolver::ResolvedConfig;
36use crate::{Error, Result};
37
38/// Header template for generated code.
39const HEADER_TEMPLATE: &str = include_str!("../templates/header.rs");
40
41/// Macro function template.
42const MACRO_TEMPLATE: &str = include_str!("../templates/macro.rs");
43
44/// Generate proc-macro code to the default `OUT_DIR`.
45///
46/// This function reads the `OUT_DIR` environment variable set by Cargo
47/// during the build process and writes the generated code to
48/// `$OUT_DIR/derive_defs.rs`.
49///
50/// # Errors
51///
52/// Returns an error if:
53/// - The `OUT_DIR` environment variable is not set
54/// - The output file cannot be written
55///
56/// # Panics
57///
58/// Panics if `OUT_DIR` is not set. This should only happen outside of a build script.
59///
60/// # Example
61///
62/// ```no_run
63/// // In your build.rs
64/// derive_defs::generate("derive_defs.toml").unwrap();
65/// ```
66pub fn generate(config: ResolvedConfig) -> Result<()> {
67    let out_dir =
68        std::env::var("OUT_DIR").map_err(|_| Error::Validation("OUT_DIR not set".to_string()))?;
69    let output_path = Path::new(&out_dir).join("derive_defs.rs");
70
71    generate_to(config, &output_path)
72}
73
74/// Generate proc-macro code to a specific output path.
75///
76/// Similar to [`generate`], but allows specifying a custom output path instead
77/// of using `OUT_DIR`.
78///
79/// # Errors
80///
81/// Returns an error if the output file cannot be written.
82///
83/// # Example
84///
85/// ```no_run
86/// use derive_defs::resolver::{ResolvedConfig, ResolvedDef};
87/// use std::collections::HashMap;
88///
89/// let config = ResolvedConfig {
90///     defs: HashMap::new(),
91/// };
92/// derive_defs::codegen::generate_to(config, "/tmp/output.rs").unwrap();
93/// ```
94pub fn generate_to<P: AsRef<Path>>(config: ResolvedConfig, output: P) -> Result<()> {
95    let code = generate_code(config);
96
97    std::fs::write(output.as_ref(), code).map_err(Error::CodegenWrite)?;
98
99    Ok(())
100}
101
102/// Generate Rust code string from resolved configuration.
103fn generate_code(config: ResolvedConfig) -> String {
104    let mut code = String::new();
105
106    // Add header with imports and argument parser
107    code.push_str(HEADER_TEMPLATE);
108    code.push('\n');
109
110    // Generate each macro
111    for (name, def) in config.defs {
112        let macro_name = name.replace('.', "_");
113
114        // Build the derive list
115        let derive_list = if def.traits.is_empty() {
116            String::new()
117        } else {
118            def.traits.join(", ")
119        };
120
121        // Build the attribute list
122        let attr_list = def.attrs.join("\n");
123
124        // Generate the macro function
125        code.push_str(&generate_macro(&macro_name, &derive_list, &attr_list));
126        code.push('\n');
127    }
128
129    code
130}
131
132/// Generate a single macro function.
133#[allow(clippy::literal_string_with_formatting_args)]
134fn generate_macro(name: &str, derive_list: &str, attr_list: &str) -> String {
135    MACRO_TEMPLATE
136        .replace("{name}", name)
137        .replace("{derive_list:?}", &format!("{derive_list:?}"))
138        .replace("{attr_list:?}", &format!("{attr_list:?}"))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::resolver::ResolvedDef;
145    use std::collections::HashMap;
146
147    #[test]
148    fn test_generate_code() {
149        let mut defs = HashMap::new();
150        defs.insert(
151            "serialization".to_string(),
152            ResolvedDef {
153                name: "serialization".to_string(),
154                traits: vec![
155                    "Clone".to_string(),
156                    "Serialize".to_string(),
157                    "Deserialize".to_string(),
158                ],
159                attrs: vec!["#[serde(rename_all = \"camelCase\")]".to_string()],
160            },
161        );
162
163        let config = ResolvedConfig { defs };
164        let code = generate_code(config);
165
166        assert!(code.contains("proc_macro_attribute"));
167        assert!(code.contains("fn serialization"));
168        // The quote! template uses #(#derives),* which expands at runtime
169        assert!(code.contains("#[derive(#(#derives),*)]"));
170        assert!(code.contains("serde(rename_all"));
171        assert!(code.contains("struct DefArgs"));
172        assert!(code.contains("impl Parse for DefArgs"));
173        // Verify the traits are in the parse_trait_list call
174        assert!(code.contains("\"Clone, Serialize, Deserialize\""));
175    }
176
177    /// Test that generated code converts trait strings to `proc_macro2::Ident`.
178    ///
179    /// This test specifically guards against the bug where trait names were
180    /// passed directly to quote! as strings, resulting in string literals
181    /// instead of identifiers in the generated #[derive(...)] attribute.
182    #[test]
183    fn test_generated_code_converts_traits_to_idents() {
184        let mut defs = HashMap::new();
185        defs.insert(
186            "model".to_string(),
187            ResolvedDef {
188                name: "model".to_string(),
189                traits: vec!["Debug".to_string(), "Clone".to_string()],
190                attrs: vec![],
191            },
192        );
193
194        let config = ResolvedConfig { defs };
195        let code = generate_code(config);
196
197        // The generated code must convert String to proc_macro2::Ident
198        // before using in quote! macro to avoid "expected path, found literal" error
199        assert!(
200            code.contains("proc_macro2::Ident::new"),
201            "Generated code should convert trait names to proc_macro2::Ident"
202        );
203
204        // Check that the code uses call_site() span for the Idents
205        assert!(
206            code.contains("proc_macro2::Span::call_site()"),
207            "Generated code should use call_site() span for Idents"
208        );
209
210        // Check that derives are declared as Vec<proc_macro2::Ident>
211        assert!(
212            code.contains("Vec<proc_macro2::Ident>"),
213            "Generated code should declare derives as Vec<proc_macro2::Ident>"
214        );
215    }
216
217    /// Test that generated code properly handles the derive attribute construction.
218    ///
219    /// This ensures the generated code will produce valid #[derive(Trait1, Trait2)]
220    /// syntax when compiled and executed.
221    #[test]
222    fn test_generated_code_derive_construction() {
223        let mut defs = HashMap::new();
224        defs.insert(
225            "test".to_string(),
226            ResolvedDef {
227                name: "test".to_string(),
228                traits: vec!["Debug".to_string()],
229                attrs: vec![],
230            },
231        );
232
233        let config = ResolvedConfig { defs };
234        let code = generate_code(config);
235
236        // Check that the generated code has the proper structure for building derives
237        assert!(
238            code.contains("let derives: Vec<proc_macro2::Ident>"),
239            "Generated code should create a typed Vec of Idents for derives"
240        );
241
242        // The code should iterate over final_derives and convert each to Ident
243        // Note: In the generated code, this spans multiple lines:
244        //   let derives: Vec<proc_macro2::Ident> = final_derives
245        //       .iter()
246        //       .map(...)
247        assert!(
248            code.contains("final_derives") && code.contains(".iter()"),
249            "Generated code should iterate over final_derives"
250        );
251
252        // The quote! should use the derives variable (which is now Vec<Ident>)
253        assert!(
254            code.contains("#(#derives),*"),
255            "Generated code should use derives in quote! macro"
256        );
257    }
258
259    #[test]
260    fn test_generate_code_empty_traits() {
261        let mut defs = HashMap::new();
262        defs.insert(
263            "empty".to_string(),
264            ResolvedDef {
265                name: "empty".to_string(),
266                traits: vec![],
267                attrs: vec!["#[repr(C)]".to_string()],
268            },
269        );
270
271        let config = ResolvedConfig { defs };
272        let code = generate_code(config);
273
274        assert!(code.contains("fn empty"));
275        assert!(!code.contains("derive()")); // Empty derive list should not be generated
276        assert!(code.contains("repr(C)"));
277
278        // When traits are empty, the code should use quote! {}
279        assert!(
280            code.contains("let derive_attr = if final_derives.is_empty()"),
281            "Generated code should check for empty derives"
282        );
283    }
284
285    #[test]
286    fn test_generate_code_namespaced() {
287        let mut defs = HashMap::new();
288        defs.insert(
289            "common.serialization".to_string(),
290            ResolvedDef {
291                name: "common.serialization".to_string(),
292                traits: vec!["Clone".to_string()],
293                attrs: vec![],
294            },
295        );
296
297        let config = ResolvedConfig { defs };
298        let code = generate_code(config);
299
300        // Namespaced names should have dots replaced with underscores
301        assert!(code.contains("fn common_serialization"));
302    }
303
304    /// Test that generated code includes all necessary helper functions.
305    #[test]
306    fn test_generated_code_includes_helpers() {
307        let config = ResolvedConfig {
308            defs: HashMap::new(),
309        };
310        let code = generate_code(config);
311
312        assert!(
313            code.contains("fn parse_trait_list"),
314            "Generated code should include parse_trait_list function"
315        );
316        assert!(
317            code.contains("fn filter_attrs"),
318            "Generated code should include filter_attrs function"
319        );
320    }
321
322    /// Test that generated code handles runtime modifications (omit/add).
323    #[test]
324    fn test_generated_code_handles_runtime_mods() {
325        let mut defs = HashMap::new();
326        defs.insert(
327            "configurable".to_string(),
328            ResolvedDef {
329                name: "configurable".to_string(),
330                traits: vec!["Debug".to_string(), "Clone".to_string()],
331                attrs: vec![],
332            },
333        );
334
335        let config = ResolvedConfig { defs };
336        let code = generate_code(config);
337
338        // Check for omit handling
339        assert!(
340            code.contains("omit_traits"),
341            "Generated code should handle omit_traits"
342        );
343
344        // Check for add handling
345        assert!(
346            code.contains("add_traits"),
347            "Generated code should handle add_traits"
348        );
349
350        // Check for filtering logic
351        assert!(
352            code.contains(".filter(|t| !omit_traits.contains(t))"),
353            "Generated code should filter omitted traits"
354        );
355    }
356}