Skip to main content

tatara_rust_attr/
lib.rs

1//! `tatara-rust-attr` — typed attribute model + `PerAttrDeriveSpec`.
2//!
3//! Two primitives:
4//! - [`AttrKnob`] — typed description of a consumer-side `#[trait(key = …)]`
5//!   knob the derive understands. Each knob has a name + value-kind +
6//!   optional default.
7//! - [`PerAttrDeriveSpec`] — derive shape that reads its knobs from the
8//!   consumer's `#[<trait_name>(…)]` attribute(s) and parameterizes
9//!   its emission on the parsed values.
10//!
11//! Today's emit shape is the **prefix-template** family: a knob named
12//! `prefix` with a string default. The per-field template re-uses the
13//! prefix via `#prefix`. Sufficient for serde-style rename / derive-
14//! builder-style setter-prefix patterns.
15//!
16//! Authoring shape:
17//!
18//! ```
19//! use tatara_rust_ast::{CompileToCrate, Ident};
20//! use tatara_rust_attr::{AttrKnob, AttrValueKind, PerAttrDeriveSpec};
21//!
22//! let spec = PerAttrDeriveSpec {
23//!     trait_name: Ident::new("Prefixed"),
24//!     knobs: vec![AttrKnob {
25//!         name: "prefix".into(),
26//!         kind: AttrValueKind::Str,
27//!         default: Some("with_".into()),
28//!     }],
29//!     per_field_template:
30//!         "pub fn #prefix #field_name(self, v: #field_ty) -> Self { self }".into(),
31//! };
32//! let scaffold = spec.compile_to_crate("prefixed-derive").unwrap();
33//! assert!(scaffold.to_files().contains_key("src/lib.rs"));
34//! ```
35
36use serde::{Deserialize, Serialize};
37use tatara_rust_ast::{AstError, CompileToCrate, CrateScaffold, Ident};
38
39#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum AttrValueKind {
42    /// `key = "string"`.
43    Str,
44    /// `key = 42` — parsed as i64.
45    Int,
46    /// `key = true`.
47    Bool,
48}
49
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51pub struct AttrKnob {
52    /// `#[trait(<name> = …)]`.
53    pub name: String,
54    pub kind: AttrValueKind,
55    /// Default value rendered as source text (with quotes for Str etc.).
56    /// `None` ⇒ knob is required; emitted derive errors at consumer-expand
57    /// time if missing.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub default: Option<String>,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
63pub struct PerAttrDeriveSpec {
64    pub trait_name: Ident,
65    pub knobs: Vec<AttrKnob>,
66    /// Per-field template with `#<knob_name>` + `#field_name` + `#field_ty`
67    /// splice holes. Embedded raw inside `quote!{}`.
68    pub per_field_template: String,
69}
70
71impl PerAttrDeriveSpec {
72    fn fn_name(&self) -> String {
73        let s = &self.trait_name.0;
74        let mut out = String::from("derive_");
75        for (i, c) in s.chars().enumerate() {
76            if c.is_uppercase() {
77                if i > 0 {
78                    out.push('_');
79                }
80                out.extend(c.to_lowercase());
81            } else {
82                out.push(c);
83            }
84        }
85        out
86    }
87}
88
89impl CompileToCrate for PerAttrDeriveSpec {
90    fn compile_to_crate(&self, crate_name: &str) -> Result<CrateScaffold, AstError> {
91        let mut s = CrateScaffold::new(crate_name, "0.1.0");
92        s.add_file("Cargo.toml", render_cargo_toml(crate_name));
93        s.add_file("src/lib.rs", render_lib_rs(self));
94        Ok(s)
95    }
96}
97
98fn render_cargo_toml(crate_name: &str) -> String {
99    tatara_rust_ast::render_proc_macro_cargo_toml(
100        crate_name,
101        "Per-attr derive proc-macro emitted from a tatara-rust-attr PerAttrDeriveSpec.",
102    )
103}
104
105fn render_lib_rs(spec: &PerAttrDeriveSpec) -> String {
106    let trait_id = &spec.trait_name.0;
107    let fn_name = spec.fn_name();
108    let trait_lower = trait_id.to_lowercase();
109    let per_field_tpl = &spec.per_field_template;
110
111    // For each knob, emit:
112    //   let mut <knob> = <default-expr or compile_error>;
113    //   <walk #[trait(...)] meta items + assign>
114    let mut knob_let = String::new();
115    let mut knob_parse = String::new();
116    for k in &spec.knobs {
117        let n = &k.name;
118        let default_expr = match (&k.default, &k.kind) {
119            (Some(d), AttrValueKind::Str) => format!("{d:?}.to_string()"),
120            (Some(d), AttrValueKind::Int) => d.clone(),
121            (Some(d), AttrValueKind::Bool) => d.clone(),
122            (None, AttrValueKind::Str) => "String::new()".to_string(),
123            (None, AttrValueKind::Int) => "0i64".to_string(),
124            (None, AttrValueKind::Bool) => "false".to_string(),
125        };
126        knob_let.push_str(&format!("    let mut {n} = {default_expr};\n"));
127        let parse_arm = match k.kind {
128            AttrValueKind::Str => format!(
129                "                    if path.is_ident({n:?}) {{ if let syn::Expr::Lit(syn::ExprLit {{ lit: syn::Lit::Str(s), .. }}) = &mv.value {{ {n} = s.value(); }} }}"
130            ),
131            AttrValueKind::Int => format!(
132                "                    if path.is_ident({n:?}) {{ if let syn::Expr::Lit(syn::ExprLit {{ lit: syn::Lit::Int(i), .. }}) = &mv.value {{ {n} = i.base10_parse::<i64>().unwrap_or(0); }} }}"
133            ),
134            AttrValueKind::Bool => format!(
135                "                    if path.is_ident({n:?}) {{ if let syn::Expr::Lit(syn::ExprLit {{ lit: syn::Lit::Bool(b), .. }}) = &mv.value {{ {n} = b.value; }} }}"
136            ),
137        };
138        knob_parse.push_str(&parse_arm);
139        knob_parse.push('\n');
140    }
141
142    let mut out = String::new();
143    out.push_str("// GENERATED by tatara-rust-attr::PerAttrDeriveSpec.\n");
144    out.push_str("use proc_macro::TokenStream;\n");
145    out.push_str("use quote::quote;\n");
146    out.push_str(
147        "use syn::{Data, DataStruct, DeriveInput, Fields, Meta, parse_macro_input};\n\n",
148    );
149    out.push_str(&format!(
150        "#[proc_macro_derive({trait_id}, attributes({trait_lower}))]\n"
151    ));
152    out.push_str(&format!(
153        "pub fn {fn_name}(input: TokenStream) -> TokenStream {{\n"
154    ));
155    out.push_str("    let input = parse_macro_input!(input as DeriveInput);\n");
156    out.push_str("    let self_name = &input.ident;\n\n");
157    out.push_str(&knob_let);
158    out.push('\n');
159    out.push_str(&format!(
160        "    for attr in &input.attrs {{\n        if attr.path().is_ident({trait_lower:?}) {{\n"
161    ));
162    out.push_str("            if let Ok(metas) = attr.parse_args_with(syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated) {\n");
163    out.push_str("                for meta in metas {\n");
164    out.push_str("                    let Meta::NameValue(mv) = &meta else { continue };\n");
165    out.push_str("                    let path = mv.path.clone();\n");
166    out.push_str(&knob_parse);
167    out.push_str("                }\n");
168    out.push_str("            }\n");
169    out.push_str("        }\n");
170    out.push_str("    }\n\n");
171    out.push_str("    let fields = match &input.data {\n");
172    out.push_str(
173        "        Data::Struct(DataStruct { fields: Fields::Named(named), .. }) => &named.named,\n",
174    );
175    out.push_str("        _ => return syn::Error::new_spanned(self_name, \"PerAttrDerive requires a named-fields struct\").to_compile_error().into(),\n");
176    out.push_str("    };\n\n");
177    out.push_str("    let per_field = fields.iter().map(|f| {\n");
178    out.push_str("        let field_name = f.ident.as_ref().expect(\"named field\");\n");
179    out.push_str("        let field_ty = &f.ty;\n");
180    // For each knob, splice it into the per_field template via quote::format_ident! or quote interpolation.
181    // String knobs we splice as Ident via format_ident! so they prefix-concatenate cleanly.
182    for k in &spec.knobs {
183        let n = &k.name;
184        match k.kind {
185            AttrValueKind::Str => {
186                out.push_str(&format!(
187                    "        let {n}_ident = quote::format_ident!(\"{{}}{{}}\", {n}, field_name.to_string());\n"
188                ));
189                out.push_str(&format!(
190                    "        let {n} = &{n}_ident;\n"
191                ));
192            }
193            AttrValueKind::Int | AttrValueKind::Bool => {
194                out.push_str(&format!("        let {n} = &{n};\n"));
195            }
196        }
197    }
198    out.push_str("        quote! {\n");
199    out.push_str("            ");
200    out.push_str(per_field_tpl);
201    out.push_str("\n        }\n");
202    out.push_str("    });\n\n");
203    out.push_str("    let expanded = quote! {\n");
204    out.push_str("        impl #self_name {\n");
205    out.push_str("            #(#per_field)*\n");
206    out.push_str("        }\n");
207    out.push_str("    };\n");
208    out.push_str("    TokenStream::from(expanded)\n");
209    out.push_str("}\n");
210    out
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn prefixed() -> PerAttrDeriveSpec {
218        PerAttrDeriveSpec {
219            trait_name: Ident::new("Prefixed"),
220            knobs: vec![AttrKnob {
221                name: "prefix".into(),
222                kind: AttrValueKind::Str,
223                default: Some("with_".into()),
224            }],
225            per_field_template:
226                "pub fn #prefix(self, v: #field_ty) -> Self { self }".into(),
227        }
228    }
229
230    #[test]
231    fn compiles_to_lib_and_cargo() {
232        let s = prefixed().compile_to_crate("prefixed-derive").unwrap();
233        let files = s.to_files();
234        assert!(files.contains_key("Cargo.toml"));
235        assert!(files.contains_key("src/lib.rs"));
236    }
237
238    #[test]
239    fn proc_macro_derive_declares_attribute() {
240        let s = prefixed().compile_to_crate("p").unwrap();
241        let lib = s.to_files().get("src/lib.rs").unwrap().clone();
242        // attributes() must include the lowercased trait name so #[prefixed(...)] parses.
243        assert!(lib.contains("#[proc_macro_derive(Prefixed, attributes(prefixed))]"));
244    }
245
246    #[test]
247    fn lib_rs_initializes_default_and_parses_knob() {
248        let s = prefixed().compile_to_crate("p").unwrap();
249        let lib = s.to_files().get("src/lib.rs").unwrap().clone();
250        assert!(lib.contains("let mut prefix"));
251        assert!(lib.contains(r#""with_""#));
252        assert!(lib.contains(r#"path.is_ident("prefix")"#));
253        assert!(lib.contains("syn::Lit::Str"));
254    }
255
256    #[test]
257    fn string_knob_format_idents_for_prefix_concat() {
258        let s = prefixed().compile_to_crate("p").unwrap();
259        let lib = s.to_files().get("src/lib.rs").unwrap().clone();
260        assert!(lib.contains("let prefix_ident = quote::format_ident!"));
261    }
262
263    #[test]
264    fn multiple_knobs_each_get_default_let() {
265        let mut s = prefixed();
266        s.knobs.push(AttrKnob {
267            name: "inline".into(),
268            kind: AttrValueKind::Bool,
269            default: Some("false".into()),
270        });
271        s.knobs.push(AttrKnob {
272            name: "max".into(),
273            kind: AttrValueKind::Int,
274            default: Some("10".into()),
275        });
276        let lib = s.compile_to_crate("p").unwrap().to_files().get("src/lib.rs").unwrap().clone();
277        assert!(lib.contains("let mut prefix"));
278        assert!(lib.contains("let mut inline"));
279        assert!(lib.contains("let mut max"));
280        assert!(lib.contains("syn::Lit::Bool"));
281        assert!(lib.contains("syn::Lit::Int"));
282    }
283
284    #[test]
285    fn serde_roundtrip() {
286        let s = prefixed();
287        let j = serde_json::to_string(&s).unwrap();
288        let back: PerAttrDeriveSpec = serde_json::from_str(&j).unwrap();
289        assert_eq!(s, back);
290    }
291}