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