Skip to main content

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