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
17enum 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
43fn 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
80fn 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
113fn 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
133fn 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 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 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 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 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 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 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 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 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}