Skip to main content

md_tmpl_macros/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4mod codegen;
5mod compile;
6mod struct_gen;
7mod type_gen;
8
9use std::cell::RefCell;
10
11use codegen::{codegen_compiled_inline_template, codegen_segment, codegen_value, codegen_var_decl};
12use compile::{CompiledTemplateAst, load_and_compile, stem_from_path};
13use proc_macro::TokenStream;
14use quote::{format_ident, quote};
15use struct_gen::{StructGenSource, generate_struct_tokens};
16use syn::{
17    Ident, LitStr, Token,
18    parse::{Parse, ParseStream},
19    parse_macro_input,
20};
21use type_gen::generate_type_alias_tokens;
22
23thread_local! {
24    /// The crate path to use in generated code.
25    ///
26    /// Defaults to `::md_tmpl` but can be overridden via the
27    /// `crate = path` argument in `include_template!` / `template!`.
28    /// Using a thread-local avoids threading the value through every
29    /// codegen helper.
30    static CRATE_PATH: RefCell<proc_macro2::TokenStream> = RefCell::new(quote! { ::md_tmpl });
31}
32
33/// Read the current crate path from the thread-local.
34pub(crate) fn crate_path() -> proc_macro2::TokenStream {
35    CRATE_PATH.with(|cp| cp.borrow().clone())
36}
37
38/// Set the crate path for the duration of the closure, then restore it.
39fn with_crate_path<F: FnOnce() -> R, R>(path: proc_macro2::TokenStream, f: F) -> R {
40    CRATE_PATH.with(|cp| {
41        let old = cp.replace(path);
42        let result = f();
43        cp.replace(old);
44        result
45    })
46}
47
48/// Parsed input for `include_template!("path")`,
49/// `include_template!("path" => custom_mod_name)`,
50/// `include_template!("path" as StructName)`,
51/// `include_template!("path" as StructName => custom_mod_name)`,
52/// `include_template!("path", crate = ::my_crate::reexport)`, or
53/// `include_template!("path", env = { KEY: "value", KEY2: 42 })`.
54struct IncludeTemplateInput {
55    path: LitStr,
56    struct_name: Option<Ident>,
57    custom_name: Option<Ident>,
58    crate_path: Option<syn::Path>,
59    env: Vec<(String, syn::Expr)>,
60}
61
62impl Parse for IncludeTemplateInput {
63    fn parse(input: ParseStream) -> syn::Result<Self> {
64        let path: LitStr = input.parse()?;
65
66        // Optional: `as StructName`
67        let struct_name = if input.peek(Token![as]) {
68            let _as: Token![as] = input.parse()?;
69            Some(input.parse()?)
70        } else {
71            None
72        };
73
74        // Optional: `=> custom_mod_name`
75        let custom_name = if input.peek(Token![=>]) {
76            let _arrow: Token![=>] = input.parse()?;
77            Some(input.parse()?)
78        } else {
79            None
80        };
81
82        // Optional trailing arguments: `, crate = ...` or `, env = { ... }`
83        let mut crate_path = None;
84        let mut env = Vec::new();
85        while input.peek(Token![,]) {
86            let _comma: Token![,] = input.parse()?;
87            if input.is_empty() {
88                break;
89            }
90            if input.peek(Token![crate]) {
91                let _kw: Token![crate] = input.parse()?;
92                let _eq: Token![=] = input.parse()?;
93                crate_path = Some(input.parse()?);
94            } else {
95                let kw: Ident = input.parse()?;
96                if kw == "env" {
97                    let _eq: Token![=] = input.parse()?;
98                    env = parse_env_block(input)?;
99                } else {
100                    return Err(syn::Error::new(
101                        kw.span(),
102                        format!("unknown option '{kw}', expected 'crate' or 'env'"),
103                    ));
104                }
105            }
106        }
107
108        Ok(Self {
109            path,
110            struct_name,
111            custom_name,
112            crate_path,
113            env,
114        })
115    }
116}
117
118/// Parsed input for `template!(r#"source"# => mod_name)`.
119///
120/// The `=> name` is **required** because inline templates have no file path
121/// from which to derive a module name.
122struct InlineTemplateInput {
123    source: LitStr,
124    struct_name: Option<Ident>,
125    name: Ident,
126    crate_path: Option<syn::Path>,
127    env: Vec<(String, syn::Expr)>,
128}
129
130impl Parse for InlineTemplateInput {
131    fn parse(input: ParseStream) -> syn::Result<Self> {
132        let source: LitStr = input.parse()?;
133        let struct_name = if input.peek(Token![as]) {
134            let _as: Token![as] = input.parse()?;
135            Some(input.parse()?)
136        } else {
137            None
138        };
139        let _: Token![=>] = input.parse()?;
140        let name: Ident = input.parse()?;
141        let mut crate_path = None;
142        let mut env = Vec::new();
143        while input.peek(Token![,]) {
144            let _comma: Token![,] = input.parse()?;
145            if input.is_empty() {
146                break;
147            }
148            if input.peek(Token![crate]) {
149                let _kw: Token![crate] = input.parse()?;
150                let _eq: Token![=] = input.parse()?;
151                crate_path = Some(input.parse()?);
152            } else {
153                let kw: Ident = input.parse()?;
154                if kw == "env" {
155                    let _eq: Token![=] = input.parse()?;
156                    env = parse_env_block(input)?;
157                } else {
158                    return Err(syn::Error::new(
159                        kw.span(),
160                        format!("unknown option '{kw}', expected 'crate' or 'env'"),
161                    ));
162                }
163            }
164        }
165        Ok(Self {
166            source,
167            struct_name,
168            name,
169            crate_path,
170            env,
171        })
172    }
173}
174
175/// Strict, reserved, and weak keywords in Rust that require `r#` when used
176/// as identifiers.  Sourced from the Rust Reference.
177const RUST_KEYWORDS: &[&str] = &[
178    // Strict keywords
179    "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for",
180    "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
181    "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where",
182    "while", "async", "await", "dyn", // Reserved keywords
183    "abstract", "become", "box", "do", "final", "macro", "override", "priv", "typeof", "unsized",
184    "virtual", "yield", "try", // Weak keyword used in specific contexts
185    "union",
186];
187
188/// Create a module identifier, using raw syntax (`r#name`) only when `stem`
189/// is a Rust keyword.
190fn make_module_ident(stem: &str) -> Ident {
191    if RUST_KEYWORDS.contains(&stem) {
192        format_ident!("r#{}", stem)
193    } else {
194        Ident::new(stem, proc_macro2::Span::call_site())
195    }
196}
197
198/// Parse an env block: `{ KEY: expr, KEY2: expr, ... }`.
199///
200/// Each key is an identifier and each value is a literal expression
201/// (string, int, float, or bool).
202fn parse_env_block(input: ParseStream) -> syn::Result<Vec<(String, syn::Expr)>> {
203    let content;
204    syn::braced!(content in input);
205    let mut entries = Vec::new();
206    while !content.is_empty() {
207        let key: Ident = content.parse()?;
208        let _colon: Token![:] = content.parse()?;
209        let expr: syn::Expr = content.parse()?;
210        entries.push((key.to_string(), expr));
211        if content.peek(Token![,]) {
212            let _comma: Token![,] = content.parse()?;
213        }
214    }
215    Ok(entries)
216}
217
218/// Evaluate an env expression at proc-macro expansion time.
219///
220/// Supports:
221/// - String literals: `"value"` → `Value::Str("value")`
222/// - Integer literals: `42` → `Value::Int(42)`
223/// - Float literals: `3.14` → `Value::Float(3.14)`
224/// - Bool literals: `true`/`false` → `Value::Bool(true/false)`
225fn eval_env_expr(expr: &syn::Expr) -> Result<md_tmpl_core::Value, String> {
226    match expr {
227        syn::Expr::Lit(lit) => match &lit.lit {
228            syn::Lit::Str(s) => Ok(md_tmpl_core::Value::Str(s.value())),
229            syn::Lit::Int(i) => {
230                let n: i64 = i
231                    .base10_parse()
232                    .map_err(|e| format!("invalid integer: {e}"))?;
233                Ok(md_tmpl_core::Value::Int(n))
234            }
235            syn::Lit::Float(f) => {
236                let n: f64 = f
237                    .base10_parse()
238                    .map_err(|e| format!("invalid float: {e}"))?;
239                Ok(md_tmpl_core::Value::Float(n))
240            }
241            syn::Lit::Bool(b) => Ok(md_tmpl_core::Value::Bool(b.value)),
242            _ => Err("env value must be a string, int, float, or bool literal".to_string()),
243        },
244        // Handle `true` and `false` as path expressions (syn parses
245        // bare `true`/`false` as Expr::Path, not Expr::Lit, in some contexts).
246        syn::Expr::Path(p) => {
247            if p.path.is_ident("true") {
248                Ok(md_tmpl_core::Value::Bool(true))
249            } else if p.path.is_ident("false") {
250                Ok(md_tmpl_core::Value::Bool(false))
251            } else {
252                Err(format!(
253                    "env value must be a literal, got path: {}",
254                    quote! { #expr }
255                ))
256            }
257        }
258        _ => Err(format!(
259            "env value must be a literal, got: {}",
260            quote! { #expr }
261        )),
262    }
263}
264
265/// Helper: convert a `load_and_compile` error into a compile error token stream.
266fn err_tokens(span: proc_macro2::Span, rel_path: &str, e: &str) -> TokenStream {
267    let msg = format!("template '{rel_path}': {e}");
268    syn::Error::new(span, msg).to_compile_error().into()
269}
270
271/// Pre-parse and validate a `.tmpl.md` template at compile time and emit a
272/// complete module.
273///
274/// # Syntax
275///
276/// ```text
277/// include_template!("path/to/template.tmpl.md");
278/// include_template!("path/to/template.tmpl.md" => custom_mod);
279/// ```
280///
281/// When no custom name is given the module name is derived from the file stem
282/// (e.g. `greeting` from `greeting.tmpl.md`).
283///
284/// # Generated module contents
285///
286/// * `pub fn template() -> &'static Template` — the pre-compiled template
287///   singleton.
288/// * `pub struct Params { … }` — typed parameter struct with:
289///   - `render()` — zero-arg render using the embedded template.
290///   - `render_reloaded(tmpl)` — render with a hot-reloaded template
291///     from disk.
292///   - `validate_template(tmpl)` — check template compatibility.
293///   - `to_context()` — convert to a `Context`.
294/// * Sub-structs for compound types.
295/// * Constants from the `consts:` block.
296/// * Type aliases from the `types:` block.
297///
298/// # Examples
299///
300/// ```rust
301/// extern crate md_tmpl_core as md_tmpl;
302/// md_tmpl_macros::include_template!("prompts/simple_greeting.tmpl.md");
303///
304/// let output = simple_greeting::Params {
305///     name: "World".into(),
306/// }
307/// .render()
308/// .unwrap();
309/// assert_eq!(output, "\nHello World!\n");
310/// ```
311///
312/// # Panics
313///
314/// Panics if an `env` expression cannot be evaluated at macro expansion time.
315#[proc_macro]
316pub fn include_template(input: TokenStream) -> TokenStream {
317    let parsed = parse_macro_input!(input as IncludeTemplateInput);
318    let rel_path = parsed.path.value();
319
320    // Evaluate env expressions at macro expansion time.
321    let env_values: Vec<(String, md_tmpl_core::Value)> = parsed
322        .env
323        .iter()
324        .map(|(k, expr)| {
325            let val = eval_env_expr(expr).unwrap_or_else(|e| panic!("env '{k}': {e}"));
326            (k.clone(), val)
327        })
328        .collect();
329    let env_refs: Vec<(&str, md_tmpl_core::Value)> = env_values
330        .iter()
331        .map(|(k, v)| (k.as_str(), v.clone()))
332        .collect();
333
334    let (full_path, ast) = match load_and_compile(&rel_path, &env_refs) {
335        Ok(v) => v,
336        Err(e) => return err_tokens(parsed.path.span(), &rel_path, &e),
337    };
338    let CompiledTemplateAst {
339        frontmatter: fm,
340        segments,
341        inline_templates,
342        source_hash,
343    } = ast;
344    let path_str = full_path.to_string_lossy().to_string();
345
346    // Module name: custom or derived from file stem.
347    let mod_ident = match parsed.custom_name {
348        Some(ident) => ident,
349        None => make_module_ident(&stem_from_path(&rel_path)),
350    };
351
352    // Crate path: custom or default `::md_tmpl`.
353    let crate_path = parsed
354        .crate_path
355        .map_or_else(|| quote! { ::md_tmpl }, |p| quote! { #p });
356
357    with_crate_path(crate_path.clone(), || {
358        // Template AST codegen.
359        let segments_tokens = segments.iter().map(codegen_segment);
360        let decls_tokens = fm.declarations.iter().map(codegen_var_decl);
361        let inline_templates_tokens = inline_templates.iter().map(|(k, v)| {
362            let v_tokens = codegen_compiled_inline_template(v);
363            quote! { (#k, #v_tokens) }
364        });
365        let consts_tokens = fm.consts.iter().chain(fm.env.iter()).filter_map(|d| {
366            d.default_value.as_ref().map(|v| {
367                let name = &d.name;
368                let val_tokens = codegen_value(v);
369                quote! { (#name, #val_tokens) }
370            })
371        });
372        let imported_consts_tokens = fm.imported_consts.iter().map(|(k, v)| {
373            let val_tokens = codegen_value(v);
374            quote! { (#k, #val_tokens) }
375        });
376
377        // Params struct codegen.
378        let struct_name = parsed
379            .struct_name
380            .unwrap_or_else(|| format_ident!("Params"));
381        let source = StructGenSource::Module {
382            doc_path: &rel_path,
383        };
384        let struct_tokens = generate_struct_tokens(&fm, &struct_name, &source);
385
386        // Type alias codegen.
387        let type_alias_tokens = generate_type_alias_tokens(&fm.type_aliases);
388
389        let name_token = if let Some(n) = &fm.name {
390            quote! { Some(#n) }
391        } else {
392            quote! { None }
393        };
394        let desc_token = if let Some(d) = &fm.description {
395            quote! { Some(#d) }
396        } else {
397            quote! { None }
398        };
399
400        let expanded = quote! {
401            pub mod #mod_ident {
402                const _: &str = include_str!(#path_str);
403
404                fn __init_template() -> #crate_path::Template {
405                    #crate_path::Template::from_precompiled(&#crate_path::PrecompiledTemplateData {
406                        segments: &[#(#segments_tokens),*],
407                        declared_variables: &[#(#decls_tokens),*],
408                        inline_templates: &[#(#inline_templates_tokens),*],
409                        source_hash: #source_hash,
410                        consts: &[#(#consts_tokens),*],
411                        imported_consts: &[#(#imported_consts_tokens),*],
412                        name: #name_token,
413                        description: #desc_token,
414                    })
415                }
416                static __TEMPLATE: #crate_path::__private::LazyLock<#crate_path::Template> =
417                    #crate_path::__private::LazyLock::new(__init_template);
418
419                /// Get a reference to the compile-time validated, pre-compiled template.
420                pub fn template() -> &'static #crate_path::Template {
421                    &*__TEMPLATE
422                }
423
424                #struct_tokens
425                #(#type_alias_tokens)*
426            }
427        };
428        expanded.into()
429    })
430}
431
432/// Parse and validate an inline template string at compile time and emit a
433/// complete module.
434///
435/// Unlike [`include_template!`] which reads from a file, this macro takes a
436/// string literal containing the full template source (including frontmatter).
437/// The `=> module_name` is **required** because there is no file path from
438/// which to derive a name.
439///
440/// The generated module has the same shape as [`include_template!`] — see its
441/// docs for details.
442///
443/// # Examples
444///
445/// ```rust
446/// extern crate md_tmpl_core as md_tmpl;
447/// md_tmpl_macros::template!(
448///     r#"
449/// ---
450/// params:
451///   - name = str
452/// ---
453/// Hello {{ name }}!
454/// "# => greeting
455/// );
456///
457/// let output = greeting::Params { name: "World".into() }
458///     .render()
459///     .unwrap();
460/// assert_eq!(output, "Hello World!\n");
461/// ```
462///
463/// # Panics
464///
465/// Panics if an `env` expression cannot be evaluated at macro expansion time.
466#[proc_macro]
467pub fn template(input: TokenStream) -> TokenStream {
468    let parsed = parse_macro_input!(input as InlineTemplateInput);
469    let source = parsed.source.value();
470    let mod_ident = parsed.name;
471
472    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
473    let base_dir = std::path::Path::new(&manifest_dir);
474
475    // Evaluate env expressions at macro expansion time.
476    let env_values: Vec<(String, md_tmpl_core::Value)> = parsed
477        .env
478        .iter()
479        .map(|(k, expr)| {
480            let val = eval_env_expr(expr).unwrap_or_else(|e| panic!("env '{k}': {e}"));
481            (k.clone(), val)
482        })
483        .collect();
484    let env_refs: Vec<(&str, md_tmpl_core::Value)> = env_values
485        .iter()
486        .map(|(k, v)| (k.as_str(), v.clone()))
487        .collect();
488
489    let ast = match compile::compile_template_to_ast(&source, base_dir, &env_refs) {
490        Ok(v) => v,
491        Err(e) => {
492            let msg = format!("inline template: {e}");
493            return syn::Error::new(parsed.source.span(), msg)
494                .to_compile_error()
495                .into();
496        }
497    };
498    let CompiledTemplateAst {
499        frontmatter: fm,
500        segments,
501        inline_templates,
502        source_hash,
503    } = ast;
504
505    // Crate path: custom or default `::md_tmpl`.
506    let crate_path = parsed
507        .crate_path
508        .map_or_else(|| quote! { ::md_tmpl }, |p| quote! { #p });
509
510    with_crate_path(crate_path.clone(), || {
511        // Template AST codegen.
512        let segments_tokens = segments.iter().map(codegen_segment);
513        let decls_tokens = fm.declarations.iter().map(codegen_var_decl);
514        let inline_templates_tokens = inline_templates.iter().map(|(k, v)| {
515            let v_tokens = codegen_compiled_inline_template(v);
516            quote! { (#k, #v_tokens) }
517        });
518        let consts_tokens = fm.consts.iter().chain(fm.env.iter()).filter_map(|d| {
519            d.default_value.as_ref().map(|v| {
520                let name = &d.name;
521                let val_tokens = codegen_value(v);
522                quote! { (#name, #val_tokens) }
523            })
524        });
525        let imported_consts_tokens = fm.imported_consts.iter().map(|(k, v)| {
526            let val_tokens = codegen_value(v);
527            quote! { (#k, #val_tokens) }
528        });
529
530        // Params struct codegen — uses Module so render() calls super::template().
531        let struct_name = parsed
532            .struct_name
533            .clone()
534            .unwrap_or_else(|| format_ident!("Params"));
535        let source = StructGenSource::Module {
536            doc_path: "<inline>",
537        };
538        let struct_tokens = generate_struct_tokens(&fm, &struct_name, &source);
539
540        // Type alias codegen.
541        let type_alias_tokens = generate_type_alias_tokens(&fm.type_aliases);
542
543        let name_token = if let Some(n) = &fm.name {
544            quote! { Some(#n) }
545        } else {
546            quote! { None }
547        };
548        let desc_token = if let Some(d) = &fm.description {
549            quote! { Some(#d) }
550        } else {
551            quote! { None }
552        };
553
554        let expanded = quote! {
555            pub mod #mod_ident {
556                fn __init_template() -> #crate_path::Template {
557                    #crate_path::Template::from_precompiled(&#crate_path::PrecompiledTemplateData {
558                        segments: &[#(#segments_tokens),*],
559                        declared_variables: &[#(#decls_tokens),*],
560                        inline_templates: &[#(#inline_templates_tokens),*],
561                        source_hash: #source_hash,
562                        consts: &[#(#consts_tokens),*],
563                        imported_consts: &[#(#imported_consts_tokens),*],
564                        name: #name_token,
565                        description: #desc_token,
566                    })
567                }
568                static __TEMPLATE: #crate_path::__private::LazyLock<#crate_path::Template> =
569                    #crate_path::__private::LazyLock::new(__init_template);
570
571                /// Get a reference to the compile-time validated, pre-compiled template.
572                pub fn template() -> &'static #crate_path::Template {
573                    &*__TEMPLATE
574                }
575
576                #struct_tokens
577                #(#type_alias_tokens)*
578            }
579        };
580        expanded.into()
581    })
582}