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: env!("VAR") })`.
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 an arbitrary Rust expression
201/// (typically a string literal or `env!("VAR_NAME")`).
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)`
225/// - `env!("VAR")` → reads `std::env::var("VAR")` at compile time → `Value::Str(...)`
226fn eval_env_expr(expr: &syn::Expr) -> Result<md_tmpl_core::Value, String> {
227    match expr {
228        syn::Expr::Lit(lit) => match &lit.lit {
229            syn::Lit::Str(s) => Ok(md_tmpl_core::Value::Str(s.value())),
230            syn::Lit::Int(i) => {
231                let n: i64 = i
232                    .base10_parse()
233                    .map_err(|e| format!("invalid integer: {e}"))?;
234                Ok(md_tmpl_core::Value::Int(n))
235            }
236            syn::Lit::Float(f) => {
237                let n: f64 = f
238                    .base10_parse()
239                    .map_err(|e| format!("invalid float: {e}"))?;
240                Ok(md_tmpl_core::Value::Float(n))
241            }
242            syn::Lit::Bool(b) => Ok(md_tmpl_core::Value::Bool(b.value)),
243            _ => Err(
244                "env value must be a string, int, float, bool literal, or env!(\"VAR\")"
245                    .to_string(),
246            ),
247        },
248        syn::Expr::Macro(m) => {
249            // Support env!("VAR_NAME")
250            let path = &m.mac.path;
251            let is_env = path.is_ident("env");
252            if !is_env {
253                return Err(format!(
254                    "only literals and env!() are supported in macro env values, got: {}",
255                    quote! { #expr }
256                ));
257            }
258            let tokens = &m.mac.tokens;
259            let var_name: LitStr = syn::parse2(tokens.clone())
260                .map_err(|e| format!("env!() argument must be a string literal: {e}"))?;
261            let val = std::env::var(var_name.value())
262                .map_err(|e| format!("env!(\"{}\") at compile time: {e}", var_name.value()))?;
263            Ok(md_tmpl_core::Value::Str(val))
264        }
265        // Handle `true` and `false` as path expressions (syn parses
266        // bare `true`/`false` as Expr::Path, not Expr::Lit, in some contexts).
267        syn::Expr::Path(p) => {
268            if p.path.is_ident("true") {
269                Ok(md_tmpl_core::Value::Bool(true))
270            } else if p.path.is_ident("false") {
271                Ok(md_tmpl_core::Value::Bool(false))
272            } else {
273                Err(format!(
274                    "env value must be a literal or env!(\"VAR\"), got path: {}",
275                    quote! { #expr }
276                ))
277            }
278        }
279        _ => Err(format!(
280            "env value must be a literal or env!(\"VAR\"), got: {}",
281            quote! { #expr }
282        )),
283    }
284}
285
286/// Helper: convert a `load_and_compile` error into a compile error token stream.
287fn err_tokens(span: proc_macro2::Span, rel_path: &str, e: &str) -> TokenStream {
288    let msg = format!("template '{rel_path}': {e}");
289    syn::Error::new(span, msg).to_compile_error().into()
290}
291
292/// Pre-parse and validate a `.tmpl.md` template at compile time and emit a
293/// complete module.
294///
295/// # Syntax
296///
297/// ```text
298/// include_template!("path/to/template.tmpl.md");
299/// include_template!("path/to/template.tmpl.md" => custom_mod);
300/// ```
301///
302/// When no custom name is given the module name is derived from the file stem
303/// (e.g. `greeting` from `greeting.tmpl.md`).
304///
305/// # Generated module contents
306///
307/// * `pub fn template() -> &'static Template` — the pre-compiled template
308///   singleton.
309/// * `pub struct Params { … }` — typed parameter struct with:
310///   - `render()` — zero-arg render using the embedded template.
311///   - `render_reloaded(tmpl)` — render with a hot-reloaded template
312///     from disk.
313///   - `validate_template(tmpl)` — check template compatibility.
314///   - `to_context()` — convert to a `Context`.
315/// * Sub-structs for compound types.
316/// * Constants from the `consts:` block.
317/// * Type aliases from the `types:` block.
318///
319/// # Examples
320///
321/// ```rust
322/// extern crate md_tmpl_core as md_tmpl;
323/// md_tmpl_macros::include_template!("prompts/simple_greeting.tmpl.md");
324///
325/// let output = simple_greeting::Params {
326///     name: "World".into(),
327/// }
328/// .render()
329/// .unwrap();
330/// assert_eq!(output, "\nHello World!\n");
331/// ```
332///
333/// # Panics
334///
335/// Panics if an `env` expression cannot be evaluated at macro expansion time
336/// (e.g. a referenced environment variable is not set).
337#[proc_macro]
338pub fn include_template(input: TokenStream) -> TokenStream {
339    let parsed = parse_macro_input!(input as IncludeTemplateInput);
340    let rel_path = parsed.path.value();
341
342    // Evaluate env expressions at macro expansion time.
343    let env_values: Vec<(String, md_tmpl_core::Value)> = parsed
344        .env
345        .iter()
346        .map(|(k, expr)| {
347            let val = eval_env_expr(expr).unwrap_or_else(|e| panic!("env '{k}': {e}"));
348            (k.clone(), val)
349        })
350        .collect();
351    let env_refs: Vec<(&str, md_tmpl_core::Value)> = env_values
352        .iter()
353        .map(|(k, v)| (k.as_str(), v.clone()))
354        .collect();
355
356    let (full_path, ast) = match load_and_compile(&rel_path, &env_refs) {
357        Ok(v) => v,
358        Err(e) => return err_tokens(parsed.path.span(), &rel_path, &e),
359    };
360    let CompiledTemplateAst {
361        frontmatter: fm,
362        segments,
363        inline_templates,
364        source_hash,
365    } = ast;
366    let path_str = full_path.to_string_lossy().to_string();
367
368    // Module name: custom or derived from file stem.
369    let mod_ident = match parsed.custom_name {
370        Some(ident) => ident,
371        None => make_module_ident(&stem_from_path(&rel_path)),
372    };
373
374    // Crate path: custom or default `::md_tmpl`.
375    let crate_path = parsed
376        .crate_path
377        .map_or_else(|| quote! { ::md_tmpl }, |p| quote! { #p });
378
379    with_crate_path(crate_path.clone(), || {
380        // Template AST codegen.
381        let segments_tokens = segments.iter().map(codegen_segment);
382        let decls_tokens = fm.declarations.iter().map(codegen_var_decl);
383        let inline_templates_tokens = inline_templates.iter().map(|(k, v)| {
384            let v_tokens = codegen_compiled_inline_template(v);
385            quote! { (#k, #v_tokens) }
386        });
387        let consts_tokens = fm.consts.iter().chain(fm.env.iter()).filter_map(|d| {
388            d.default_value.as_ref().map(|v| {
389                let name = &d.name;
390                let val_tokens = codegen_value(v);
391                quote! { (#name, #val_tokens) }
392            })
393        });
394        let imported_consts_tokens = fm.imported_consts.iter().map(|(k, v)| {
395            let val_tokens = codegen_value(v);
396            quote! { (#k, #val_tokens) }
397        });
398
399        // Params struct codegen.
400        let struct_name = parsed
401            .struct_name
402            .unwrap_or_else(|| format_ident!("Params"));
403        let source = StructGenSource::Module {
404            doc_path: &rel_path,
405        };
406        let struct_tokens = generate_struct_tokens(&fm, &struct_name, &source);
407
408        // Type alias codegen.
409        let type_alias_tokens = generate_type_alias_tokens(&fm.type_aliases);
410
411        let name_token = if let Some(n) = &fm.name {
412            quote! { Some(#n) }
413        } else {
414            quote! { None }
415        };
416        let desc_token = if let Some(d) = &fm.description {
417            quote! { Some(#d) }
418        } else {
419            quote! { None }
420        };
421
422        let expanded = quote! {
423            pub mod #mod_ident {
424                const _: &str = include_str!(#path_str);
425
426                static __TEMPLATE: #crate_path::__private::LazyLock<#crate_path::Template> =
427                    #crate_path::__private::LazyLock::new(|| {
428                        #crate_path::Template::from_precompiled(&#crate_path::PrecompiledTemplateData {
429                            segments: &[#(#segments_tokens),*],
430                            declared_variables: &[#(#decls_tokens),*],
431                            inline_templates: &[#(#inline_templates_tokens),*],
432                            source_hash: #source_hash,
433                            consts: &[#(#consts_tokens),*],
434                            imported_consts: &[#(#imported_consts_tokens),*],
435                            name: #name_token,
436                            description: #desc_token,
437                        })
438                    });
439
440                /// Get a reference to the compile-time validated, pre-compiled template.
441                pub fn template() -> &'static #crate_path::Template {
442                    &*__TEMPLATE
443                }
444
445                #struct_tokens
446                #(#type_alias_tokens)*
447            }
448        };
449        expanded.into()
450    })
451}
452
453/// Parse and validate an inline template string at compile time and emit a
454/// complete module.
455///
456/// Unlike [`include_template!`] which reads from a file, this macro takes a
457/// string literal containing the full template source (including frontmatter).
458/// The `=> module_name` is **required** because there is no file path from
459/// which to derive a name.
460///
461/// The generated module has the same shape as [`include_template!`] — see its
462/// docs for details.
463///
464/// # Examples
465///
466/// ```rust
467/// extern crate md_tmpl_core as md_tmpl;
468/// md_tmpl_macros::template!(
469///     r#"
470/// ---
471/// params:
472///   - name = str
473/// ---
474/// Hello {{ name }}!
475/// "# => greeting
476/// );
477///
478/// let output = greeting::Params { name: "World".into() }
479///     .render()
480///     .unwrap();
481/// assert_eq!(output, "Hello World!\n");
482/// ```
483///
484/// # Panics
485///
486/// Panics if an `env` expression cannot be evaluated at macro expansion time
487/// (e.g. a referenced environment variable is not set).
488#[proc_macro]
489pub fn template(input: TokenStream) -> TokenStream {
490    let parsed = parse_macro_input!(input as InlineTemplateInput);
491    let source = parsed.source.value();
492    let mod_ident = parsed.name;
493
494    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
495    let base_dir = std::path::Path::new(&manifest_dir);
496
497    // Evaluate env expressions at macro expansion time.
498    let env_values: Vec<(String, md_tmpl_core::Value)> = parsed
499        .env
500        .iter()
501        .map(|(k, expr)| {
502            let val = eval_env_expr(expr).unwrap_or_else(|e| panic!("env '{k}': {e}"));
503            (k.clone(), val)
504        })
505        .collect();
506    let env_refs: Vec<(&str, md_tmpl_core::Value)> = env_values
507        .iter()
508        .map(|(k, v)| (k.as_str(), v.clone()))
509        .collect();
510
511    let ast = match compile::compile_template_to_ast(&source, base_dir, &env_refs) {
512        Ok(v) => v,
513        Err(e) => {
514            let msg = format!("inline template: {e}");
515            return syn::Error::new(parsed.source.span(), msg)
516                .to_compile_error()
517                .into();
518        }
519    };
520    let CompiledTemplateAst {
521        frontmatter: fm,
522        segments,
523        inline_templates,
524        source_hash,
525    } = ast;
526
527    // Crate path: custom or default `::md_tmpl`.
528    let crate_path = parsed
529        .crate_path
530        .map_or_else(|| quote! { ::md_tmpl }, |p| quote! { #p });
531
532    with_crate_path(crate_path.clone(), || {
533        // Template AST codegen.
534        let segments_tokens = segments.iter().map(codegen_segment);
535        let decls_tokens = fm.declarations.iter().map(codegen_var_decl);
536        let inline_templates_tokens = inline_templates.iter().map(|(k, v)| {
537            let v_tokens = codegen_compiled_inline_template(v);
538            quote! { (#k, #v_tokens) }
539        });
540        let consts_tokens = fm.consts.iter().chain(fm.env.iter()).filter_map(|d| {
541            d.default_value.as_ref().map(|v| {
542                let name = &d.name;
543                let val_tokens = codegen_value(v);
544                quote! { (#name, #val_tokens) }
545            })
546        });
547        let imported_consts_tokens = fm.imported_consts.iter().map(|(k, v)| {
548            let val_tokens = codegen_value(v);
549            quote! { (#k, #val_tokens) }
550        });
551
552        // Params struct codegen — uses Module so render() calls super::template().
553        let struct_name = parsed
554            .struct_name
555            .clone()
556            .unwrap_or_else(|| format_ident!("Params"));
557        let source = StructGenSource::Module {
558            doc_path: "<inline>",
559        };
560        let struct_tokens = generate_struct_tokens(&fm, &struct_name, &source);
561
562        // Type alias codegen.
563        let type_alias_tokens = generate_type_alias_tokens(&fm.type_aliases);
564
565        let name_token = if let Some(n) = &fm.name {
566            quote! { Some(#n) }
567        } else {
568            quote! { None }
569        };
570        let desc_token = if let Some(d) = &fm.description {
571            quote! { Some(#d) }
572        } else {
573            quote! { None }
574        };
575
576        let expanded = quote! {
577            pub mod #mod_ident {
578                static __TEMPLATE: #crate_path::__private::LazyLock<#crate_path::Template> =
579                    #crate_path::__private::LazyLock::new(|| {
580                        #crate_path::Template::from_precompiled(&#crate_path::PrecompiledTemplateData {
581                            segments: &[#(#segments_tokens),*],
582                            declared_variables: &[#(#decls_tokens),*],
583                            inline_templates: &[#(#inline_templates_tokens),*],
584                            source_hash: #source_hash,
585                            consts: &[#(#consts_tokens),*],
586                            imported_consts: &[#(#imported_consts_tokens),*],
587                            name: #name_token,
588                            description: #desc_token,
589                        })
590                    });
591
592                /// Get a reference to the compile-time validated, pre-compiled template.
593                pub fn template() -> &'static #crate_path::Template {
594                    &*__TEMPLATE
595                }
596
597                #struct_tokens
598                #(#type_alias_tokens)*
599            }
600        };
601        expanded.into()
602    })
603}