Skip to main content

md_tmpl/template/
mod.rs

1use alloc::{
2    string::{String, ToString},
3    sync::Arc,
4    vec::Vec,
5};
6#[cfg(feature = "std")]
7use std::path::{Path, PathBuf};
8
9use crate::{
10    compat::{HashMap, HashSet},
11    compiled::{self, CompiledInlineTemplate, Segment},
12    context::Context,
13    error::TemplateError,
14    frontmatter::{self, Frontmatter},
15    scope::Scope,
16    types::{VarDecl, VarType},
17    value::Value,
18};
19
20/// Configuration for template compilation.
21///
22/// Collects all optional parameters that previously required separate
23/// constructor functions. Use with [`Template::compile`] or
24/// [`Template::compile_file`].
25///
26/// # Examples
27///
28/// ```
29/// use md_tmpl::CompileOptions;
30///
31/// // Default options (strict mode):
32/// let opts = CompileOptions::default();
33///
34/// // Allow unused declared parameters:
35/// let opts = CompileOptions::default().allow_unused(true);
36/// ```
37#[non_exhaustive]
38#[derive(Debug, Clone, Copy, Default)]
39pub struct CompileOptions<'a> {
40    /// When `true`, declared parameters that are never referenced in the
41    /// template body are allowed instead of producing an error.
42    ///
43    /// Equivalent to `allow_unused: true` in frontmatter.
44    pub allow_unused: bool,
45    /// Base directory for resolving `{% include %}` and `{% import %}` directives.
46    ///
47    /// When `None`, includes are not resolved (suitable for in-memory templates).
48    #[cfg(feature = "std")]
49    pub base_dir: Option<&'a std::path::Path>,
50    // Lifetime anchor for no_std where base_dir doesn't exist.
51    #[cfg(not(feature = "std"))]
52    _phantom: core::marker::PhantomData<&'a ()>,
53}
54
55#[cfg(feature = "std")]
56impl<'a> CompileOptions<'a> {
57    /// Set the base directory for include resolution.
58    #[must_use]
59    pub fn base_dir(mut self, dir: &'a std::path::Path) -> Self {
60        self.base_dir = Some(dir);
61        self
62    }
63}
64
65impl CompileOptions<'_> {
66    /// Allow unused declared parameters.
67    #[must_use]
68    pub fn allow_unused(mut self, allow: bool) -> Self {
69        self.allow_unused = allow;
70        self
71    }
72}
73
74/// A parsed template ready for rendering.
75///
76/// Templates can be loaded from files or parsed from in-memory strings.
77/// Variable declarations from frontmatter are used for context validation
78/// before rendering.
79#[derive(Debug, Clone)]
80pub struct Template {
81    /// The template body text (after stripping frontmatter).
82    body: String,
83    /// Template name (from frontmatter).
84    name: Option<String>,
85    /// Template description (from frontmatter).
86    description: Option<String>,
87    /// Pre-compiled segment instructions (the fast render path).
88    segments: Arc<[Segment]>,
89    /// Declared variables from frontmatter.
90    declared_variables: Arc<[VarDecl]>,
91    /// Base directory for resolving includes (from file path).
92    #[cfg(feature = "std")]
93    base_dir: Option<PathBuf>,
94    /// Pre-compiled inline template definitions (`{% tmpl name %}...{% /tmpl %}`).
95    inline_templates: Arc<HashMap<String, CompiledInlineTemplate>>,
96    source_hash: u64,
97    max_include_depth: usize,
98    /// Pre-computed: true if any declared variable has a default value.
99    has_defaults: bool,
100    /// Constants defined in this template.
101    consts: Arc<HashMap<String, crate::value::Value>>,
102    /// Imported constants keyed by `stem.NAME`.
103    imported_consts: Arc<HashMap<String, crate::value::Value>>,
104    /// Pre-computed estimated output capacity (cached from segment tree walk).
105    estimated_capacity: usize,
106}
107
108/// Pre-compiled template data used to reconstruct a [`Template`] from cache.
109///
110/// See [`Template::from_cached`].
111#[cfg(feature = "std")]
112pub(crate) struct CachedTemplateData {
113    /// Pre-compiled segment instructions.
114    pub segments: Arc<[Segment]>,
115    /// Declared variables from frontmatter.
116    pub declared_variables: Arc<[VarDecl]>,
117    /// Base directory for resolving includes.
118    pub base_dir: Option<PathBuf>,
119    /// Pre-compiled inline template definitions.
120    pub inline_templates: Arc<HashMap<String, CompiledInlineTemplate>>,
121    /// Content hash of the raw source.
122    pub source_hash: u64,
123    /// Constants defined in this template.
124    pub consts: Arc<HashMap<String, crate::value::Value>>,
125    /// Imported constants.
126    pub imported_consts: Arc<HashMap<String, crate::value::Value>>,
127    /// Template name.
128    pub name: Option<String>,
129    /// Template description.
130    pub description: Option<String>,
131}
132
133/// Pre-compiled template data for compile-time macro integration.
134///
135/// See [`Template::from_precompiled`].
136#[doc(hidden)]
137pub struct PrecompiledTemplateData<'a> {
138    /// Pre-compiled segment instructions.
139    pub segments: &'a [Segment],
140    /// Declared variables from frontmatter.
141    pub declared_variables: &'a [VarDecl],
142    /// Pre-compiled inline template definitions.
143    pub inline_templates: &'a [(&'a str, CompiledInlineTemplate)],
144    /// Content hash of the raw source.
145    pub source_hash: u64,
146    /// Constants defined in this template.
147    pub consts: &'a [(&'a str, crate::value::Value)],
148    /// Imported constants.
149    pub imported_consts: &'a [(&'a str, crate::value::Value)],
150    /// Template name.
151    pub name: Option<&'a str>,
152    /// Template description.
153    pub description: Option<&'a str>,
154}
155
156impl Template {
157    /// Load a template from a file, stripping YAML frontmatter.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`TemplateError::Io`] if the file cannot be read.
162    #[cfg(feature = "std")]
163    pub fn from_file(path: &Path) -> Result<Self, TemplateError> {
164        let source = std::fs::read_to_string(path)?;
165        let (tmpl, _fm) =
166            Self::compile_from_source(&source, Some(path.parent().unwrap_or(Path::new("."))))?;
167        Ok(tmpl)
168    }
169
170    /// Parse a template from an in-memory string (no include resolution).
171    ///
172    /// # Errors
173    ///
174    /// Returns [`TemplateError::Syntax`] if the body contains a syntax error.
175    pub fn from_source(source: &str) -> Result<Self, TemplateError> {
176        #[cfg(feature = "std")]
177        let (tmpl, _fm) = Self::compile_from_source(source, None)?;
178        #[cfg(not(feature = "std"))]
179        let (tmpl, _fm) = Self::compile_from_source_no_std(source)?;
180        Ok(tmpl)
181    }
182
183    /// Parse a template from source, allowing declared parameters that are
184    /// not referenced in the template body.
185    ///
186    /// Equivalent to setting `allow_unused: true` in the frontmatter.
187    /// Useful for dynamically-loaded templates where parameters may be
188    /// conditionally used or forwarded to includes.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`TemplateError::Syntax`] if the body contains a syntax error.
193    #[deprecated(
194        since = "0.2.0",
195        note = "Use `Template::compile(source, CompileOptions::default().allow_unused(true))` instead"
196    )]
197    pub fn from_source_allowing_unused(source: &str) -> Result<Self, TemplateError> {
198        let (tmpl, _fm) = Self::compile(source, CompileOptions::default().allow_unused(true))?;
199        Ok(tmpl)
200    }
201
202    /// Parse from source with a base directory for includes.
203    ///
204    /// # Errors
205    ///
206    /// Returns [`TemplateError::Syntax`] if the body contains a syntax error.
207    #[cfg(feature = "std")]
208    #[deprecated(
209        since = "0.2.0",
210        note = "Use `Template::compile(source, CompileOptions::default().base_dir(dir))` instead"
211    )]
212    pub fn from_source_with_base_dir(source: &str, base_dir: &Path) -> Result<Self, TemplateError> {
213        let (tmpl, _fm) = Self::compile(source, CompileOptions::default().base_dir(base_dir))?;
214        Ok(tmpl)
215    }
216
217    /// Parse and return frontmatter too.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`TemplateError::Syntax`] if the body contains a syntax error.
222    #[deprecated(
223        since = "0.2.0",
224        note = "Use `Template::compile(source, CompileOptions::default())` which always returns Frontmatter"
225    )]
226    pub fn from_source_with_frontmatter(
227        source: &str,
228    ) -> Result<(Self, Frontmatter), TemplateError> {
229        Self::compile(source, CompileOptions::default())
230    }
231
232    /// Load and return frontmatter too.
233    ///
234    /// # Errors
235    ///
236    /// Returns [`TemplateError::Io`] if the file cannot be read.
237    #[cfg(feature = "std")]
238    #[deprecated(
239        since = "0.2.0",
240        note = "Use `Template::compile_file(path, CompileOptions::default())` which always returns Frontmatter"
241    )]
242    pub fn from_file_with_frontmatter(path: &Path) -> Result<(Self, Frontmatter), TemplateError> {
243        Self::compile_file(path, CompileOptions::default())
244    }
245
246    /// Parse a template from source with compile options, returning both the
247    /// template and its frontmatter.
248    ///
249    /// This is the unified entry point that replaces the family of
250    /// `from_source_*` constructors.
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// use md_tmpl::{CompileOptions, Template};
256    ///
257    /// let (tmpl, fm) = Template::compile(
258    ///     r#"---
259    /// params: [name = str]
260    /// ---
261    /// Hello {{ name }}!"#,
262    ///     CompileOptions::default(),
263    /// )
264    /// .unwrap();
265    /// ```
266    ///
267    /// # Errors
268    ///
269    /// Returns [`TemplateError::Syntax`] if the body contains a syntax error.
270    pub fn compile(
271        source: &str,
272        options: CompileOptions<'_>,
273    ) -> Result<(Self, Frontmatter), TemplateError> {
274        #[cfg(feature = "std")]
275        return Self::compile_inner(source, options.base_dir, options.allow_unused);
276        #[cfg(not(feature = "std"))]
277        return Self::compile_inner_no_std(source, options.allow_unused);
278    }
279
280    /// Load a template from a file with compile options, returning both the
281    /// template and its frontmatter.
282    ///
283    /// The file's parent directory is used as the base directory for include
284    /// resolution unless overridden in `options`.
285    ///
286    /// # Examples
287    ///
288    /// ```no_run
289    /// use std::path::Path;
290    ///
291    /// use md_tmpl::{CompileOptions, Template};
292    ///
293    /// let (tmpl, fm) =
294    ///     Template::compile_file(Path::new("template.tmpl.md"), CompileOptions::default()).unwrap();
295    /// ```
296    ///
297    /// # Errors
298    ///
299    /// Returns [`TemplateError::Io`] if the file cannot be read.
300    #[cfg(feature = "std")]
301    pub fn compile_file(
302        path: &Path,
303        options: CompileOptions<'_>,
304    ) -> Result<(Self, Frontmatter), TemplateError> {
305        let source = std::fs::read_to_string(path)?;
306        let base_dir = options.base_dir.or_else(|| path.parent());
307        Self::compile_inner(&source, base_dir, options.allow_unused)
308    }
309
310    /// Shared compilation entry point — honours `allow_unused` from frontmatter.
311    #[cfg(feature = "std")]
312    fn compile_from_source(
313        source: &str,
314        base_dir: Option<&Path>,
315    ) -> Result<(Self, Frontmatter), TemplateError> {
316        Self::compile_inner(source, base_dir, false)
317    }
318
319    /// Core compilation: parse frontmatter → compile body → static analysis → build `Template`.
320    ///
321    /// When `force_allow_unused` is `true` the unused-params check is skipped
322    /// regardless of the frontmatter setting.
323    #[cfg(feature = "std")]
324    fn compile_inner(
325        source: &str,
326        base_dir: Option<&Path>,
327        force_allow_unused: bool,
328    ) -> Result<(Self, Frontmatter), TemplateError> {
329        let source_hash = crate::cache::hash_source(source);
330        let (fm, body) = if let Some(dir) = base_dir {
331            frontmatter::parse_frontmatter_with_base_dir(source, dir)?
332        } else {
333            frontmatter::parse_frontmatter(source)?
334        };
335        let body = body.to_string();
336        let (segments, inline_templates) = compiled::compile(&body, &fm.type_aliases)?;
337
338        // --- Static analysis ---
339        let referenced = compiled::collect_referenced_params(&segments);
340        check_undeclared_variables(&referenced, &fm, &inline_templates)?;
341        check_unused_params(
342            &fm.declarations,
343            &referenced,
344            force_allow_unused || fm.allow_unused,
345        )?;
346        check_name_collisions(&fm, &inline_templates, &segments)?;
347        let enum_keys = collect_enum_type_keys(&fm);
348        check_bare_enum_access(&segments, &enum_keys)?;
349
350        let has_defaults = fm.declarations.iter().any(|d| d.default_value.is_some());
351        let mut consts: HashMap<String, Value> = fm
352            .consts
353            .iter()
354            .filter_map(|d| d.default_value.clone().map(|v| (d.name.clone(), v)))
355            .collect();
356        // Inject enum type aliases as namespace constants (e.g. Stage.Design).
357        inject_enum_type_constants(&fm.type_aliases, &mut consts);
358        let segments: Arc<[Segment]> = Arc::from(segments);
359        let estimated_capacity = compiled::render::estimate_output_capacity(&segments);
360        let tmpl = Self {
361            body,
362            name: fm.name.clone(),
363            description: fm.description.clone(),
364            segments,
365            declared_variables: Arc::from(fm.declarations.clone()),
366            base_dir: base_dir.map(Path::to_path_buf),
367            inline_templates: Arc::new(inline_templates),
368            source_hash,
369            max_include_depth: crate::scope::MAX_INCLUDE_DEPTH,
370            has_defaults,
371            consts: Arc::new(consts),
372            imported_consts: Arc::new(fm.imported_consts.clone()),
373            estimated_capacity,
374        };
375        Ok((tmpl, fm))
376    }
377
378    /// `no_std` compilation entry point (no base directory, no imports).
379    #[cfg(not(feature = "std"))]
380    fn compile_from_source_no_std(source: &str) -> Result<(Self, Frontmatter), TemplateError> {
381        Self::compile_inner_no_std(source, false)
382    }
383
384    /// `no_std` core compilation.
385    #[cfg(not(feature = "std"))]
386    fn compile_inner_no_std(
387        source: &str,
388        force_allow_unused: bool,
389    ) -> Result<(Self, Frontmatter), TemplateError> {
390        let source_hash = hash_source_no_std(source);
391        let (fm, body) = frontmatter::parse_frontmatter(source)?;
392        let body = body.to_string();
393        let (segments, inline_templates) = compiled::compile(&body, &fm.type_aliases)?;
394
395        let referenced = compiled::collect_referenced_params(&segments);
396        check_undeclared_variables(&referenced, &fm, &inline_templates)?;
397        check_unused_params(
398            &fm.declarations,
399            &referenced,
400            force_allow_unused || fm.allow_unused,
401        )?;
402        check_name_collisions(&fm, &inline_templates, &segments)?;
403        let enum_keys = collect_enum_type_keys(&fm);
404        check_bare_enum_access(&segments, &enum_keys)?;
405
406        let has_defaults = fm.declarations.iter().any(|d| d.default_value.is_some());
407        let mut consts: HashMap<String, Value> = fm
408            .consts
409            .iter()
410            .filter_map(|d| d.default_value.clone().map(|v| (d.name.clone(), v)))
411            .collect();
412        // Inject enum type aliases as namespace constants (e.g. Stage.Design).
413        inject_enum_type_constants(&fm.type_aliases, &mut consts);
414        let segments: Arc<[Segment]> = Arc::from(segments);
415        let estimated_capacity = compiled::render::estimate_output_capacity(&segments);
416        let tmpl = Self {
417            body,
418            name: fm.name.clone(),
419            description: fm.description.clone(),
420            segments,
421            declared_variables: Arc::from(fm.declarations.clone()),
422            inline_templates: Arc::new(inline_templates),
423            source_hash,
424            max_include_depth: crate::scope::MAX_INCLUDE_DEPTH,
425            has_defaults,
426            consts: Arc::new(consts),
427            imported_consts: Arc::new(fm.imported_consts.clone()),
428            estimated_capacity,
429        };
430        Ok((tmpl, fm))
431    }
432
433    /// Construct a `Template` from pre-compiled segments (used by [`TemplateCache`]).
434    ///
435    /// Skips parsing and compilation entirely — the caller is responsible for
436    /// providing correct, pre-compiled data.
437    ///
438    /// [`TemplateCache`]: crate::TemplateCache
439    #[cfg(feature = "std")]
440    pub(crate) fn from_cached(data: CachedTemplateData) -> Self {
441        let has_defaults = data
442            .declared_variables
443            .iter()
444            .any(|d| d.default_value.is_some());
445        let estimated_capacity = compiled::render::estimate_output_capacity(&data.segments);
446        Self {
447            body: String::new(),
448            name: data.name,
449            description: data.description,
450            segments: data.segments,
451            declared_variables: data.declared_variables,
452            base_dir: data.base_dir,
453            inline_templates: data.inline_templates,
454            source_hash: data.source_hash,
455            max_include_depth: crate::scope::MAX_INCLUDE_DEPTH,
456            has_defaults,
457            consts: data.consts,
458            imported_consts: data.imported_consts,
459            estimated_capacity,
460        }
461    }
462
463    /// Construct a `Template` from pre-compiled static structures (used by compile-time macros).
464    #[doc(hidden)]
465    #[must_use]
466    pub fn from_precompiled(data: &PrecompiledTemplateData<'_>) -> Self {
467        let inline_map = data
468            .inline_templates
469            .iter()
470            .map(|(k, v)| (k.to_string(), v.clone()))
471            .collect();
472        let const_map = data
473            .consts
474            .iter()
475            .map(|(k, v)| (k.to_string(), v.clone()))
476            .collect();
477        let imported_const_map = data
478            .imported_consts
479            .iter()
480            .map(|(k, v)| (k.to_string(), v.clone()))
481            .collect();
482        let has_defaults = data
483            .declared_variables
484            .iter()
485            .any(|d| d.default_value.is_some());
486        let segments: Arc<[Segment]> = Arc::from(data.segments);
487        let estimated_capacity = compiled::render::estimate_output_capacity(&segments);
488        Self {
489            body: String::new(),
490            name: data.name.map(String::from),
491            description: data.description.map(String::from),
492            segments,
493            declared_variables: Arc::from(data.declared_variables),
494            #[cfg(feature = "std")]
495            base_dir: None,
496            inline_templates: Arc::new(inline_map),
497            source_hash: data.source_hash,
498            max_include_depth: crate::scope::MAX_INCLUDE_DEPTH,
499            has_defaults,
500            consts: Arc::new(const_map),
501            imported_consts: Arc::new(imported_const_map),
502            estimated_capacity,
503        }
504    }
505
506    /// Validate context: check presence, types, AND no extra variables.
507    ///
508    /// By default the engine is strict: passing undeclared parameters is
509    /// an error. Set `allow_extra` to `true` to suppress the extra-params
510    /// check (useful when forwarding a shared context to multiple templates).
511    ///
512    /// # Errors
513    ///
514    /// Returns [`TemplateError::MissingParams`] if any declared variable
515    /// is absent, [`TemplateError::TypeMismatch`] if a value has the
516    /// wrong type, or [`TemplateError::ExtraParams`] if undeclared keys
517    /// are present (and `allow_extra` is false).
518    fn validate_context(&self, ctx: &Context, allow_extra: bool) -> Result<(), TemplateError> {
519        let mut missing = Vec::new();
520        let mut mismatch: Option<(String, crate::types::TypeCheckError)> = None;
521        for decl in self.declared_variables.iter() {
522            match ctx.get(&decl.name) {
523                None => {
524                    // Skip params with defaults — they'll be injected.
525                    if decl.default_value.is_none() {
526                        missing.push(decl.name.as_str());
527                    }
528                }
529                Some(value) => {
530                    if mismatch.is_none()
531                        && let Err(e) = decl.var_type.check(value)
532                    {
533                        mismatch = Some((decl.name.clone(), e));
534                    }
535                }
536            }
537        }
538        // Report missing params first (most fundamental).
539        if !missing.is_empty() {
540            return Err(TemplateError::MissingParams(
541                missing.into_iter().map(String::from).collect(),
542            ));
543        }
544        if let Some((name, check_err)) = mismatch {
545            let detail = if check_err.path.is_empty() {
546                String::new()
547            } else {
548                format!(" (at .{})", check_err.path)
549            };
550            return Err(TemplateError::TypeMismatch {
551                name: format!("{name}{detail}"),
552                expected: check_err.expected,
553                actual: check_err.actual,
554                actual_value: check_err.actual_value,
555            });
556        }
557        // Reject extra (undeclared) parameters unless explicitly allowed.
558        if !allow_extra {
559            let mut declared: HashSet<&str> = self
560                .declared_variables
561                .iter()
562                .map(|d| d.name.as_str())
563                .collect();
564            for name in self.consts.keys() {
565                declared.insert(name.as_str());
566            }
567            let extra: Vec<String> = ctx
568                .values
569                .keys()
570                .filter(|k| !declared.contains(k.as_str()))
571                .cloned()
572                .collect();
573            if !extra.is_empty() {
574                return Err(TemplateError::ExtraParams(extra));
575            }
576        }
577        Ok(())
578    }
579
580    /// Returns default values for all params that have them.
581    #[must_use]
582    pub fn defaults(&self) -> HashMap<String, crate::value::Value> {
583        self.declared_variables
584            .iter()
585            .filter_map(|d| {
586                d.default_value
587                    .as_ref()
588                    .map(|v| (d.name.clone(), v.clone()))
589            })
590            .collect()
591    }
592
593    /// Returns the default value for a single parameter, if it has one.
594    #[must_use]
595    pub fn default(&self, name: &str) -> Option<&crate::value::Value> {
596        self.declared_variables
597            .iter()
598            .find(|d| d.name == name)
599            .and_then(|d| d.default_value.as_ref())
600    }
601
602    /// Returns a [`Context`] pre-filled with all default values.
603    ///
604    /// Use this as a starting point, then override only the params you need:
605    /// ```
606    /// # use md_tmpl::{Template, Context};
607    /// let tmpl = Template::from_source(
608    ///     r#"---
609    /// params:
610    ///   - name = str
611    ///   - count = int := 5
612    /// ---
613    /// {{ name }} ({{ count }})"#,
614    /// )
615    /// .unwrap();
616    /// let mut ctx = tmpl.defaults_context();
617    /// ctx.set("name", "Alice"); // count already has default 5
618    /// assert_eq!(tmpl.render_ctx(&ctx).unwrap(), "Alice (5)");
619    /// ```
620    #[must_use]
621    pub fn defaults_context(&self) -> Context {
622        let defaults = self.defaults();
623        let mut ctx = Context::with_capacity(defaults.len());
624        for (k, v) in defaults {
625            ctx.set(k, v);
626        }
627        ctx
628    }
629
630    /// Return the raw template body text (after frontmatter stripping).
631    ///
632    /// Useful for compile-time validation and macro integration.
633    #[must_use]
634    pub fn body(&self) -> &str {
635        &self.body
636    }
637
638    /// Returns the template's name, if defined in frontmatter.
639    #[must_use]
640    pub fn name(&self) -> Option<&str> {
641        self.name.as_deref()
642    }
643
644    /// Returns the template's description, if defined in frontmatter.
645    #[must_use]
646    pub fn description(&self) -> Option<&str> {
647        self.description.as_deref()
648    }
649
650    /// Set the maximum include depth for rendering this template.
651    pub fn set_max_include_depth(&mut self, depth: usize) {
652        self.max_include_depth = depth;
653    }
654
655    /// Set the maximum include depth for rendering this template (builder style).
656    #[must_use]
657    pub fn with_max_include_depth(mut self, depth: usize) -> Self {
658        self.max_include_depth = depth;
659        self
660    }
661
662    /// Return the declared variables from frontmatter.
663    ///
664    /// Used by generated param structs to validate that a reloaded template
665    /// still matches the compile-time variable declarations.
666    #[must_use]
667    pub fn declarations(&self) -> &[VarDecl] {
668        &self.declared_variables
669    }
670
671    pub(crate) fn segments(&self) -> &[crate::compiled::Segment] {
672        &self.segments
673    }
674
675    /// Returns the base directory used for resolving filesystem `{% include %}` paths.
676    #[cfg(feature = "std")]
677    #[must_use]
678    pub fn base_dir(&self) -> Option<&Path> {
679        self.base_dir.as_deref()
680    }
681
682    /// Returns the constants defined in this template's frontmatter.
683    ///
684    /// Constants are defined with `consts:` in frontmatter and are automatically
685    /// available during rendering without being passed in the context.
686    ///
687    /// # Examples
688    ///
689    /// ```
690    /// use md_tmpl::Template;
691    ///
692    /// let tmpl = Template::from_source(
693    ///     r#"---
694    /// consts:
695    ///   - MAX = int := 100
696    ///
697    /// params: []
698    /// ---
699    /// {{ MAX }}"#,
700    /// )
701    /// .unwrap();
702    /// let consts = tmpl.consts();
703    /// assert_eq!(consts.get("MAX").unwrap().as_int(), Some(100));
704    /// ```
705    #[must_use]
706    pub fn consts(&self) -> Arc<HashMap<String, Value>> {
707        self.consts.clone()
708    }
709
710    /// Returns a borrowed reference to the constants defined in this
711    /// template's frontmatter, avoiding the [`Arc`] clone of [`consts`](Self::consts).
712    #[must_use]
713    pub fn consts_ref(&self) -> &HashMap<String, Value> {
714        &self.consts
715    }
716
717    /// Returns the imported constants (from `{% import %}` directives).
718    ///
719    /// These are constants imported from other template files and are
720    /// automatically available during rendering alongside regular constants.
721    #[must_use]
722    pub fn imported_consts(&self) -> Arc<HashMap<String, Value>> {
723        self.imported_consts.clone()
724    }
725
726    /// Returns a borrowed reference to the imported constants, avoiding
727    /// the [`Arc`] clone of [`imported_consts`](Self::imported_consts).
728    #[must_use]
729    pub fn imported_consts_ref(&self) -> &HashMap<String, Value> {
730        &self.imported_consts
731    }
732
733    pub(crate) fn inline_templates(&self) -> &HashMap<String, CompiledInlineTemplate> {
734        &self.inline_templates
735    }
736
737    /// Content hash of the raw source — use to detect unchanged files on
738    /// hot-reload without re-parsing.
739    ///
740    /// Same source → same hash.  Different source → (very likely) different
741    /// hash.  This is a fast non-cryptographic hash, not suitable for
742    /// security purposes.
743    #[must_use]
744    pub fn source_hash(&self) -> u64 {
745        self.source_hash
746    }
747
748    /// Validate that a (possibly reloaded) template's variable declarations
749    /// match an expected set.
750    ///
751    /// Call this after re-loading a template from disk to ensure that
752    /// nobody (e.g. an autonomous agent editing markdown files at runtime)
753    /// has modified the `params:` block in the frontmatter.
754    ///
755    /// The template body may be changed freely — only the variable
756    /// declarations must remain stable.
757    ///
758    /// # Errors
759    ///
760    /// Returns [`TemplateError::DeclarationsMutated`] with a human-readable
761    /// diff if the declarations don't match.
762    pub fn validate_declarations(&self, expected: &[VarDecl]) -> Result<(), TemplateError> {
763        let current: HashMap<&str, &crate::types::VarType> = self
764            .declared_variables
765            .iter()
766            .map(|d| (d.name.as_str(), &d.var_type))
767            .collect();
768        let expected_map: HashMap<&str, &crate::types::VarType> = expected
769            .iter()
770            .map(|d| (d.name.as_str(), &d.var_type))
771            .collect();
772
773        let current_names: HashSet<&str> = current.keys().copied().collect();
774        let expected_names: HashSet<&str> = expected_map.keys().copied().collect();
775
776        let missing: Vec<&str> = expected_names.difference(&current_names).copied().collect();
777        let extra: Vec<&str> = current_names.difference(&expected_names).copied().collect();
778
779        // Check for type changes on variables that exist in both.
780        let retyped: Vec<String> = current_names
781            .intersection(&expected_names)
782            .filter_map(|name| {
783                let cur_type = current[name];
784                let exp_type = expected_map[name];
785                if cur_type == exp_type {
786                    None
787                } else {
788                    Some(format!("{name}: {exp_type} → {cur_type}"))
789                }
790            })
791            .collect();
792
793        if missing.is_empty() && extra.is_empty() && retyped.is_empty() {
794            return Ok(());
795        }
796
797        let mut parts = Vec::new();
798        if !missing.is_empty() {
799            parts.push(format!("removed: {}", missing.join(", ")));
800        }
801        if !extra.is_empty() {
802            parts.push(format!("added: {}", extra.join(", ")));
803        }
804        if !retyped.is_empty() {
805            parts.push(format!("retyped: {}", retyped.join(", ")));
806        }
807
808        Err(TemplateError::DeclarationsMutated {
809            details: parts.join("; "),
810        })
811    }
812
813    /// Render the template with the given context (strict mode).
814    ///
815    /// Validates the context against frontmatter declarations:
816    /// - Missing declared parameters → error
817    /// - Type mismatches → error
818    /// - Extra undeclared parameters → error
819    ///
820    /// Use [`render_ctx_allowing_extra`](Self::render_ctx_allowing_extra) to permit
821    /// undeclared parameters (e.g. when sharing a context across templates).
822    ///
823    /// # Errors
824    ///
825    /// Returns [`TemplateError`] if validation fails or a rendering error
826    /// occurs.
827    pub fn render_ctx(&self, ctx: &Context) -> Result<String, TemplateError> {
828        self.render_inner(ctx, false)
829    }
830
831    /// Render the template, allowing extra (undeclared) parameters.
832    ///
833    /// Like [`render_ctx`](Self::render_ctx), but extra context keys that aren't
834    /// declared in frontmatter are silently ignored instead of producing
835    /// an error. Useful when forwarding a shared context to multiple
836    /// templates.
837    ///
838    /// # Errors
839    ///
840    /// Returns [`TemplateError`] if validation fails or a rendering error
841    /// occurs.
842    pub fn render_ctx_allowing_extra(&self, ctx: &Context) -> Result<String, TemplateError> {
843        self.render_inner(ctx, true)
844    }
845
846    /// Render a template that takes no user-provided parameters.
847    ///
848    /// If the template declares parameters, those **must** all have defaults.
849    /// Calling `render_empty()` on a template with required (no-default)
850    /// parameters returns [`TemplateError::MissingParams`].
851    ///
852    /// This is more efficient than `render(&empty_struct)` (no serde overhead)
853    /// and more explicit than `render_ctx(&Context::new())`.
854    ///
855    /// # Examples
856    ///
857    /// ```
858    /// use md_tmpl::Template;
859    ///
860    /// // No params — renders as-is
861    /// let tmpl = Template::from_source(
862    ///     r#"---
863    /// params: []
864    /// ---
865    /// Hello world!"#,
866    /// )
867    /// .unwrap();
868    /// assert_eq!(tmpl.render_empty().unwrap(), "Hello world!");
869    ///
870    /// // All params have defaults
871    /// let tmpl = Template::from_source(
872    ///     r#"---
873    /// params:
874    ///   - greeting = str := "Hi"
875    /// ---
876    /// {{ greeting }}!"#,
877    /// )
878    /// .unwrap();
879    /// assert_eq!(tmpl.render_empty().unwrap(), "Hi!");
880    /// ```
881    ///
882    /// # Errors
883    ///
884    /// Returns [`TemplateError::MissingParams`] if any declared parameter
885    /// lacks a default value.
886    pub fn render_empty(&self) -> Result<String, TemplateError> {
887        let ctx = if self.has_defaults {
888            self.defaults_context()
889        } else {
890            Context::new()
891        };
892        self.render_ctx(&ctx)
893    }
894
895    /// Like [`render_empty`](Self::render_empty), but appends to an existing buffer.
896    ///
897    /// # Errors
898    ///
899    /// Returns [`TemplateError::MissingParams`] if any declared parameter
900    /// lacks a default value.
901    pub fn render_empty_into(&self, output: &mut String) -> Result<(), TemplateError> {
902        let ctx = if self.has_defaults {
903            self.defaults_context()
904        } else {
905            Context::new()
906        };
907        self.render_ctx_into(&ctx, output)
908    }
909
910    /// Internal render path with configurable strictness.
911    fn render_inner(&self, ctx: &Context, allow_extra: bool) -> Result<String, TemplateError> {
912        let mut output = String::with_capacity(self.estimated_capacity);
913        self.render_into_inner(ctx, allow_extra, &mut output)?;
914        Ok(output)
915    }
916
917    /// Render the template directly into an existing `String` buffer.
918    ///
919    /// Unlike [`render_ctx`](Self::render_ctx), this appends to `output` without
920    /// allocating a new `String`. Useful when composing multiple template
921    /// outputs into a single buffer.
922    ///
923    /// # Errors
924    ///
925    /// Returns [`TemplateError`] if validation fails or a rendering error
926    /// occurs. On error, `output` may contain partial results.
927    pub fn render_ctx_into(&self, ctx: &Context, output: &mut String) -> Result<(), TemplateError> {
928        self.render_into_inner(ctx, false, output)
929    }
930
931    /// Like [`render_ctx_into`](Self::render_ctx_into), but allows extra (undeclared)
932    /// parameters.
933    ///
934    /// # Errors
935    ///
936    /// Returns [`TemplateError`] if validation fails or a rendering error
937    /// occurs.
938    pub fn render_ctx_into_allowing_extra(
939        &self,
940        ctx: &Context,
941        output: &mut String,
942    ) -> Result<(), TemplateError> {
943        self.render_into_inner(ctx, true, output)
944    }
945
946    /// Shared implementation for all render-into paths.
947    fn render_into_inner(
948        &self,
949        ctx: &Context,
950        allow_extra: bool,
951        output: &mut String,
952    ) -> Result<(), TemplateError> {
953        self.validate_context(ctx, allow_extra)?;
954        self.render_core(ctx, output)
955    }
956
957    /// Core rendering without any context validation.
958    ///
959    /// Used by both `render_into_inner` (after validation) and
960    /// `render_ctx_unchecked` (no validation at all).
961    fn render_core(&self, ctx: &Context, output: &mut String) -> Result<(), TemplateError> {
962        let ctx = self.inject_defaults(ctx);
963        let mut scope = Scope::new(&ctx).with_max_include_depth(self.max_include_depth);
964        // Skip Arc clones when there are no constants (common case).
965        if !self.consts.is_empty() || !self.imported_consts.is_empty() {
966            scope.set_consts(&self.consts, &self.imported_consts);
967        }
968        scope.set_inline_templates(&self.inline_templates);
969        #[cfg(feature = "std")]
970        return compiled::render::render_segments_into(
971            &self.segments,
972            &mut scope,
973            self.base_dir.as_deref(),
974            output,
975        );
976        #[cfg(not(feature = "std"))]
977        return compiled::render_segments_into_no_std(&self.segments, &mut scope, output);
978    }
979
980    /// Render the template **without** context validation.
981    ///
982    /// Skips the parameter presence, type, and extra-key checks that
983    /// [`render_ctx`](Self::render_ctx) performs on every call. This is a safe
984    /// operation — rendering errors (e.g. undefined variable) are still
985    /// reported via `Err` — but the upfront validation overhead is removed.
986    ///
987    /// Use this when the context is known-good (e.g. constructed from a
988    /// strongly-typed params struct, or pre-validated once at startup).
989    ///
990    /// # Errors
991    ///
992    /// Returns [`TemplateError`] if a rendering error occurs (e.g.
993    /// undefined variable, filter error).
994    pub fn render_ctx_unchecked(&self, ctx: &Context) -> Result<String, TemplateError> {
995        let mut output = String::with_capacity(self.estimated_capacity);
996        self.render_core(ctx, &mut output)?;
997        Ok(output)
998    }
999
1000    /// Render into a buffer **without** context validation.
1001    ///
1002    /// Like [`render_ctx_unchecked`](Self::render_ctx_unchecked), but appends to
1003    /// an existing buffer.
1004    ///
1005    /// # Errors
1006    ///
1007    /// Returns [`TemplateError`] if a rendering error occurs.
1008    pub fn render_ctx_into_unchecked(
1009        &self,
1010        ctx: &Context,
1011        output: &mut String,
1012    ) -> Result<(), TemplateError> {
1013        self.render_core(ctx, output)
1014    }
1015
1016    /// Render the template using a [`TemplateCache`](crate::TemplateCache) for include resolution.
1017    ///
1018    /// Like [`render_ctx`](Self::render_ctx), but included templates are resolved
1019    /// through the cache — unchanged includes are not re-read or re-compiled.
1020    /// This is the recommended rendering path for hot-reload scenarios where
1021    /// templates are re-rendered frequently.
1022    ///
1023    /// # Errors
1024    ///
1025    /// Returns [`TemplateError`] if validation fails or a rendering error
1026    /// occurs.
1027    #[cfg(feature = "std")]
1028    pub fn render_ctx_cached<S: core::hash::BuildHasher + Send + Sync>(
1029        &self,
1030        ctx: &Context,
1031        cache: &crate::TemplateCache<S>,
1032    ) -> Result<String, TemplateError> {
1033        self.validate_context(ctx, false)?;
1034        let ctx = self.inject_defaults(ctx);
1035        let mut scope =
1036            Scope::with_cache(&ctx, cache).with_max_include_depth(self.max_include_depth);
1037        if !self.consts.is_empty() || !self.imported_consts.is_empty() {
1038            scope.set_consts(&self.consts, &self.imported_consts);
1039        }
1040        scope.set_inline_templates(&self.inline_templates);
1041        compiled::render_segments(&self.segments, &mut scope, self.base_dir.as_deref())
1042    }
1043
1044    /// Render with caching, allowing extra parameters in the context.
1045    ///
1046    /// Like [`render_ctx_cached()`](Self::render_ctx_cached) but does not
1047    /// reject parameters not declared in the template frontmatter.
1048    ///
1049    /// # Errors
1050    ///
1051    /// Returns [`TemplateError`] if validation fails or a rendering error
1052    /// occurs.
1053    #[cfg(feature = "std")]
1054    pub fn render_ctx_cached_allowing_extra<S: core::hash::BuildHasher + Send + Sync>(
1055        &self,
1056        ctx: &Context,
1057        cache: &crate::TemplateCache<S>,
1058    ) -> Result<String, TemplateError> {
1059        self.validate_context(ctx, true)?;
1060        let ctx = self.inject_defaults(ctx);
1061        let mut scope =
1062            Scope::with_cache(&ctx, cache).with_max_include_depth(self.max_include_depth);
1063        if !self.consts.is_empty() || !self.imported_consts.is_empty() {
1064            scope.set_consts(&self.consts, &self.imported_consts);
1065        }
1066        scope.set_inline_templates(&self.inline_templates);
1067        compiled::render_segments(&self.segments, &mut scope, self.base_dir.as_deref())
1068    }
1069
1070    /// Inject default values for any declared params not present in `ctx`.
1071    ///
1072    /// Returns a `Cow::Borrowed` if no defaults needed, avoiding allocation.
1073    fn inject_defaults<'a>(&self, ctx: &'a Context) -> alloc::borrow::Cow<'a, Context> {
1074        if !self.has_defaults {
1075            return alloc::borrow::Cow::Borrowed(ctx);
1076        }
1077        let mut owned: Option<Context> = None;
1078        for decl in self.declared_variables.iter() {
1079            if let Some(ref default) = decl.default_value {
1080                let effective = owned.as_ref().unwrap_or(ctx);
1081                if effective.get(&decl.name).is_none() {
1082                    let ctx_mut = owned.get_or_insert_with(|| ctx.clone());
1083                    ctx_mut.set(decl.name.clone(), default.clone());
1084                }
1085            }
1086        }
1087        match owned {
1088            Some(ctx) => alloc::borrow::Cow::Owned(ctx),
1089            None => alloc::borrow::Cow::Borrowed(ctx),
1090        }
1091    }
1092}
1093
1094#[cfg(feature = "serde")]
1095impl Template {
1096    /// Render the template from any `Serialize` struct.
1097    ///
1098    /// Struct fields become template variables — no manual `Context`
1099    /// construction needed.
1100    ///
1101    /// # Errors
1102    ///
1103    /// Returns [`TemplateError`] if serialization fails, the value is not a
1104    /// struct/map, or rendering encounters an error.
1105    ///
1106    /// # Examples
1107    ///
1108    /// ```
1109    /// use md_tmpl::Template;
1110    /// use serde::Serialize;
1111    ///
1112    /// #[derive(Serialize)]
1113    /// struct Data {
1114    ///     name: String,
1115    ///     count: i64,
1116    /// }
1117    ///
1118    /// let tmpl = Template::from_source(
1119    ///     r#"---
1120    /// params: [name = str, count = int]
1121    /// ---
1122    /// {{ name }} has {{ count }} items"#,
1123    /// )
1124    /// .unwrap();
1125    /// let output = tmpl
1126    ///     .render(&Data {
1127    ///         name: "Alice".into(),
1128    ///         count: 3,
1129    ///     })
1130    ///     .unwrap();
1131    /// assert_eq!(output, "Alice has 3 items");
1132    /// ```
1133    pub fn render<T: serde::Serialize>(
1134        &self,
1135        value: &T,
1136    ) -> Result<String, crate::error::TemplateError> {
1137        let ctx = Context::from_serialize(value)?;
1138        self.render_ctx(&ctx)
1139    }
1140
1141    /// Like [`render`](Self::render), but appends output into an
1142    /// existing buffer.
1143    ///
1144    /// # Errors
1145    ///
1146    /// Returns [`TemplateError`] if serialization fails, the value is not a
1147    /// struct/map, or rendering encounters an error.
1148    pub fn render_into<T: serde::Serialize>(
1149        &self,
1150        value: &T,
1151        output: &mut String,
1152    ) -> Result<(), crate::error::TemplateError> {
1153        let ctx = Context::from_serialize(value)?;
1154        self.render_ctx_into(&ctx, output)
1155    }
1156}
1157
1158// ---------------------------------------------------------------------------
1159// Trait impls for embedding Template in macro-generated structs
1160// ---------------------------------------------------------------------------
1161
1162/// Two templates are considered equal if they were compiled from the same
1163/// source (compared via non-cryptographic 64-bit hash).
1164///
1165/// **Note:** This is an approximate comparison — different sources that
1166/// produce the same hash would incorrectly compare as equal.  Do not use
1167/// `Template` as a `HashMap` key or rely on `Eq` for deduplication in
1168/// security-sensitive contexts.  For exact source comparison, compare
1169/// [`body()`](Self::body) and [`declarations()`](Self::declarations).
1170impl PartialEq for Template {
1171    fn eq(&self, other: &Self) -> bool {
1172        self.source_hash == other.source_hash
1173    }
1174}
1175
1176impl Eq for Template {}
1177
1178/// Serialize a [`Template`] as a source-hash identifier.
1179///
1180/// Templates embedded in macro-generated parameter structs need `Serialize`
1181/// to satisfy derive bounds, even when the struct is never actually
1182/// serialized.  The hash lets debug/logging code produce something readable.
1183#[cfg(feature = "serde")]
1184impl serde::Serialize for Template {
1185    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1186        serializer.serialize_str(&format!("template:{:016x}", self.source_hash))
1187    }
1188}
1189
1190/// Deserialize always fails — [`Template`] must be constructed from source.
1191///
1192/// This impl exists solely to satisfy derive bounds on macro-generated
1193/// parameter structs.  Actual deserialization of a compiled template is not
1194/// meaningful.
1195#[cfg(feature = "serde")]
1196impl<'de> serde::Deserialize<'de> for Template {
1197    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1198        let _ = <serde::de::IgnoredAny as serde::Deserialize>::deserialize(deserializer)?;
1199        Err(serde::de::Error::custom(
1200            "Template cannot be deserialized; construct from source with \
1201             Template::from_source() or Template::from_file()",
1202        ))
1203    }
1204}
1205
1206/// Load a named template from a directory.
1207///
1208/// Looks for `<name>.tmpl.md` in `dir`.
1209///
1210/// # Errors
1211///
1212/// Returns [`TemplateError::Io`] if the file is not found or cannot be read.
1213#[cfg(feature = "std")]
1214pub fn load_template(dir: &Path, name: &str) -> Result<Template, TemplateError> {
1215    let path = dir.join(format!("{name}.tmpl.md"));
1216    Template::from_file(&path)
1217}
1218/// Inject enum type aliases as namespace constants.
1219///
1220/// For each enum type alias like `Stage = enum(Design, Build)`, this creates
1221/// a dict constant `Stage` → `{Design: "Design", Build: "Build"}`.
1222/// This enables expressions like `{{ kind(Stage.Design) }}` in templates.
1223/// Bare access like `{{ Stage.Design }}` is rejected at compile time —
1224/// users must wrap enum literals in `kind()` for explicit variant name extraction.
1225///
1226/// Unit variants map to `Value::Str(name)`. Struct variants map to a tagged
1227/// dict with just `__kind__` set (a partial value suitable for `kind()` and
1228/// match arms).
1229fn inject_enum_type_constants(
1230    type_aliases: &HashMap<String, VarType>,
1231    consts: &mut HashMap<String, Value>,
1232) {
1233    for (type_name, var_type) in type_aliases {
1234        let VarType::Enum(variants) = var_type else {
1235            continue;
1236        };
1237        // Don't overwrite a user-defined constant with the same name.
1238        if consts.contains_key(type_name) {
1239            continue;
1240        }
1241        let mut variant_map = HashMap::new();
1242        for variant in variants {
1243            if variant.fields.is_empty() {
1244                // Unit variant → simple string.
1245                variant_map.insert(variant.name.clone(), Value::Str(variant.name.clone()));
1246            } else {
1247                // Struct variant → tagged dict with __kind__ only.
1248                let mut partial = HashMap::new();
1249                partial.insert(
1250                    crate::consts::ENUM_TAG_KEY.into(),
1251                    Value::Str(variant.name.clone()),
1252                );
1253                variant_map.insert(variant.name.clone(), Value::Struct(Arc::new(partial)));
1254            }
1255        }
1256        consts.insert(type_name.clone(), Value::Struct(Arc::new(variant_map)));
1257    }
1258}
1259
1260/// Collect the set of enum type names (both local and imported).
1261///
1262/// For local types, stores just the type name (e.g. `"Stage"`).
1263/// For imported types, stores the full `stem.TypeName` key (e.g.
1264/// `"lib.Color"`), so that non-enum imports like `lib.MAX_TIMEOUT`
1265/// are not incorrectly flagged.
1266fn collect_enum_type_keys(fm: &Frontmatter) -> HashSet<String> {
1267    let mut keys = HashSet::new();
1268    // Local enum types.
1269    for (name, ty) in &fm.type_aliases {
1270        if matches!(ty, VarType::Enum(_)) {
1271            keys.insert(name.clone());
1272        }
1273    }
1274    // Imported enum types: recorded during frontmatter import resolution.
1275    for key in &fm.imported_enum_type_keys {
1276        keys.insert(key.clone());
1277    }
1278    keys
1279}
1280
1281/// Reject bare enum literal expressions like `{{ Stage.Design }}`.
1282///
1283/// Enum type namespaces are injected as dict constants so that `kind()` can
1284/// access them, but they should not be rendered directly. Instead, users
1285/// must wrap them in `kind()`: `{{ kind(Stage.Design) }}`.
1286///
1287/// This prevents accidental confusion between enum type access and regular
1288/// variable dot-access.
1289fn check_bare_enum_access(
1290    segments: &[compiled::Segment],
1291    enum_keys: &HashSet<String>,
1292) -> Result<(), TemplateError> {
1293    for seg in segments {
1294        match seg {
1295            compiled::Segment::Expr {
1296                expr: compiled::CompiledExpr::Path(path),
1297                ..
1298            } => {
1299                let parts = path.parts();
1300                if parts.len() >= 2 && is_enum_path(parts, enum_keys) {
1301                    return Err(TemplateError::syntax(format!(
1302                        "bare enum literal '{}' is not allowed — \
1303                         use kind({}) to get the variant name as a string",
1304                        path.as_str(),
1305                        path.as_str(),
1306                    )));
1307                }
1308            }
1309            compiled::Segment::ForLoop { body, .. } => {
1310                check_bare_enum_access(body, enum_keys)?;
1311            }
1312            compiled::Segment::If {
1313                branches,
1314                else_body,
1315            } => {
1316                for (_, branch_body) in branches {
1317                    check_bare_enum_access(branch_body, enum_keys)?;
1318                }
1319                check_bare_enum_access(else_body, enum_keys)?;
1320            }
1321            compiled::Segment::Match { arms, .. } => {
1322                for (_, arm_body) in arms {
1323                    check_bare_enum_access(arm_body, enum_keys)?;
1324                }
1325            }
1326            compiled::Segment::Include(inc) => {
1327                if let Some(ref inline) = inc.inline_compiled {
1328                    check_bare_enum_access(&inline.segments, enum_keys)?;
1329                }
1330            }
1331            _ => {}
1332        }
1333    }
1334    Ok(())
1335}
1336
1337/// Check if a dotted path matches a known enum type namespace.
1338///
1339/// - Local: `["Stage", "Design"]` → checks `"Stage"` in keys.
1340/// - Imported: `["lib", "Color", "Red"]` → checks `"lib.Color"` in keys.
1341fn is_enum_path(parts: &[String], enum_keys: &HashSet<String>) -> bool {
1342    // Try 1-part root (local enum type).
1343    if enum_keys.contains(&parts[0]) {
1344        return true;
1345    }
1346    // Try 2-part root (imported enum type: stem.TypeName).
1347    if parts.len() >= 3 {
1348        let key = format!("{}.{}", parts[0], parts[1]);
1349        if enum_keys.contains(&key) {
1350            return true;
1351        }
1352    }
1353    false
1354}
1355
1356/// Enforce that all parameters referenced in the body are declared.
1357///
1358/// Builds the full set of declared names (params, consts, import stems, inline
1359/// template names) and checks every referenced variable against it. Produces
1360/// 'did you mean?' suggestions via Levenshtein distance for near-misses.
1361fn check_undeclared_variables(
1362    referenced: &HashSet<String>,
1363    fm: &Frontmatter,
1364    inline_templates: &HashMap<String, CompiledInlineTemplate>,
1365) -> Result<(), TemplateError> {
1366    let mut declared: HashSet<String> = fm.params.iter().cloned().collect();
1367    for c in &fm.consts {
1368        declared.insert(c.name.clone());
1369    }
1370    for import in &fm.imports {
1371        declared.insert(import.stem.clone());
1372    }
1373    // Enum type aliases are auto-injected as namespace constants,
1374    // so references like `Stage.Design` (root = `Stage`) are valid.
1375    for (name, ty) in &fm.type_aliases {
1376        if matches!(ty, VarType::Enum(_)) {
1377            declared.insert(name.clone());
1378        }
1379    }
1380    // Inline template names ({% tmpl NAME %}) are valid targets for
1381    // {% include NAME %} and should not be flagged as undeclared variables.
1382    for inline_name in inline_templates.keys() {
1383        declared.insert(inline_name.clone());
1384    }
1385
1386    let undeclared: Vec<&String> = referenced
1387        .iter()
1388        .filter(|v| !declared.contains(v.as_str()))
1389        .collect();
1390    if undeclared.is_empty() {
1391        return Ok(());
1392    }
1393
1394    let mut names: Vec<&str> = undeclared.iter().map(|s| s.as_str()).collect();
1395    names.sort_unstable();
1396
1397    // Collect 'did you mean?' suggestions for each undeclared name.
1398    let mut suggestions = Vec::new();
1399    for name in &names {
1400        let mut best: Option<(&str, usize)> = None;
1401        for candidate in &declared {
1402            let dist = crate::error::levenshtein_distance(name, candidate);
1403            if dist > 0 && dist <= 2 && best.is_none_or(|b| dist < b.1) {
1404                best = Some((candidate, dist));
1405            }
1406        }
1407        if let Some((suggestion, _)) = best {
1408            suggestions.push(format!("'{name}' (did you mean '{suggestion}'?)"));
1409        }
1410    }
1411    let suffix = if suggestions.is_empty() {
1412        String::new()
1413    } else {
1414        format!(". Suggestions: {}", suggestions.join(", "))
1415    };
1416    Err(TemplateError::syntax(format!(
1417        "{}{}{suffix}",
1418        crate::consts::ERR_UNDECLARED_PREFIX,
1419        names.join(", ")
1420    )))
1421}
1422
1423/// Reject declared parameters that are never referenced in the body.
1424///
1425/// Skipped when `allow_unused` is `true` (set via frontmatter or API).
1426fn check_unused_params(
1427    declarations: &[VarDecl],
1428    referenced: &HashSet<String>,
1429    allow_unused: bool,
1430) -> Result<(), TemplateError> {
1431    if allow_unused {
1432        return Ok(());
1433    }
1434    let unused: Vec<&str> = declarations
1435        .iter()
1436        .filter(|decl| !referenced.contains(&decl.name))
1437        .map(|decl| decl.name.as_str())
1438        .collect();
1439    if unused.is_empty() {
1440        return Ok(());
1441    }
1442    Err(TemplateError::syntax(format!(
1443        "unused declared parameter(s): {}. Reference them in the template body, \
1444         in a {{# comment #}}, or remove them from the frontmatter `params:` list. \
1445         To suppress this check, add `allow_unused: true` to the frontmatter",
1446        unused.join(", ")
1447    )))
1448}
1449
1450/// Check for namespace collisions between imports, params/consts, and inline
1451/// templates (Rules 11, 12, 13).
1452///
1453/// - **Rule 11**: Import stem vs inline template name.
1454/// - **Rule 12**: Param/const name vs inline template name.
1455/// - **Rule 13**: For-loop bindings must not shadow any declared name.
1456fn check_name_collisions(
1457    fm: &Frontmatter,
1458    inline_templates: &HashMap<String, CompiledInlineTemplate>,
1459    segments: &[Segment],
1460) -> Result<(), TemplateError> {
1461    // Rule 11: Import stem vs inline template name collision.
1462    for import in &fm.imports {
1463        if inline_templates.contains_key(&import.stem) {
1464            return Err(TemplateError::syntax(format!(
1465                "import stem '{}' conflicts with inline template name",
1466                import.stem
1467            )));
1468        }
1469    }
1470
1471    // Rule 12: Param/const name vs inline template name collision.
1472    // Check against params + consts only — NOT the full declared set which
1473    // already contains inline template names (for undeclared-var analysis).
1474    let param_and_const_names: HashSet<&str> = fm
1475        .params
1476        .iter()
1477        .map(String::as_str)
1478        .chain(fm.consts.iter().map(|c| c.name.as_str()))
1479        .collect();
1480    for inline_name in inline_templates.keys() {
1481        if param_and_const_names.contains(inline_name.as_str()) {
1482            return Err(TemplateError::syntax(format!(
1483                "inline template name '{inline_name}' conflicts with a declared parameter or constant"
1484            )));
1485        }
1486    }
1487
1488    // Rule 13: for-loop bindings must not shadow declared names.
1489    let protected_names: HashSet<&str> = fm
1490        .params
1491        .iter()
1492        .map(String::as_str)
1493        .chain(fm.consts.iter().map(|c| c.name.as_str()))
1494        .chain(fm.imports.iter().map(|i| i.stem.as_str()))
1495        .chain(inline_templates.keys().map(String::as_str))
1496        .collect();
1497    validate_for_bindings(segments, &protected_names)
1498}
1499
1500/// Walk compiled segments and reject any for-loop binding that shadows a
1501/// protected name (param, const, import stem, or inline template).
1502///
1503/// Sequential for-loops with the same binding are allowed — the binding
1504/// is scoped to the loop body and does not persist.
1505fn validate_for_bindings(
1506    segments: &[crate::compiled::Segment],
1507    protected: &HashSet<&str>,
1508) -> Result<(), TemplateError> {
1509    use crate::compiled::Segment;
1510    for seg in segments {
1511        match seg {
1512            Segment::ForLoop { binding, body, .. } => {
1513                if protected.contains(binding.as_ref()) {
1514                    return Err(TemplateError::syntax(format!(
1515                        "{} declared name '{binding}'",
1516                        crate::consts::ERR_FOR_BINDING_SHADOWS,
1517                    )));
1518                }
1519                validate_for_bindings(body, protected)?;
1520            }
1521            Segment::If {
1522                branches,
1523                else_body,
1524            } => {
1525                for (_cond, branch_body) in branches {
1526                    validate_for_bindings(branch_body, protected)?;
1527                }
1528                validate_for_bindings(else_body, protected)?;
1529            }
1530            Segment::Match { arms, .. } => {
1531                for (_variants, arm_body) in arms {
1532                    validate_for_bindings(arm_body, protected)?;
1533                }
1534            }
1535            _ => {}
1536        }
1537    }
1538    Ok(())
1539}
1540
1541/// Simple FNV-1a hash for `no_std` environments.
1542///
1543/// Delegates to the shared implementation in [`crate::__private::fnv1a_hash`].
1544#[cfg(not(feature = "std"))]
1545fn hash_source_no_std(source: &str) -> u64 {
1546    crate::__private::fnv1a_hash(source.as_bytes())
1547}
1548
1549#[cfg(all(test, feature = "std"))]
1550mod adversarial_tests;
1551#[cfg(all(test, feature = "std"))]
1552mod collision_and_scope_tests;
1553#[cfg(all(test, feature = "std"))]
1554mod const_tests;
1555#[cfg(all(test, feature = "std"))]
1556mod error_diagnostic_tests;
1557#[cfg(all(test, feature = "std"))]
1558mod higher_order_tests;
1559#[cfg(all(test, feature = "std"))]
1560mod inline_edge_tests;
1561#[cfg(all(test, feature = "std"))]
1562mod render_integration_tests;
1563#[cfg(all(test, feature = "std"))]
1564mod shared_tests;
1565#[cfg(all(test, feature = "std"))]
1566mod tests;