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    import_paths: Vec<(String, syn::Path)>,
61}
62
63impl Parse for IncludeTemplateInput {
64    fn parse(input: ParseStream) -> syn::Result<Self> {
65        let path: LitStr = input.parse()?;
66
67        // Optional: `as StructName`
68        let struct_name = if input.peek(Token![as]) {
69            let _as: Token![as] = input.parse()?;
70            Some(input.parse()?)
71        } else {
72            None
73        };
74
75        // Optional: `=> custom_mod_name`
76        let custom_name = if input.peek(Token![=>]) {
77            let _arrow: Token![=>] = input.parse()?;
78            Some(input.parse()?)
79        } else {
80            None
81        };
82
83        // Optional trailing arguments: `, crate = ...`, `, env = { ... }`, or
84        // `, imports = { stem = ::rust::path, ... }`.
85        let mut crate_path = None;
86        let mut env = Vec::new();
87        let mut import_paths = Vec::new();
88        while input.peek(Token![,]) {
89            let _comma: Token![,] = input.parse()?;
90            if input.is_empty() {
91                break;
92            }
93            if input.peek(Token![crate]) {
94                let _kw: Token![crate] = input.parse()?;
95                let _eq: Token![=] = input.parse()?;
96                crate_path = Some(input.parse()?);
97            } else {
98                let kw: Ident = input.parse()?;
99                if kw == "env" {
100                    let _eq: Token![=] = input.parse()?;
101                    env = parse_env_block(input)?;
102                } else if kw == "imports" {
103                    let _eq: Token![=] = input.parse()?;
104                    import_paths = parse_imports_block(input)?;
105                } else {
106                    return Err(syn::Error::new(
107                        kw.span(),
108                        format!("unknown option '{kw}', expected 'crate', 'env', or 'imports'"),
109                    ));
110                }
111            }
112        }
113
114        Ok(Self {
115            path,
116            struct_name,
117            custom_name,
118            crate_path,
119            env,
120            import_paths,
121        })
122    }
123}
124
125/// Parsed input for `template!(r#"source"# => mod_name)`.
126///
127/// The `=> name` is **required** because inline templates have no file path
128/// from which to derive a module name.
129struct InlineTemplateInput {
130    source: LitStr,
131    struct_name: Option<Ident>,
132    name: Ident,
133    crate_path: Option<syn::Path>,
134    env: Vec<(String, syn::Expr)>,
135    import_paths: Vec<(String, syn::Path)>,
136}
137
138impl Parse for InlineTemplateInput {
139    fn parse(input: ParseStream) -> syn::Result<Self> {
140        let source: LitStr = input.parse()?;
141        let struct_name = if input.peek(Token![as]) {
142            let _as: Token![as] = input.parse()?;
143            Some(input.parse()?)
144        } else {
145            None
146        };
147        let _: Token![=>] = input.parse()?;
148        let name: Ident = input.parse()?;
149        let mut crate_path = None;
150        let mut env = Vec::new();
151        let mut import_paths = Vec::new();
152        while input.peek(Token![,]) {
153            let _comma: Token![,] = input.parse()?;
154            if input.is_empty() {
155                break;
156            }
157            if input.peek(Token![crate]) {
158                let _kw: Token![crate] = input.parse()?;
159                let _eq: Token![=] = input.parse()?;
160                crate_path = Some(input.parse()?);
161            } else {
162                let kw: Ident = input.parse()?;
163                if kw == "env" {
164                    let _eq: Token![=] = input.parse()?;
165                    env = parse_env_block(input)?;
166                } else if kw == "imports" {
167                    let _eq: Token![=] = input.parse()?;
168                    import_paths = parse_imports_block(input)?;
169                } else {
170                    return Err(syn::Error::new(
171                        kw.span(),
172                        format!("unknown option '{kw}', expected 'crate', 'env', or 'imports'"),
173                    ));
174                }
175            }
176        }
177        Ok(Self {
178            source,
179            struct_name,
180            name,
181            crate_path,
182            env,
183            import_paths,
184        })
185    }
186}
187
188/// Strict, reserved, and weak keywords in Rust that require `r#` when used
189/// as identifiers.  Sourced from the Rust Reference.
190const RUST_KEYWORDS: &[&str] = &[
191    // Strict keywords
192    "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for",
193    "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
194    "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where",
195    "while", "async", "await", "dyn", // Reserved keywords
196    "abstract", "become", "box", "do", "final", "macro", "override", "priv", "typeof", "unsized",
197    "virtual", "yield", "try", // Weak keyword used in specific contexts
198    "union",
199];
200
201/// Keywords that cannot be used as raw identifiers (`r#self` is a compile
202/// error).  For these we prefix with `__` and emit `#[serde(rename = "…")]`.
203const UNESCAPABLE_KEYWORDS: &[&str] = &["self", "Self", "super", "crate"];
204
205/// Create an identifier, using raw syntax (`r#name`) when `name` is a Rust
206/// keyword.  For the four keywords that cannot be raw identifiers (`self`,
207/// `Self`, `super`, `crate`), we prefix with `__` instead (e.g. `__self`).
208///
209/// Works for module names, field names, and any user-provided identifier that
210/// might collide with a keyword.
211pub(crate) fn make_ident(name: &str) -> Ident {
212    if UNESCAPABLE_KEYWORDS.contains(&name) {
213        format_ident!("__{}", name)
214    } else if RUST_KEYWORDS.contains(&name) {
215        format_ident!("r#{}", name)
216    } else {
217        Ident::new(name, proc_macro2::Span::call_site())
218    }
219}
220
221/// Returns a `#[serde(rename = "original")]` attribute token stream when the
222/// name was mangled by [`make_ident`] (i.e., one of the un-escapable keywords).
223/// Returns an empty token stream otherwise — safe to interpolate unconditionally.
224pub(crate) fn serde_rename_attr(name: &str) -> proc_macro2::TokenStream {
225    if UNESCAPABLE_KEYWORDS.contains(&name) {
226        quote! { #[serde(rename = #name)] }
227    } else {
228        quote! {}
229    }
230}
231
232/// Parse an env block: `{ KEY: expr, KEY2: expr, ... }`.
233///
234/// Each key is an identifier and each value is a literal expression
235/// (string, int, float, or bool).
236fn parse_env_block(input: ParseStream) -> syn::Result<Vec<(String, syn::Expr)>> {
237    let content;
238    syn::braced!(content in input);
239    let mut entries = Vec::new();
240    while !content.is_empty() {
241        let key: Ident = content.parse()?;
242        let _colon: Token![:] = content.parse()?;
243        let expr: syn::Expr = content.parse()?;
244        entries.push((key.to_string(), expr));
245        if content.peek(Token![,]) {
246            let _comma: Token![,] = content.parse()?;
247        }
248    }
249    Ok(entries)
250}
251
252/// Parse an imports block: `{ stem = ::rust::path, stem2 = crate::mod }`.
253///
254/// Maps each import stem (as declared in the template's `imports:` block) to
255/// the Rust module path where that imported template's generated types live.
256/// Used to reference imported enum types directly instead of emitting a
257/// duplicate per-template copy.
258fn parse_imports_block(input: ParseStream) -> syn::Result<Vec<(String, syn::Path)>> {
259    let content;
260    syn::braced!(content in input);
261    let mut entries = Vec::new();
262    while !content.is_empty() {
263        let stem: Ident = content.parse()?;
264        let _eq: Token![=] = content.parse()?;
265        let path: syn::Path = content.parse()?;
266        entries.push((stem.to_string(), path));
267        if content.peek(Token![,]) {
268            let _comma: Token![,] = content.parse()?;
269        }
270    }
271    Ok(entries)
272}
273
274/// Resolve each imported-enum param to the full Rust path of its imported type.
275///
276/// Intersects the frontmatter's [`imported_type_params`] (param → `(stem,
277/// type_name)`) with the caller-provided `imports = { stem = path }` mapping,
278/// yielding `param_name → tokens(path::TypeName)`. Params whose import stem was
279/// not mapped are omitted, so codegen falls back to emitting a fresh type.
280///
281/// [`imported_type_params`]: md_tmpl_core::Frontmatter::imported_type_params
282fn build_imported_type_paths(
283    fm: &md_tmpl_core::Frontmatter,
284    import_paths: &[(String, syn::Path)],
285) -> std::collections::HashMap<String, proc_macro2::TokenStream> {
286    let mut out = std::collections::HashMap::new();
287    for (param, (stem, type_name)) in &fm.imported_type_params {
288        if let Some((_, base)) = import_paths.iter().find(|(s, _)| s == stem) {
289            let ty = format_ident!("{}", type_name);
290            out.insert(param.clone(), quote! { #base::#ty });
291        }
292    }
293    out
294}
295
296/// Evaluate an env expression at proc-macro expansion time.
297///
298/// Supports:
299/// - String literals: `"value"` → `Value::Str("value")`
300/// - Integer literals: `42` → `Value::Int(42)`
301/// - Float literals: `3.14` → `Value::Float(3.14)`
302/// - Bool literals: `true`/`false` → `Value::Bool(true/false)`
303fn eval_env_expr(expr: &syn::Expr) -> Result<md_tmpl_core::Value, String> {
304    match expr {
305        syn::Expr::Lit(lit) => match &lit.lit {
306            syn::Lit::Str(s) => Ok(md_tmpl_core::Value::Str(s.value())),
307            syn::Lit::Int(i) => {
308                let n: i64 = i
309                    .base10_parse()
310                    .map_err(|e| format!("invalid integer: {e}"))?;
311                Ok(md_tmpl_core::Value::Int(n))
312            }
313            syn::Lit::Float(f) => {
314                let n: f64 = f
315                    .base10_parse()
316                    .map_err(|e| format!("invalid float: {e}"))?;
317                Ok(md_tmpl_core::Value::Float(n))
318            }
319            syn::Lit::Bool(b) => Ok(md_tmpl_core::Value::Bool(b.value)),
320            _ => Err("env value must be a string, int, float, or bool literal".to_string()),
321        },
322        // Handle `true` and `false` as path expressions (syn parses
323        // bare `true`/`false` as Expr::Path, not Expr::Lit, in some contexts).
324        syn::Expr::Path(p) => {
325            if p.path.is_ident("true") {
326                Ok(md_tmpl_core::Value::Bool(true))
327            } else if p.path.is_ident("false") {
328                Ok(md_tmpl_core::Value::Bool(false))
329            } else {
330                Err(format!(
331                    "env value must be a literal, got path: {}",
332                    quote! { #expr }
333                ))
334            }
335        }
336        _ => Err(format!(
337            "env value must be a literal, got: {}",
338            quote! { #expr }
339        )),
340    }
341}
342
343/// Helper: convert a `load_and_compile` error into a compile error token stream.
344fn err_tokens(span: proc_macro2::Span, rel_path: &str, e: &str) -> TokenStream {
345    let msg = format!("template '{rel_path}': {e}");
346    syn::Error::new(span, msg).to_compile_error().into()
347}
348
349/// Pre-parse and validate a `.tmpl.md` template at compile time and emit a
350/// complete module.
351///
352/// # Syntax
353///
354/// ```text
355/// include_template!("path/to/template.tmpl.md");
356/// include_template!("path/to/template.tmpl.md" => custom_mod);
357/// ```
358///
359/// When no custom name is given the module name is derived from the file stem
360/// (e.g. `greeting` from `greeting.tmpl.md`).
361///
362/// # Generated module contents
363///
364/// * `pub fn template() -> &'static Template` — the pre-compiled template
365///   singleton.
366/// * `pub struct Params { … }` — typed parameter struct with:
367///   - `render()` — zero-arg render using the embedded template.
368///   - `render_reloaded(tmpl)` — render with a hot-reloaded template
369///     from disk.
370///   - `validate_template(tmpl)` — check template compatibility.
371///   - `to_context()` — convert to a `Context`.
372/// * Sub-structs for compound types.
373/// * Constants from the `consts:` block.
374/// * Type aliases from the `types:` block.
375///
376/// # Examples
377///
378/// ```rust
379/// extern crate md_tmpl_core as md_tmpl;
380/// md_tmpl_macros::include_template!("prompts/simple_greeting.tmpl.md");
381///
382/// let output = simple_greeting::Params {
383///     name: "World".into(),
384/// }
385/// .render()
386/// .unwrap();
387/// assert_eq!(output, "\nHello World!\n");
388/// ```
389///
390/// # Panics
391///
392/// Panics if an `env` expression cannot be evaluated at macro expansion time.
393#[proc_macro]
394pub fn include_template(input: TokenStream) -> TokenStream {
395    let parsed = parse_macro_input!(input as IncludeTemplateInput);
396    let rel_path = parsed.path.value();
397
398    // Evaluate env expressions at macro expansion time.
399    let env_values: Vec<(String, md_tmpl_core::Value)> = parsed
400        .env
401        .iter()
402        .map(|(k, expr)| {
403            let val = eval_env_expr(expr).unwrap_or_else(|e| panic!("env '{k}': {e}"));
404            (k.clone(), val)
405        })
406        .collect();
407    let env_refs: Vec<(&str, md_tmpl_core::Value)> = env_values
408        .iter()
409        .map(|(k, v)| (k.as_str(), v.clone()))
410        .collect();
411
412    let (full_path, ast) = match load_and_compile(&rel_path, &env_refs) {
413        Ok(v) => v,
414        Err(e) => return err_tokens(parsed.path.span(), &rel_path, &e),
415    };
416    let CompiledTemplateAst {
417        frontmatter: fm,
418        segments,
419        inline_templates,
420        source_hash,
421    } = ast;
422    let path_str = full_path.to_string_lossy().to_string();
423
424    // Module name: custom or derived from file stem.
425    let mod_ident = match parsed.custom_name {
426        Some(ident) => ident,
427        None => make_ident(&stem_from_path(&rel_path)),
428    };
429
430    // Crate path: custom or default `::md_tmpl`.
431    let crate_path = parsed
432        .crate_path
433        .map_or_else(|| quote! { ::md_tmpl }, |p| quote! { #p });
434
435    with_crate_path(crate_path.clone(), || {
436        // Template AST codegen.
437        let segments_tokens = segments.iter().map(codegen_segment);
438        let decls_tokens = fm.declarations.iter().map(codegen_var_decl);
439        let inline_templates_tokens = inline_templates.iter().map(|(k, v)| {
440            let v_tokens = codegen_compiled_inline_template(v);
441            quote! { (#k, #v_tokens) }
442        });
443        let consts_tokens = fm.consts.iter().chain(fm.env.iter()).filter_map(|d| {
444            d.default_value.as_ref().map(|v| {
445                let name = &d.name;
446                let val_tokens = codegen_value(v);
447                quote! { (#name, #val_tokens) }
448            })
449        });
450        let imported_consts_tokens = fm.imported_consts.iter().map(|(k, v)| {
451            let val_tokens = codegen_value(v);
452            quote! { (#k, #val_tokens) }
453        });
454
455        // Params struct codegen.
456        let struct_name = parsed
457            .struct_name
458            .unwrap_or_else(|| format_ident!("Params"));
459        let source = StructGenSource::Module {
460            doc_path: &rel_path,
461        };
462        let imported_type_paths = build_imported_type_paths(&fm, &parsed.import_paths);
463        let struct_tokens =
464            generate_struct_tokens(&fm, &struct_name, &source, &imported_type_paths);
465
466        // Type alias codegen.
467        let type_alias_tokens = generate_type_alias_tokens(&fm.type_aliases);
468
469        let name_token = if let Some(n) = &fm.name {
470            quote! { Some(#n) }
471        } else {
472            quote! { None }
473        };
474        let desc_token = if let Some(d) = &fm.description {
475            quote! { Some(#d) }
476        } else {
477            quote! { None }
478        };
479
480        let expanded = quote! {
481            pub mod #mod_ident {
482                const _: &str = include_str!(#path_str);
483
484                fn __init_template() -> #crate_path::Template {
485                    #crate_path::Template::from_precompiled(&#crate_path::PrecompiledTemplateData {
486                        segments: &[#(#segments_tokens),*],
487                        declared_variables: &[#(#decls_tokens),*],
488                        inline_templates: &[#(#inline_templates_tokens),*],
489                        source_hash: #source_hash,
490                        consts: &[#(#consts_tokens),*],
491                        imported_consts: &[#(#imported_consts_tokens),*],
492                        name: #name_token,
493                        description: #desc_token,
494                    })
495                }
496                static __TEMPLATE: #crate_path::__private::LazyLock<#crate_path::Template> =
497                    #crate_path::__private::LazyLock::new(__init_template);
498
499                /// Get a reference to the compile-time validated, pre-compiled template.
500                pub fn template() -> &'static #crate_path::Template {
501                    &*__TEMPLATE
502                }
503
504                #struct_tokens
505                #(#type_alias_tokens)*
506            }
507        };
508        expanded.into()
509    })
510}
511
512/// Parse and validate an inline template string at compile time and emit a
513/// complete module.
514///
515/// Unlike [`include_template!`] which reads from a file, this macro takes a
516/// string literal containing the full template source (including frontmatter).
517/// The `=> module_name` is **required** because there is no file path from
518/// which to derive a name.
519///
520/// The generated module has the same shape as [`include_template!`] — see its
521/// docs for details.
522///
523/// # Examples
524///
525/// ```rust
526/// extern crate md_tmpl_core as md_tmpl;
527/// md_tmpl_macros::template!(
528///     r#"
529/// ---
530/// params:
531///   - name = str
532/// ---
533/// Hello {{ name }}!
534/// "# => greeting
535/// );
536///
537/// let output = greeting::Params { name: "World".into() }
538///     .render()
539///     .unwrap();
540/// assert_eq!(output, "Hello World!\n");
541/// ```
542///
543/// # Panics
544///
545/// Panics if an `env` expression cannot be evaluated at macro expansion time.
546#[proc_macro]
547pub fn template(input: TokenStream) -> TokenStream {
548    let parsed = parse_macro_input!(input as InlineTemplateInput);
549    let source = parsed.source.value();
550    let mod_ident = parsed.name;
551
552    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
553    let base_dir = std::path::Path::new(&manifest_dir);
554
555    // Evaluate env expressions at macro expansion time.
556    let env_values: Vec<(String, md_tmpl_core::Value)> = parsed
557        .env
558        .iter()
559        .map(|(k, expr)| {
560            let val = eval_env_expr(expr).unwrap_or_else(|e| panic!("env '{k}': {e}"));
561            (k.clone(), val)
562        })
563        .collect();
564    let env_refs: Vec<(&str, md_tmpl_core::Value)> = env_values
565        .iter()
566        .map(|(k, v)| (k.as_str(), v.clone()))
567        .collect();
568
569    let ast = match compile::compile_template_to_ast(&source, base_dir, &env_refs) {
570        Ok(v) => v,
571        Err(e) => {
572            let msg = format!("inline template: {e}");
573            return syn::Error::new(parsed.source.span(), msg)
574                .to_compile_error()
575                .into();
576        }
577    };
578    let CompiledTemplateAst {
579        frontmatter: fm,
580        segments,
581        inline_templates,
582        source_hash,
583    } = ast;
584
585    // Crate path: custom or default `::md_tmpl`.
586    let crate_path = parsed
587        .crate_path
588        .map_or_else(|| quote! { ::md_tmpl }, |p| quote! { #p });
589
590    with_crate_path(crate_path.clone(), || {
591        // Template AST codegen.
592        let segments_tokens = segments.iter().map(codegen_segment);
593        let decls_tokens = fm.declarations.iter().map(codegen_var_decl);
594        let inline_templates_tokens = inline_templates.iter().map(|(k, v)| {
595            let v_tokens = codegen_compiled_inline_template(v);
596            quote! { (#k, #v_tokens) }
597        });
598        let consts_tokens = fm.consts.iter().chain(fm.env.iter()).filter_map(|d| {
599            d.default_value.as_ref().map(|v| {
600                let name = &d.name;
601                let val_tokens = codegen_value(v);
602                quote! { (#name, #val_tokens) }
603            })
604        });
605        let imported_consts_tokens = fm.imported_consts.iter().map(|(k, v)| {
606            let val_tokens = codegen_value(v);
607            quote! { (#k, #val_tokens) }
608        });
609
610        // Params struct codegen — uses Module so render() calls super::template().
611        let struct_name = parsed
612            .struct_name
613            .clone()
614            .unwrap_or_else(|| format_ident!("Params"));
615        let source = StructGenSource::Module {
616            doc_path: "<inline>",
617        };
618        let imported_type_paths = build_imported_type_paths(&fm, &parsed.import_paths);
619        let struct_tokens =
620            generate_struct_tokens(&fm, &struct_name, &source, &imported_type_paths);
621
622        // Type alias codegen.
623        let type_alias_tokens = generate_type_alias_tokens(&fm.type_aliases);
624
625        let name_token = if let Some(n) = &fm.name {
626            quote! { Some(#n) }
627        } else {
628            quote! { None }
629        };
630        let desc_token = if let Some(d) = &fm.description {
631            quote! { Some(#d) }
632        } else {
633            quote! { None }
634        };
635
636        let expanded = quote! {
637            pub mod #mod_ident {
638                fn __init_template() -> #crate_path::Template {
639                    #crate_path::Template::from_precompiled(&#crate_path::PrecompiledTemplateData {
640                        segments: &[#(#segments_tokens),*],
641                        declared_variables: &[#(#decls_tokens),*],
642                        inline_templates: &[#(#inline_templates_tokens),*],
643                        source_hash: #source_hash,
644                        consts: &[#(#consts_tokens),*],
645                        imported_consts: &[#(#imported_consts_tokens),*],
646                        name: #name_token,
647                        description: #desc_token,
648                    })
649                }
650                static __TEMPLATE: #crate_path::__private::LazyLock<#crate_path::Template> =
651                    #crate_path::__private::LazyLock::new(__init_template);
652
653                /// Get a reference to the compile-time validated, pre-compiled template.
654                pub fn template() -> &'static #crate_path::Template {
655                    &*__TEMPLATE
656                }
657
658                #struct_tokens
659                #(#type_alias_tokens)*
660            }
661        };
662        expanded.into()
663    })
664}