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