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