Skip to main content

textus_derive/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::Span;
3use quote::quote;
4use std::collections::HashSet;
5use std::path::{Path, PathBuf};
6use syn::{DeriveInput, Ident, LitStr, parse_macro_input};
7
8#[proc_macro_derive(Template, attributes(template))]
9pub fn derive_template(input: TokenStream) -> TokenStream {
10    let input = parse_macro_input!(input as DeriveInput);
11    match expand(input) {
12        Ok(ts) => ts.into(),
13        Err(e) => e.to_compile_error().into(),
14    }
15}
16
17// ── Attribute types ──────────────────────────────────────────────────
18
19enum Mode {
20    Strict,
21    Default,
22    Lenient,
23}
24
25struct Attrs {
26    path: String,
27    mode: Mode,
28    strip_prefix: Option<String>,
29    strip_suffix: Option<String>,
30}
31
32enum Seg {
33    Lit(String),
34    Var(String),
35}
36
37struct FileEntry {
38    rel: String,
39    segs: Vec<Seg>,
40    abs: String,
41}
42
43// ── Attribute parsing ────────────────────────────────────────────────
44
45fn parse_attrs(input: &DeriveInput) -> syn::Result<Attrs> {
46    let attr = input
47        .attrs
48        .iter()
49        .find(|a| a.path().is_ident("template"))
50        .ok_or_else(|| syn::Error::new_spanned(input, "missing #[template(...)]"))?;
51
52    let (mut path, mut mode, mut strip_prefix, mut strip_suffix) = (None, Mode::Default, None, None);
53    attr.parse_nested_meta(|meta| {
54        if meta.path.is_ident("path") {
55            path = Some(meta.value()?.parse::<LitStr>()?.value());
56        } else if meta.path.is_ident("strip_prefix") {
57            strip_prefix = Some(meta.value()?.parse::<LitStr>()?.value());
58        } else if meta.path.is_ident("strip_suffix") {
59            strip_suffix = Some(meta.value()?.parse::<LitStr>()?.value());
60        } else if meta.path.is_ident("mode") {
61            let lit = meta.value()?.parse::<LitStr>()?.value();
62            mode = match lit.as_str() {
63                "strict" => Mode::Strict,
64                "default" => Mode::Default,
65                "lenient" => Mode::Lenient,
66                _ => return Err(meta.error(format!("unknown mode `{lit}`"))),
67            };
68        }
69        Ok(())
70    })?;
71
72    Ok(Attrs {
73        path: path.ok_or_else(|| syn::Error::new_spanned(attr, "`path` is required"))?,
74        mode,
75        strip_prefix,
76        strip_suffix,
77    })
78}
79
80// ── Template parsing ─────────────────────────────────────────────────
81
82fn parse_template(src: &str) -> Vec<Seg> {
83    let mut segs = Vec::new();
84    let mut rest = src;
85    while let Some(i) = rest.find("{{") {
86        if i > 0 {
87            segs.push(Seg::Lit(rest[..i].into()));
88        }
89        rest = &rest[i + 2..];
90        match rest.find("}}") {
91            Some(j) => {
92                segs.push(Seg::Var(rest[..j].trim().into()));
93                rest = &rest[j + 2..];
94            }
95            None => segs.push(Seg::Lit("{{".into())),
96        }
97    }
98    if !rest.is_empty() {
99        segs.push(Seg::Lit(rest.into()));
100    }
101    segs
102}
103
104fn collect_vars(segs: &[Seg]) -> HashSet<String> {
105    segs.iter()
106        .filter_map(|s| match s {
107            Seg::Var(v) => Some(v.clone()),
108            _ => None,
109        })
110        .collect()
111}
112
113// ── Filesystem walk ──────────────────────────────────────────────────
114
115fn walk_dir(dir: &Path) -> syn::Result<Vec<PathBuf>> {
116    let mut out = Vec::new();
117    for entry in std::fs::read_dir(dir)
118        .map_err(|e| syn::Error::new(Span::call_site(), format!("{}: {e}", dir.display())))?
119    {
120        let p = entry
121            .map_err(|e| syn::Error::new(Span::call_site(), e.to_string()))?
122            .path();
123        if p.is_dir() {
124            out.extend(walk_dir(&p)?);
125        } else {
126            out.push(p);
127        }
128    }
129    out.sort();
130    Ok(out)
131}
132
133// ── Code generation ──────────────────────────────────────────────────
134
135fn expand(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
136    let attrs = parse_attrs(&input)?;
137    let name = &input.ident;
138    let (impl_g, ty_g, where_cl) = input.generics.split_for_impl();
139
140    // Collect struct field names
141    let fields: HashSet<String> = match &input.data {
142        syn::Data::Struct(s) => match &s.fields {
143            syn::Fields::Named(n) => n
144                .named
145                .iter()
146                .map(|f| f.ident.as_ref().unwrap().to_string())
147                .collect(),
148            _ => return Err(syn::Error::new_spanned(&input, "named fields required")),
149        },
150        _ => return Err(syn::Error::new_spanned(&input, "only structs supported")),
151    };
152
153    // Resolve template directory relative to CARGO_MANIFEST_DIR
154    let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap();
155    let dir = Path::new(&manifest).join(&attrs.path);
156    if !dir.is_dir() {
157        return Err(syn::Error::new_spanned(
158            &input,
159            format!("not a directory: {}", dir.display()),
160        ));
161    }
162
163    // Walk, parse, validate
164    let files = walk_dir(&dir)?;
165    let mut all_vars = HashSet::new();
166    let mut entries = Vec::new();
167
168    for file in &files {
169        let content = std::fs::read_to_string(file)
170            .map_err(|e| syn::Error::new_spanned(&input, format!("{}: {e}", file.display())))?;
171
172        let mut rel = file
173            .strip_prefix(&dir)
174            .unwrap()
175            .to_string_lossy()
176            .replace('\\', "/");
177
178        if let Some(prefix) = attrs.strip_prefix.as_deref() {
179            if let Some(trimmed) = rel.strip_prefix(prefix) {
180                rel = String::from(trimmed);
181            }
182        }
183        if let Some(suffix) = attrs.strip_suffix.as_deref() {
184            if let Some(trimmed) = rel.strip_suffix(suffix) {
185                rel = String::from(trimmed);
186            }
187        }
188
189        let segs = parse_template(&content);
190        let vars = collect_vars(&segs);
191
192        // Every variable must be a valid Rust identifier
193        for v in &vars {
194            syn::parse_str::<Ident>(v).map_err(|_| {
195                syn::Error::new_spanned(
196                    &input,
197                    format!("`{v}` in `{rel}` is not a valid identifier"),
198                )
199            })?;
200        }
201
202        // Default + Strict: template variables must exist as struct fields
203        if !matches!(attrs.mode, Mode::Lenient) {
204            for v in &vars {
205                if !fields.contains(v) {
206                    return Err(syn::Error::new_spanned(
207                        &input,
208                        format!("variable `{v}` in `{rel}` has no matching struct field"),
209                    ));
210                }
211            }
212        }
213
214        all_vars.extend(vars);
215        entries.push(FileEntry {
216            rel,
217            segs,
218            abs: file.to_string_lossy().into(),
219        });
220    }
221
222    // Strict: every struct field must appear in at least one template
223    if matches!(attrs.mode, Mode::Strict) {
224        for f in &fields {
225            if !all_vars.contains(f) {
226                return Err(syn::Error::new_spanned(
227                    &input,
228                    format!("field `{f}` unused in any template (strict mode)"),
229                ));
230            }
231        }
232    }
233
234    // Build the render items
235    let render_items = entries.iter().map(|e| {
236        let rel = &e.rel;
237        let has_vars = e.segs.iter().any(|s| matches!(s, Seg::Var(_)));
238
239        let content_expr = if has_vars {
240            let mut fmt = String::new();
241            let mut args = Vec::<proc_macro2::TokenStream>::new();
242            for seg in &e.segs {
243                match seg {
244                    Seg::Lit(l) => fmt.push_str(&l.replace('{', "{{").replace('}', "}}")),
245                    Seg::Var(v) => {
246                        fmt.push_str("{}");
247                        let id = Ident::new(v, Span::call_site());
248                        args.push(quote! { self.#id });
249                    }
250                }
251            }
252            quote! { ::std::borrow::Cow::Owned(format!(#fmt, #(#args),*)) }
253        } else {
254            let text: String = e
255                .segs
256                .iter()
257                .map(|s| match s {
258                    Seg::Lit(l) => l.as_str(),
259                    _ => unreachable!(),
260                })
261                .collect();
262            quote! { ::std::borrow::Cow::Borrowed(#text) }
263        };
264
265        quote! { (#rel, #content_expr) }
266    });
267
268    // File-dependency tracking so cargo rebuilds when templates change
269    let tracking = entries.iter().map(|e| {
270        let abs = &e.abs;
271        quote! { let _ = include_bytes!(#abs); }
272    });
273
274    Ok(quote! {
275        impl #impl_g ::textus::Template for #name #ty_g #where_cl {
276            fn render(&self) -> ::std::vec::Vec<(
277                &'static str,
278                ::std::borrow::Cow<'static, str>,
279            )> {
280                #(#tracking)*
281                vec![#(#render_items),*]
282            }
283        }
284    })
285}