Skip to main content

md_tmpl_core/template/
mod.rs

1use alloc::{
2    string::{String, ToString},
3    sync::Arc,
4    vec::Vec,
5};
6#[cfg(feature = "std")]
7use std::path::{Path, PathBuf};
8
9use crate::{
10    compat::{HashMap, HashSet},
11    compiled::{self, CompiledInlineTemplate, Segment},
12    context::Context,
13    error::TemplateError,
14    frontmatter::{self, Frontmatter},
15    types::VarDecl,
16    value::Value,
17};
18
19mod analysis;
20mod render_methods;
21#[cfg(not(feature = "std"))]
22use self::analysis::hash_source_no_std;
23use self::analysis::{
24    check_bare_enum_access, check_internal_key_access, check_name_collisions,
25    check_static_enum_in_conditions, check_undeclared_variables, check_unused_params,
26    collect_enum_type_keys, inject_enum_type_constants,
27};
28
29/// Configuration for template compilation.
30///
31/// Collects all optional parameters that previously required separate
32/// constructor functions. Use with [`Template::compile`] or
33/// [`Template::compile_file`].
34///
35/// # Examples
36///
37/// ```
38/// use md_tmpl_core::CompileOptions;
39///
40/// // Default options (strict mode):
41/// let opts = CompileOptions::default();
42///
43/// // Allow unused declared parameters:
44/// let opts = CompileOptions::default().allow_unused(true);
45/// ```
46#[non_exhaustive]
47#[derive(Debug, Clone, Copy, Default)]
48pub struct CompileOptions<'a> {
49    /// When `true`, declared parameters that are never referenced in the
50    /// template body are allowed instead of producing an error.
51    ///
52    /// Equivalent to `allow_unused: true` in frontmatter.
53    pub allow_unused: bool,
54    /// Base directory for resolving `{% include %}` and `{% import %}` directives.
55    ///
56    /// When `None`, includes are not resolved (suitable for in-memory templates).
57    #[cfg(feature = "std")]
58    pub base_dir: Option<&'a std::path::Path>,
59    /// Compile-time environment variables (name-value pairs).
60    /// Values are typed — they must match the type declared in `env:` frontmatter.
61    /// String values for scalar types (int, bool, float) are auto-parsed.
62    pub env: &'a [(&'a str, crate::Value)],
63    // Lifetime anchor for no_std where base_dir doesn't exist.
64    #[cfg(not(feature = "std"))]
65    _phantom: core::marker::PhantomData<&'a ()>,
66}
67
68#[cfg(feature = "std")]
69impl<'a> CompileOptions<'a> {
70    /// Set the base directory for include resolution.
71    #[must_use]
72    pub fn base_dir(mut self, dir: &'a std::path::Path) -> Self {
73        self.base_dir = Some(dir);
74        self
75    }
76}
77
78impl<'a> CompileOptions<'a> {
79    /// Allow unused declared parameters.
80    #[must_use]
81    pub fn allow_unused(mut self, allow: bool) -> Self {
82        self.allow_unused = allow;
83        self
84    }
85
86    /// Set compile-time environment variables.
87    #[must_use]
88    pub fn env(mut self, pairs: &'a [(&'a str, crate::Value)]) -> Self {
89        self.env = pairs;
90        self
91    }
92}
93
94/// A parsed template ready for rendering.
95///
96/// Templates can be loaded from files or parsed from in-memory strings.
97/// Variable declarations from frontmatter are used for context validation
98/// before rendering.
99pub struct Template {
100    /// The template body text (after stripping frontmatter).
101    body: String,
102    /// Template name (from frontmatter).
103    name: Option<String>,
104    /// Template description (from frontmatter).
105    description: Option<String>,
106    /// Pre-compiled segment instructions (the fast render path).
107    segments: Arc<[Segment]>,
108    /// Declared variables from frontmatter.
109    declared_variables: Arc<[VarDecl]>,
110    /// Base directory for resolving includes (from file path).
111    #[cfg(feature = "std")]
112    base_dir: Option<PathBuf>,
113    /// Pre-compiled inline template definitions (`{% tmpl name %}...{% /tmpl %}`).
114    inline_templates: Arc<HashMap<String, CompiledInlineTemplate>>,
115    source_hash: u64,
116    max_include_depth: usize,
117    /// Pre-computed: true if any declared variable has a default value.
118    has_defaults: bool,
119    /// Constants defined in this template.
120    consts: Arc<HashMap<String, crate::value::Value>>,
121    /// Imported constants keyed by `stem.NAME`.
122    imported_consts: Arc<HashMap<String, crate::value::Value>>,
123    /// Pre-computed estimated output capacity (cached from segment tree walk).
124    estimated_capacity: usize,
125    /// Cached `TypeId`s of Rust types that have passed validation.
126    ///
127    /// When `render::<T>()` is called, the first invocation runs full
128    /// `validate_context`. If it passes, `TypeId::of::<T>()` is stored
129    /// here so subsequent calls skip validation entirely.
130    #[cfg(feature = "std")]
131    checked_type_ids: std::sync::Mutex<Vec<core::any::TypeId>>,
132}
133
134impl core::fmt::Debug for Template {
135    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
136        f.debug_struct("Template")
137            .field("body", &self.body)
138            .field("name", &self.name)
139            .field("description", &self.description)
140            .field("segments", &self.segments)
141            .field("declared_variables", &self.declared_variables)
142            .field("source_hash", &self.source_hash)
143            .finish_non_exhaustive()
144    }
145}
146
147impl Clone for Template {
148    fn clone(&self) -> Self {
149        Self {
150            body: self.body.clone(),
151            name: self.name.clone(),
152            description: self.description.clone(),
153            segments: self.segments.clone(),
154            declared_variables: self.declared_variables.clone(),
155            #[cfg(feature = "std")]
156            base_dir: self.base_dir.clone(),
157            inline_templates: self.inline_templates.clone(),
158            source_hash: self.source_hash,
159            max_include_depth: self.max_include_depth,
160            has_defaults: self.has_defaults,
161            consts: self.consts.clone(),
162            imported_consts: self.imported_consts.clone(),
163            estimated_capacity: self.estimated_capacity,
164            // Clone inherits cached type IDs — the validation is
165            // shape-based, not instance-based.
166            #[cfg(feature = "std")]
167            checked_type_ids: std::sync::Mutex::new(
168                self.checked_type_ids
169                    .lock()
170                    .unwrap_or_else(std::sync::PoisonError::into_inner)
171                    .clone(),
172            ),
173        }
174    }
175}
176
177/// Pre-compiled template data used to reconstruct a [`Template`] from cache.
178///
179/// See [`Template::from_cached`].
180#[cfg(feature = "std")]
181pub(crate) struct CachedTemplateData {
182    /// Pre-compiled segment instructions.
183    pub segments: Arc<[Segment]>,
184    /// Declared variables from frontmatter.
185    pub declared_variables: Arc<[VarDecl]>,
186    /// Base directory for resolving includes.
187    pub base_dir: Option<PathBuf>,
188    /// Pre-compiled inline template definitions.
189    pub inline_templates: Arc<HashMap<String, CompiledInlineTemplate>>,
190    /// Content hash of the raw source.
191    pub source_hash: u64,
192    /// Constants defined in this template.
193    pub consts: Arc<HashMap<String, crate::value::Value>>,
194    /// Imported constants.
195    pub imported_consts: Arc<HashMap<String, crate::value::Value>>,
196    /// Template name.
197    pub name: Option<String>,
198    /// Template description.
199    pub description: Option<String>,
200}
201
202/// Pre-compiled template data for compile-time macro integration.
203///
204/// See [`Template::from_precompiled`].
205#[doc(hidden)]
206pub struct PrecompiledTemplateData<'a> {
207    /// Pre-compiled segment instructions.
208    pub segments: &'a [Segment],
209    /// Declared variables from frontmatter.
210    pub declared_variables: &'a [VarDecl],
211    /// Pre-compiled inline template definitions.
212    pub inline_templates: &'a [(&'a str, CompiledInlineTemplate)],
213    /// Content hash of the raw source.
214    pub source_hash: u64,
215    /// Constants defined in this template.
216    pub consts: &'a [(&'a str, crate::value::Value)],
217    /// Imported constants.
218    pub imported_consts: &'a [(&'a str, crate::value::Value)],
219    /// Template name.
220    pub name: Option<&'a str>,
221    /// Template description.
222    pub description: Option<&'a str>,
223}
224
225impl Template {
226    /// Load a template from a file, stripping YAML frontmatter.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`TemplateError::Io`] if the file cannot be read.
231    #[cfg(feature = "std")]
232    pub fn from_file(path: &Path) -> Result<Self, TemplateError> {
233        let source = std::fs::read_to_string(path)?;
234        let (tmpl, _fm) =
235            Self::compile_from_source(&source, Some(path.parent().unwrap_or(Path::new("."))))?;
236        Ok(tmpl)
237    }
238
239    /// Parse a template from an in-memory string (no include resolution).
240    ///
241    /// # Errors
242    ///
243    /// Returns [`TemplateError::Syntax`] if the body contains a syntax error.
244    pub fn from_source(source: &str) -> Result<Self, TemplateError> {
245        #[cfg(feature = "std")]
246        let (tmpl, _fm) = Self::compile_from_source(source, None)?;
247        #[cfg(not(feature = "std"))]
248        let (tmpl, _fm) = Self::compile_from_source_no_std(source)?;
249        Ok(tmpl)
250    }
251
252    /// Parse a template from source with compile options, returning both the
253    /// template and its frontmatter.
254    ///
255    /// This is the unified entry point that replaces the family of
256    /// `from_source_*` constructors.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use md_tmpl_core::{CompileOptions, Template};
262    ///
263    /// let (tmpl, fm) = Template::compile(
264    ///     r#"---
265    /// params: [name = str]
266    /// ---
267    /// Hello {{ name }}!"#,
268    ///     CompileOptions::default(),
269    /// )
270    /// .unwrap();
271    /// ```
272    ///
273    /// # Errors
274    ///
275    /// Returns [`TemplateError::Syntax`] if the body contains a syntax error.
276    pub fn compile(
277        source: &str,
278        options: CompileOptions<'_>,
279    ) -> Result<(Self, Frontmatter), TemplateError> {
280        #[cfg(feature = "std")]
281        return Self::compile_inner(source, options.base_dir, options.allow_unused, options.env);
282        #[cfg(not(feature = "std"))]
283        return Self::compile_inner_no_std(source, options.allow_unused, options.env);
284    }
285
286    /// Load a template from a file with compile options, returning both the
287    /// template and its frontmatter.
288    ///
289    /// The file's parent directory is used as the base directory for include
290    /// resolution unless overridden in `options`.
291    ///
292    /// # Examples
293    ///
294    /// ```no_run
295    /// use std::path::Path;
296    ///
297    /// use md_tmpl_core::{CompileOptions, Template};
298    ///
299    /// let (tmpl, fm) =
300    ///     Template::compile_file(Path::new("template.tmpl.md"), CompileOptions::default()).unwrap();
301    /// ```
302    ///
303    /// # Errors
304    ///
305    /// Returns [`TemplateError::Io`] if the file cannot be read.
306    #[cfg(feature = "std")]
307    pub fn compile_file(
308        path: &Path,
309        options: CompileOptions<'_>,
310    ) -> Result<(Self, Frontmatter), TemplateError> {
311        let source = std::fs::read_to_string(path)?;
312        let base_dir = options.base_dir.or_else(|| path.parent());
313        Self::compile_inner(&source, base_dir, options.allow_unused, options.env)
314    }
315
316    /// Shared compilation entry point — honours `allow_unused` from frontmatter.
317    #[cfg(feature = "std")]
318    fn compile_from_source(
319        source: &str,
320        base_dir: Option<&Path>,
321    ) -> Result<(Self, Frontmatter), TemplateError> {
322        Self::compile_inner(source, base_dir, false, &[])
323    }
324
325    /// Core compilation: parse frontmatter → compile body → static analysis → build `Template`.
326    ///
327    /// When `force_allow_unused` is `true` the unused-params check is skipped
328    /// regardless of the frontmatter setting.
329    #[cfg(feature = "std")]
330    fn compile_inner(
331        source: &str,
332        base_dir: Option<&Path>,
333        force_allow_unused: bool,
334        env_values: &[(&str, Value)],
335    ) -> Result<(Self, Frontmatter), TemplateError> {
336        let source_hash = crate::cache::hash_source(source);
337        let (fm, body) = if let Some(dir) = base_dir {
338            frontmatter::parse_frontmatter_with_base_dir(source, dir, env_values)?
339        } else {
340            frontmatter::parse_frontmatter_with_env(source, env_values)?
341        };
342        let body = body.to_string();
343        let (segments, inline_templates) = compiled::compile(&body, &fm.type_aliases)?;
344
345        // --- Static analysis ---
346        let referenced = compiled::collect_referenced_params(&segments);
347        check_undeclared_variables(&referenced, &fm, &inline_templates)?;
348        check_unused_params(
349            &fm.declarations,
350            &referenced,
351            force_allow_unused || fm.allow_unused,
352        )?;
353        check_name_collisions(&fm, &inline_templates, &segments)?;
354        let enum_keys = collect_enum_type_keys(&fm);
355        check_bare_enum_access(&segments, &enum_keys)?;
356        check_static_enum_in_conditions(&segments, &fm.type_aliases)?;
357        check_internal_key_access(&segments)?;
358
359        let has_defaults = fm.declarations.iter().any(|d| d.default_value.is_some());
360        let mut consts: HashMap<String, Value> = fm
361            .consts
362            .iter()
363            .filter_map(|d| d.default_value.clone().map(|v| (d.name.clone(), v)))
364            .collect();
365        // Inject resolved env values as constants.
366        for d in &fm.env {
367            if let Some(ref v) = d.default_value {
368                consts.entry(d.name.clone()).or_insert_with(|| v.clone());
369            }
370        }
371        // Inject enum type aliases as namespace constants (e.g. Stage.Design).
372        inject_enum_type_constants(&fm.type_aliases, &mut consts);
373        let segments: Arc<[Segment]> = Arc::from(segments);
374        let estimated_capacity = compiled::render::estimate_output_capacity(&segments);
375        let tmpl = Self {
376            body,
377            name: fm.name.clone(),
378            description: fm.description.clone(),
379            segments,
380            declared_variables: Arc::from(fm.declarations.clone()),
381            base_dir: base_dir.map(Path::to_path_buf),
382            inline_templates: Arc::new(inline_templates),
383            source_hash,
384            max_include_depth: crate::scope::MAX_INCLUDE_DEPTH,
385            has_defaults,
386            consts: Arc::new(consts),
387            imported_consts: Arc::new(fm.imported_consts.clone()),
388            estimated_capacity,
389            checked_type_ids: std::sync::Mutex::new(Vec::new()),
390        };
391        Ok((tmpl, fm))
392    }
393
394    /// `no_std` compilation entry point (no base directory, no imports).
395    #[cfg(not(feature = "std"))]
396    fn compile_from_source_no_std(source: &str) -> Result<(Self, Frontmatter), TemplateError> {
397        Self::compile_inner_no_std(source, false, &[])
398    }
399
400    /// `no_std` core compilation.
401    #[cfg(not(feature = "std"))]
402    fn compile_inner_no_std(
403        source: &str,
404        force_allow_unused: bool,
405        env_values: &[(&str, Value)],
406    ) -> Result<(Self, Frontmatter), TemplateError> {
407        let source_hash = hash_source_no_std(source);
408        let (fm, body) = frontmatter::parse_frontmatter_with_env(source, env_values)?;
409        let body = body.to_string();
410        let (segments, inline_templates) = compiled::compile(&body, &fm.type_aliases)?;
411
412        let referenced = compiled::collect_referenced_params(&segments);
413        check_undeclared_variables(&referenced, &fm, &inline_templates)?;
414        check_unused_params(
415            &fm.declarations,
416            &referenced,
417            force_allow_unused || fm.allow_unused,
418        )?;
419        check_name_collisions(&fm, &inline_templates, &segments)?;
420        let enum_keys = collect_enum_type_keys(&fm);
421        check_bare_enum_access(&segments, &enum_keys)?;
422        check_static_enum_in_conditions(&segments, &fm.type_aliases)?;
423        check_internal_key_access(&segments)?;
424
425        let has_defaults = fm.declarations.iter().any(|d| d.default_value.is_some());
426        let mut consts: HashMap<String, Value> = fm
427            .consts
428            .iter()
429            .filter_map(|d| d.default_value.clone().map(|v| (d.name.clone(), v)))
430            .collect();
431        // Inject resolved env values as constants.
432        for d in &fm.env {
433            if let Some(ref v) = d.default_value {
434                consts.entry(d.name.clone()).or_insert_with(|| v.clone());
435            }
436        }
437        // Inject enum type aliases as namespace constants (e.g. Stage.Design).
438        inject_enum_type_constants(&fm.type_aliases, &mut consts);
439        let segments: Arc<[Segment]> = Arc::from(segments);
440        let estimated_capacity = compiled::render::estimate_output_capacity(&segments);
441        let tmpl = Self {
442            body,
443            name: fm.name.clone(),
444            description: fm.description.clone(),
445            segments,
446            declared_variables: Arc::from(fm.declarations.clone()),
447            inline_templates: Arc::new(inline_templates),
448            source_hash,
449            max_include_depth: crate::scope::MAX_INCLUDE_DEPTH,
450            has_defaults,
451            consts: Arc::new(consts),
452            imported_consts: Arc::new(fm.imported_consts.clone()),
453            estimated_capacity,
454        };
455        Ok((tmpl, fm))
456    }
457
458    /// Construct a `Template` from pre-compiled segments (used by [`TemplateCache`]).
459    ///
460    /// Skips parsing and compilation entirely — the caller is responsible for
461    /// providing correct, pre-compiled data.
462    ///
463    /// [`TemplateCache`]: crate::TemplateCache
464    #[cfg(feature = "std")]
465    pub(crate) fn from_cached(data: CachedTemplateData) -> Self {
466        let has_defaults = data
467            .declared_variables
468            .iter()
469            .any(|d| d.default_value.is_some());
470        let estimated_capacity = compiled::render::estimate_output_capacity(&data.segments);
471        Self {
472            body: String::new(),
473            name: data.name,
474            description: data.description,
475            segments: data.segments,
476            declared_variables: data.declared_variables,
477            base_dir: data.base_dir,
478            inline_templates: data.inline_templates,
479            source_hash: data.source_hash,
480            max_include_depth: crate::scope::MAX_INCLUDE_DEPTH,
481            has_defaults,
482            consts: data.consts,
483            imported_consts: data.imported_consts,
484            estimated_capacity,
485            checked_type_ids: std::sync::Mutex::new(Vec::new()),
486        }
487    }
488
489    /// Construct a `Template` from pre-compiled static structures (used by compile-time macros).
490    #[doc(hidden)]
491    #[must_use]
492    pub fn from_precompiled(data: &PrecompiledTemplateData<'_>) -> Self {
493        let inline_map = data
494            .inline_templates
495            .iter()
496            .map(|(k, v)| (k.to_string(), v.clone()))
497            .collect();
498        let const_map = data
499            .consts
500            .iter()
501            .map(|(k, v)| (k.to_string(), v.clone()))
502            .collect();
503        let imported_const_map = data
504            .imported_consts
505            .iter()
506            .map(|(k, v)| (k.to_string(), v.clone()))
507            .collect();
508        let has_defaults = data
509            .declared_variables
510            .iter()
511            .any(|d| d.default_value.is_some());
512        let segments: Arc<[Segment]> = Arc::from(data.segments);
513        let estimated_capacity = compiled::render::estimate_output_capacity(&segments);
514        Self {
515            body: String::new(),
516            name: data.name.map(String::from),
517            description: data.description.map(String::from),
518            segments,
519            declared_variables: Arc::from(data.declared_variables),
520            #[cfg(feature = "std")]
521            base_dir: None,
522            inline_templates: Arc::new(inline_map),
523            source_hash: data.source_hash,
524            max_include_depth: crate::scope::MAX_INCLUDE_DEPTH,
525            has_defaults,
526            consts: Arc::new(const_map),
527            imported_consts: Arc::new(imported_const_map),
528            estimated_capacity,
529            #[cfg(feature = "std")]
530            checked_type_ids: std::sync::Mutex::new(Vec::new()),
531        }
532    }
533
534    /// Validate context: check presence, types, AND no extra variables.
535    ///
536    /// By default the engine is strict: passing undeclared parameters is
537    /// an error. Set `allow_extra` to `true` to suppress the extra-params
538    /// check (useful when forwarding a shared context to multiple templates).
539    ///
540    /// # Errors
541    ///
542    /// Returns [`TemplateError::MissingParams`] if any declared variable
543    /// is absent, [`TemplateError::TypeMismatch`] if a value has the
544    /// wrong type, or [`TemplateError::ExtraParams`] if undeclared keys
545    /// are present (and `allow_extra` is false).
546    fn validate_context(&self, ctx: &Context, allow_extra: bool) -> Result<(), TemplateError> {
547        let mut missing = Vec::new();
548        let mut mismatch: Option<(String, crate::types::TypeCheckError)> = None;
549        for decl in self.declared_variables.iter() {
550            match ctx.get(&decl.name) {
551                None => {
552                    // Skip params with defaults — they'll be injected.
553                    if decl.default_value.is_none() {
554                        missing.push(decl.name.as_str());
555                    }
556                }
557                Some(value) => {
558                    if mismatch.is_none()
559                        && let Err(e) = decl.var_type.check(value)
560                    {
561                        mismatch = Some((decl.name.clone(), e));
562                    }
563                }
564            }
565        }
566        // Report missing params first (most fundamental).
567        if !missing.is_empty() {
568            return Err(TemplateError::MissingParams(
569                missing.into_iter().map(String::from).collect(),
570            ));
571        }
572        if let Some((name, check_err)) = mismatch {
573            let detail = if check_err.path.is_empty() {
574                String::new()
575            } else {
576                format!(" (at .{})", check_err.path)
577            };
578            return Err(TemplateError::TypeMismatch {
579                name: format!("{name}{detail}"),
580                expected: check_err.expected,
581                actual: check_err.actual,
582                actual_value: check_err.actual_value,
583            });
584        }
585        // Reject extra (undeclared) parameters unless explicitly allowed.
586        if !allow_extra {
587            let mut declared: HashSet<&str> = self
588                .declared_variables
589                .iter()
590                .map(|d| d.name.as_str())
591                .collect();
592            for name in self.consts.keys() {
593                declared.insert(name.as_str());
594            }
595            let extra: Vec<String> = ctx
596                .values
597                .keys()
598                .filter(|k| !declared.contains(k.as_str()))
599                .cloned()
600                .collect();
601            if !extra.is_empty() {
602                return Err(TemplateError::ExtraParams(extra));
603            }
604        }
605        Ok(())
606    }
607
608    /// Returns default values for all params that have them.
609    #[must_use]
610    pub fn defaults(&self) -> HashMap<String, crate::value::Value> {
611        self.declared_variables
612            .iter()
613            .filter_map(|d| {
614                d.default_value
615                    .as_ref()
616                    .map(|v| (d.name.clone(), v.clone()))
617            })
618            .collect()
619    }
620
621    /// Returns the default value for a single parameter, if it has one.
622    #[must_use]
623    pub fn default(&self, name: &str) -> Option<&crate::value::Value> {
624        self.declared_variables
625            .iter()
626            .find(|d| d.name == name)
627            .and_then(|d| d.default_value.as_ref())
628    }
629
630    /// Returns a [`Context`] pre-filled with all default values.
631    ///
632    /// Use this as a starting point, then override only the params you need:
633    /// ```
634    /// # use md_tmpl_core::{Template, Context};
635    /// let tmpl = Template::from_source(
636    ///     r#"---
637    /// params:
638    ///   - name = str
639    ///   - count = int := 5
640    /// ---
641    /// {{ name }} ({{ count }})"#,
642    /// )
643    /// .unwrap();
644    /// let mut ctx = tmpl.defaults_context();
645    /// ctx.set("name", "Alice"); // count already has default 5
646    /// assert_eq!(tmpl.render_ctx(&ctx).unwrap(), "Alice (5)");
647    /// ```
648    #[must_use]
649    pub fn defaults_context(&self) -> Context {
650        let defaults = self.defaults();
651        let mut ctx = Context::with_capacity(defaults.len());
652        for (k, v) in defaults {
653            ctx.set(k, v);
654        }
655        ctx
656    }
657
658    /// Return the raw template body text (after frontmatter stripping).
659    ///
660    /// Useful for compile-time validation and macro integration.
661    #[must_use]
662    pub fn body(&self) -> &str {
663        &self.body
664    }
665
666    /// Returns the template's name, if defined in frontmatter.
667    #[must_use]
668    pub fn name(&self) -> Option<&str> {
669        self.name.as_deref()
670    }
671
672    /// Returns the template's description, if defined in frontmatter.
673    #[must_use]
674    pub fn description(&self) -> Option<&str> {
675        self.description.as_deref()
676    }
677
678    /// Set the maximum include depth for rendering this template.
679    pub fn set_max_include_depth(&mut self, depth: usize) {
680        self.max_include_depth = depth;
681    }
682
683    /// Set the maximum include depth for rendering this template (builder style).
684    #[must_use]
685    pub fn with_max_include_depth(mut self, depth: usize) -> Self {
686        self.max_include_depth = depth;
687        self
688    }
689
690    /// Return the declared variables from frontmatter.
691    ///
692    /// Used by generated param structs to validate that a reloaded template
693    /// still matches the compile-time variable declarations.
694    #[must_use]
695    pub fn declarations(&self) -> &[VarDecl] {
696        &self.declared_variables
697    }
698
699    pub(crate) fn segments(&self) -> &[crate::compiled::Segment] {
700        &self.segments
701    }
702
703    /// Returns the base directory used for resolving filesystem `{% include %}` paths.
704    #[cfg(feature = "std")]
705    #[must_use]
706    pub fn base_dir(&self) -> Option<&Path> {
707        self.base_dir.as_deref()
708    }
709
710    /// Returns the constants defined in this template's frontmatter.
711    ///
712    /// Constants are defined with `consts:` in frontmatter and are automatically
713    /// available during rendering without being passed in the context.
714    ///
715    /// # Examples
716    ///
717    /// ```
718    /// use md_tmpl_core::Template;
719    ///
720    /// let tmpl = Template::from_source(
721    ///     r#"---
722    /// consts:
723    ///   - MAX = int := 100
724    ///
725    /// params: []
726    /// ---
727    /// {{ MAX }}"#,
728    /// )
729    /// .unwrap();
730    /// let consts = tmpl.consts();
731    /// assert_eq!(consts.get("MAX").unwrap().as_int(), Some(100));
732    /// ```
733    #[must_use]
734    pub fn consts(&self) -> Arc<HashMap<String, Value>> {
735        self.consts.clone()
736    }
737
738    /// Returns a borrowed reference to the constants defined in this
739    /// template's frontmatter, avoiding the [`Arc`] clone of [`consts`](Self::consts).
740    #[must_use]
741    pub fn consts_ref(&self) -> &HashMap<String, Value> {
742        &self.consts
743    }
744
745    /// Returns the imported constants (from `{% import %}` directives).
746    ///
747    /// These are constants imported from other template files and are
748    /// automatically available during rendering alongside regular constants.
749    #[must_use]
750    pub fn imported_consts(&self) -> Arc<HashMap<String, Value>> {
751        self.imported_consts.clone()
752    }
753
754    /// Returns a borrowed reference to the imported constants, avoiding
755    /// the [`Arc`] clone of [`imported_consts`](Self::imported_consts).
756    #[must_use]
757    pub fn imported_consts_ref(&self) -> &HashMap<String, Value> {
758        &self.imported_consts
759    }
760
761    pub(crate) fn inline_templates(&self) -> &HashMap<String, CompiledInlineTemplate> {
762        &self.inline_templates
763    }
764
765    /// Content hash of the raw source — use to detect unchanged files on
766    /// hot-reload without re-parsing.
767    ///
768    /// Same source → same hash.  Different source → (very likely) different
769    /// hash.  This is a fast non-cryptographic hash, not suitable for
770    /// security purposes.
771    #[must_use]
772    pub fn source_hash(&self) -> u64 {
773        self.source_hash
774    }
775
776    /// Validate that a (possibly reloaded) template's variable declarations
777    /// match an expected set.
778    ///
779    /// Call this after re-loading a template from disk to ensure that
780    /// nobody (e.g. an autonomous agent editing markdown files at runtime)
781    /// has modified the `params:` block in the frontmatter.
782    ///
783    /// The template body may be changed freely — only the variable
784    /// declarations must remain stable.
785    ///
786    /// # Errors
787    ///
788    /// Returns [`TemplateError::DeclarationsMutated`] with a human-readable
789    /// diff if the declarations don't match.
790    pub fn validate_declarations(&self, expected: &[VarDecl]) -> Result<(), TemplateError> {
791        let current: HashMap<&str, &crate::types::VarType> = self
792            .declared_variables
793            .iter()
794            .map(|d| (d.name.as_str(), &d.var_type))
795            .collect();
796        let expected_map: HashMap<&str, &crate::types::VarType> = expected
797            .iter()
798            .map(|d| (d.name.as_str(), &d.var_type))
799            .collect();
800
801        let current_names: HashSet<&str> = current.keys().copied().collect();
802        let expected_names: HashSet<&str> = expected_map.keys().copied().collect();
803
804        let missing: Vec<&str> = expected_names.difference(&current_names).copied().collect();
805        let extra: Vec<&str> = current_names.difference(&expected_names).copied().collect();
806
807        // Check for type changes on variables that exist in both.
808        let retyped: Vec<String> = current_names
809            .intersection(&expected_names)
810            .filter_map(|name| {
811                let cur_type = current[name];
812                let exp_type = expected_map[name];
813                if cur_type == exp_type {
814                    None
815                } else {
816                    Some(format!("{name}: {exp_type} → {cur_type}"))
817                }
818            })
819            .collect();
820
821        if missing.is_empty() && extra.is_empty() && retyped.is_empty() {
822            return Ok(());
823        }
824
825        let mut parts = Vec::new();
826        if !missing.is_empty() {
827            parts.push(format!("removed: {}", missing.join(", ")));
828        }
829        if !extra.is_empty() {
830            parts.push(format!("added: {}", extra.join(", ")));
831        }
832        if !retyped.is_empty() {
833            parts.push(format!("retyped: {}", retyped.join(", ")));
834        }
835
836        Err(TemplateError::DeclarationsMutated {
837            details: parts.join("; "),
838        })
839    }
840}
841
842// ---------------------------------------------------------------------------
843// Trait impls for embedding Template in macro-generated structs
844// ---------------------------------------------------------------------------
845
846/// Two templates are considered equal if they were compiled from the same
847/// source (compared via non-cryptographic 64-bit hash).
848///
849/// **Note:** This is an approximate comparison — different sources that
850/// produce the same hash would incorrectly compare as equal.  Do not use
851/// `Template` as a `HashMap` key or rely on `Eq` for deduplication in
852/// security-sensitive contexts.  For exact source comparison, compare
853/// [`body()`](Self::body) and [`declarations()`](Self::declarations).
854impl PartialEq for Template {
855    fn eq(&self, other: &Self) -> bool {
856        self.source_hash == other.source_hash
857    }
858}
859
860impl Eq for Template {}
861
862/// Serialize a [`Template`] as a source-hash identifier.
863///
864/// Templates embedded in macro-generated parameter structs need `Serialize`
865/// to satisfy derive bounds, even when the struct is never actually
866/// serialized.  The hash lets debug/logging code produce something readable.
867#[cfg(feature = "serde")]
868impl serde::Serialize for Template {
869    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
870        serializer.serialize_str(&format!("template:{:016x}", self.source_hash))
871    }
872}
873
874/// Deserialize always fails — [`Template`] must be constructed from source.
875///
876/// This impl exists solely to satisfy derive bounds on macro-generated
877/// parameter structs.  Actual deserialization of a compiled template is not
878/// meaningful.
879#[cfg(feature = "serde")]
880impl<'de> serde::Deserialize<'de> for Template {
881    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
882        let _ = <serde::de::IgnoredAny as serde::Deserialize>::deserialize(deserializer)?;
883        Err(serde::de::Error::custom(
884            "Template cannot be deserialized; construct from source with \
885             Template::from_source() or Template::from_file()",
886        ))
887    }
888}
889
890/// Load a named template from a directory.
891///
892/// Looks for `<name>.tmpl.md` in `dir`.
893///
894/// # Errors
895///
896/// Returns [`TemplateError::Io`] if the file is not found or cannot be read.
897#[cfg(feature = "std")]
898pub fn load_template(dir: &Path, name: &str) -> Result<Template, TemplateError> {
899    let path = dir.join(format!("{name}.tmpl.md"));
900    Template::from_file(&path)
901}
902
903#[cfg(all(test, feature = "std"))]
904mod adversarial_tests;
905#[cfg(all(test, feature = "std"))]
906mod collision_and_scope_tests;
907#[cfg(all(test, feature = "std"))]
908mod const_tests;
909#[cfg(all(test, feature = "std"))]
910mod error_diagnostic_tests;
911#[cfg(all(test, feature = "std"))]
912mod higher_order_tests;
913#[cfg(all(test, feature = "std"))]
914mod inline_edge_tests;
915#[cfg(all(test, feature = "std"))]
916mod render_integration_tests;
917#[cfg(all(test, feature = "std"))]
918mod shared_tests;
919#[cfg(all(test, feature = "std"))]
920mod tests;