Skip to main content

md_tmpl_core/frontmatter/
mod.rs

1//! YAML-style frontmatter parsing for `.tmpl.md` files.
2//!
3//! Extracts template metadata (name, description, typed variable declarations)
4//! from the `---`-delimited block at the start of a template source string.
5//!
6//! ## Frontmatter v2 format
7//!
8//! Uses `=` for name-type pairs, `<>` for type parameters, `:=` for defaults:
9//!
10//! ```text
11//! ---
12//! name: my_template
13//! params:
14//!   - name = str
15//!   - count = int := 42
16//!   - tasks = list(title = str, priority = int)
17//! ---
18//! ```
19
20mod imports;
21mod params;
22mod type_aliases;
23mod validation;
24
25use alloc::{
26    string::{String, ToString},
27    vec::Vec,
28};
29#[cfg(feature = "std")]
30use std::path::PathBuf;
31
32pub use imports::*;
33pub use params::parse_type_annotation;
34pub(crate) use params::*;
35pub(crate) use type_aliases::*;
36pub(crate) use validation::*;
37
38use crate::{
39    compat::HashMap,
40    consts::{
41        FM_ALLOW_UNUSED_PREFIX, FM_CONSTS_PREFIX, FM_DELIMITER, FM_DELIMITER_NEWLINE,
42        FM_DESC_PREFIX, FM_ENV_PREFIX, FM_IMPORTS_PREFIX, FM_NAME_PREFIX, FM_PARAMS_PREFIX,
43        FM_TYPES_PREFIX,
44    },
45    error::TemplateError,
46    frontmatter::params::parse_declarations,
47    types::{VarDecl, VarType},
48};
49
50/// A template import declaration: `[stem](path.tmpl.md)`.
51#[derive(Debug, Clone)]
52pub struct Import {
53    /// Short alias used as namespace prefix, e.g. `other`.
54    pub stem: String,
55    /// Relative path to the imported template file.
56    #[cfg(feature = "std")]
57    pub path: PathBuf,
58    /// Relative path as a string (always available).
59    #[cfg(not(feature = "std"))]
60    pub path: alloc::string::String,
61}
62
63/// Resolved namespace from an imported template.
64#[derive(Debug, Clone, Default)]
65pub struct ImportedNamespace {
66    /// Type aliases exported by the imported template.
67    pub type_aliases: HashMap<String, VarType>,
68    /// Parameter types (for cross-template type references).
69    pub param_types: HashMap<String, VarType>,
70    /// Constants exported by the imported template.
71    pub consts: HashMap<String, crate::value::Value>,
72    /// Type declarations for each exported constant.
73    ///
74    /// Enables the type checker to resolve field accesses on imported
75    /// consts (e.g. `{% for row in lib.ITEMS %}{{ row.name }}{% /for %}`)
76    /// instead of treating the import stem as opaque.
77    pub const_types: HashMap<String, VarType>,
78}
79
80/// Parsed YAML frontmatter from a `.tmpl.md` file.
81#[derive(Debug, Clone, Default)]
82pub struct Frontmatter {
83    /// Template name (matches SKILL.md `name:` convention).
84    pub name: Option<String>,
85    /// Description of the template's purpose.
86    pub description: Option<String>,
87    /// List of expected variable declarations (name + type + optional default).
88    pub declarations: Vec<VarDecl>,
89    /// Convenience: parameter names only (derived from `declarations`).
90    pub params: Vec<String>,
91    /// Whether the params: block was present in frontmatter.
92    pub has_params: bool,
93    /// Allow declared parameters that are never referenced in the body.
94    ///
95    /// Set via `allow_unused: true` in frontmatter. Useful for
96    /// dynamically-loaded templates where params may be conditionally used.
97    pub allow_unused: bool,
98    /// Type aliases defined via `types:` in frontmatter.
99    ///
100    /// Maps alias names (e.g. `Priority`) to their resolved [`VarType`].
101    pub type_aliases: HashMap<String, VarType>,
102    /// Import declarations defined via `imports:` in frontmatter.
103    pub imports: Vec<Import>,
104    /// Constants defined via `consts:` in frontmatter.
105    pub consts: Vec<VarDecl>,
106    /// Compile-time environment variable declarations.
107    /// Provided via `CompileOptions::env()` at compile time.
108    pub env: Vec<VarDecl>,
109    /// Resolved constants from imports, keyed by `stem.NAME`.
110    pub imported_consts: HashMap<String, crate::value::Value>,
111    /// Keys in `imported_consts` that are enum type namespace dicts
112    /// (injected from imported enum type aliases). Used by the bare-enum-access
113    /// check to distinguish enum namespaces from struct constants.
114    pub imported_enum_type_keys: Vec<String>,
115    /// Type information for imported namespaces, keyed by import stem.
116    ///
117    /// Each entry maps a stem name (e.g. `"artist"`) to a `Struct` type
118    /// whose fields correspond to the imported template's typed consts.
119    /// Used by the type checker to validate field accesses and for-loop
120    /// iteration over imported consts.
121    pub imported_namespace_types: HashMap<String, VarType>,
122    /// For params whose top-level type is a dotted import reference resolving
123    /// to an enum (`stem.TypeName`), maps the param name to
124    /// `(import_stem, imported_type_name)`.
125    ///
126    /// Lets codegen backends (currently the Rust proc-macro) reference the
127    /// imported, already-generated type directly instead of emitting a
128    /// duplicate per-template copy. Empty when no such params exist.
129    pub imported_type_params: HashMap<String, (String, String)>,
130}
131
132impl Frontmatter {
133    /// Validate enum-variant names and field accesses across a template body,
134    /// using the full set of typed declarations derived from this frontmatter.
135    ///
136    /// This is the single source of truth for compile-time field-type checking,
137    /// shared by the `template!` proc-macro and the shared cross-backend test
138    /// runners, so their behavior can never drift apart.
139    ///
140    /// Declarations included (all receive full field-level validation):
141    /// - `params:` declarations,
142    /// - typed import stems (imports whose consts carry type info) — this is
143    ///   what enables `{% for row in stem.LIST_CONST %}{{ row.field }}{% /for %}`,
144    /// - local `consts:`,
145    /// - `env:` variables.
146    ///
147    /// Import stems lacking const type info remain opaque (valid but untyped).
148    ///
149    /// Returns a list of human-readable error strings (empty when valid).
150    #[must_use]
151    pub fn validate_field_types(&self, segments: &[crate::compiled::Segment]) -> Vec<String> {
152        let mut opaque_roots: crate::compat::HashSet<String> = crate::compat::HashSet::new();
153        let mut declarations: Vec<VarDecl> = self.declarations.clone();
154
155        for import in &self.imports {
156            // Always register the stem as an opaque root. Includes build their
157            // child `TypeEnv` from the included template's own declarations and
158            // inherit the parent's `opaque_roots` — but NOT the parent's typed
159            // namespace `vars`. Without the stem in `opaque_roots`, an included
160            // body referencing `stem.CONST` (e.g. `artist.SEVERITY_LADDER`)
161            // would be spuriously flagged as an undeclared variable.
162            opaque_roots.insert(import.stem.clone());
163            // When the import additionally carries typed const info, register a
164            // typed declaration so field access and for-loop element types
165            // resolve precisely at this level. `lookup()` (vars) takes
166            // precedence over `opaque_roots`, so top-level field/typo checking
167            // is unaffected; the opaque entry is only the fallback consulted
168            // inside includes (where the typed `vars` are not in scope).
169            if let Some(ns_type) = self.imported_namespace_types.get(&import.stem) {
170                declarations.push(VarDecl {
171                    name: import.stem.clone(),
172                    var_type: ns_type.clone(),
173                    default_value: None,
174                });
175            }
176        }
177        for c in &self.consts {
178            declarations.push(c.clone());
179        }
180        for e in &self.env {
181            declarations.push(e.clone());
182        }
183
184        crate::compiled::validate_field_accesses_full(
185            segments,
186            &declarations,
187            &self.type_aliases,
188            &opaque_roots,
189        )
190    }
191
192    /// Validate field types, built-in function argument types, and match
193    /// exhaustiveness/narrowing for runtime templates (`Template::from_source`).
194    ///
195    /// Identical to [`Self::validate_field_types`], except static displayability
196    /// checks (`{{ struct }}`, `{{ list }}`, `{{ option }}`) are deferred to
197    /// render time.
198    #[must_use]
199    pub fn validate_runtime_types(&self, segments: &[crate::compiled::Segment]) -> Vec<String> {
200        let mut opaque_roots: crate::compat::HashSet<String> = crate::compat::HashSet::new();
201        let mut declarations: Vec<VarDecl> = self.declarations.clone();
202
203        for import in &self.imports {
204            opaque_roots.insert(import.stem.clone());
205            if let Some(ns_type) = self.imported_namespace_types.get(&import.stem) {
206                declarations.push(VarDecl {
207                    name: import.stem.clone(),
208                    var_type: ns_type.clone(),
209                    default_value: None,
210                });
211            }
212        }
213        for c in &self.consts {
214            declarations.push(c.clone());
215        }
216        for e in &self.env {
217            declarations.push(e.clone());
218        }
219
220        crate::compiled::validate_field_accesses_runtime(
221            segments,
222            &declarations,
223            &self.type_aliases,
224            &opaque_roots,
225        )
226    }
227}
228
229/// Strip YAML frontmatter delimited by `---` and return only the body text.
230///
231/// # Errors
232///
233/// Returns [`TemplateError::Syntax`] if the frontmatter block is missing or invalid.
234pub fn strip_frontmatter(source: &str) -> Result<&str, TemplateError> {
235    parse_frontmatter(source).map(|(_, body)| body)
236}
237
238/// Parse YAML frontmatter delimited by `---` lines.
239///
240/// Returns the parsed [`Frontmatter`] and a string slice pointing to the
241/// template body after the closing `---`.
242///
243/// # Errors
244///
245/// Returns [`TemplateError::Syntax`] if the frontmatter block is
246/// missing, unclosed, or contains invalid declarations.
247pub fn parse_frontmatter(source: &str) -> Result<(Frontmatter, &str), TemplateError> {
248    parse_frontmatter_impl(
249        source,
250        #[cfg(feature = "std")]
251        None,
252        None,
253        false,
254        &[],
255    )
256}
257
258/// Parse YAML frontmatter with compile-time environment values.
259///
260/// Like [`parse_frontmatter`], but resolves `env:` declarations against
261/// the provided name-value pairs.
262///
263/// # Errors
264///
265/// Returns [`TemplateError::Syntax`] if the frontmatter block is
266/// missing, unclosed, or contains invalid declarations, or if an
267/// `env:` variable has no value and no default.
268pub fn parse_frontmatter_with_env<'a>(
269    source: &'a str,
270    env_values: &[(&str, crate::value::Value)],
271) -> Result<(Frontmatter, &'a str), TemplateError> {
272    parse_frontmatter_impl(
273        source,
274        #[cfg(feature = "std")]
275        None,
276        None,
277        false,
278        env_values,
279    )
280}
281
282/// Parse YAML frontmatter with cross-template import resolution.
283///
284/// Like [`parse_frontmatter`], but additionally resolves `imports:` entries
285/// by reading referenced template files from disk relative to `base_dir`.
286/// This allows params to reference imported types (e.g. `types.Severity`).
287///
288/// # Errors
289///
290/// Returns [`TemplateError::Syntax`] if the frontmatter block is invalid,
291/// an imported file cannot be read, or imported types cannot be resolved.
292#[cfg(feature = "std")]
293pub fn parse_frontmatter_with_base_dir<'a>(
294    source: &'a str,
295    base_dir: &std::path::Path,
296    env_values: &[(&str, crate::value::Value)],
297) -> Result<(Frontmatter, &'a str), TemplateError> {
298    parse_frontmatter_impl(source, Some(base_dir), None, false, env_values)
299}
300
301/// Parse YAML frontmatter with access to a parent template's type aliases.
302///
303/// Used for inline template definitions (`{% tmpl %}`) that can reference
304/// type aliases from the enclosing template.
305pub fn parse_frontmatter_with_parent_scope<'a>(
306    source: &'a str,
307    parent_type_aliases: &HashMap<String, VarType>,
308) -> Result<(Frontmatter, &'a str), TemplateError> {
309    parse_frontmatter_impl(
310        source,
311        #[cfg(feature = "std")]
312        None,
313        Some(parent_type_aliases),
314        true,
315        &[],
316    )
317}
318
319fn extract_yaml_logical_lines(
320    source: &str,
321    allow_missing_fm: bool,
322) -> Result<(Vec<String>, &str), TemplateError> {
323    let trimmed = source.trim_start();
324    if !trimmed.starts_with(FM_DELIMITER) {
325        if allow_missing_fm {
326            return Ok((Vec::new(), source));
327        }
328        return Err(TemplateError::syntax(
329            crate::consts::ERR_MISSING_FM.to_string(),
330        ));
331    }
332
333    let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
334    // An empty block: the closing delimiter follows the opener immediately
335    // (its separator newlines were consumed above), so the `\n---` search
336    // below cannot see it and would misreport the block as unclosed.
337    let (yaml_block, after_close) = if after_first.starts_with(FM_DELIMITER)
338        && matches!(
339            after_first.as_bytes().get(FM_DELIMITER.len()),
340            None | Some(b'\n' | b'\r')
341        ) {
342        ("", FM_DELIMITER.len())
343    } else {
344        let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
345            return Err(TemplateError::syntax(
346                crate::consts::ERR_UNCLOSED_FM.to_string(),
347            ));
348        };
349        (&after_first[..end], end + FM_DELIMITER_NEWLINE.len())
350    };
351    let body_start = if after_first[after_close..].starts_with('\n') {
352        after_close + 1
353    } else if after_first[after_close..].starts_with("\r\n") {
354        after_close + 2
355    } else {
356        after_close
357    };
358    let body = &after_first[body_start..];
359
360    let mut in_block_list = false;
361    let mut had_blank_line = true;
362    for line in yaml_block.lines() {
363        let trimmed = line.trim();
364        if trimmed.is_empty() {
365            had_blank_line = true;
366            continue;
367        }
368        let starts_with_section = line.starts_with(FM_NAME_PREFIX)
369            || line.starts_with(FM_DESC_PREFIX)
370            || line.starts_with(FM_TYPES_PREFIX)
371            || line.starts_with(FM_IMPORTS_PREFIX)
372            || line.starts_with(FM_PARAMS_PREFIX)
373            || line.starts_with(FM_CONSTS_PREFIX)
374            || line.starts_with(FM_ENV_PREFIX)
375            || line.starts_with(FM_ALLOW_UNUSED_PREFIX);
376
377        if starts_with_section {
378            if in_block_list && !had_blank_line {
379                return Err(TemplateError::syntax(format!(
380                    "A blank line is required after a block list before '{trimmed}' so raw markdown renders correctly"
381                )));
382            }
383            in_block_list = false;
384        } else if trimmed.starts_with('-') {
385            in_block_list = true;
386        }
387        had_blank_line = false;
388    }
389
390    Ok((join_continuation_lines(yaml_block), body))
391}
392
393type FmResolutionResult = Result<
394    (
395        HashMap<String, VarType>,
396        HashMap<String, ImportedNamespace>,
397        HashMap<String, crate::value::Value>,
398    ),
399    TemplateError,
400>;
401
402/// Validate and coerce a provided [`Value`](crate::value::Value) to match the declared type.
403///
404/// If the value is already the correct type, it is returned as-is.
405/// If the value is `Value::Str` but the declared type is a scalar
406/// (int, bool, float), the string is auto-parsed for convenience.
407fn validate_env_value(
408    name: &str,
409    value: &crate::value::Value,
410    var_type: &VarType,
411) -> Result<crate::value::Value, TemplateError> {
412    use crate::value::Value;
413    match (value, var_type) {
414        // String auto-parse for scalar types.
415        (Value::Str(raw), VarType::Int) => raw
416            .parse::<i64>()
417            .map(Value::Int)
418            .map_err(|_| TemplateError::syntax(format!("env '{name}': expected int, got '{raw}'"))),
419        (Value::Str(raw), VarType::Bool) => match raw.as_str() {
420            crate::consts::LIT_TRUE => Ok(Value::Bool(true)),
421            crate::consts::LIT_FALSE => Ok(Value::Bool(false)),
422            _ => Err(TemplateError::syntax(format!(
423                "env '{name}': expected bool, got '{raw}'"
424            ))),
425        },
426        (Value::Str(raw), VarType::Float) => raw.parse::<f64>().map(Value::Float).map_err(|_| {
427            TemplateError::syntax(format!("env '{name}': expected float, got '{raw}'"))
428        }),
429        // Direct type matches and unknown combos: accept as-is.
430        // The template engine validates at render time via type declarations.
431        _ => Ok(value.clone()),
432    }
433}
434
435fn resolve_fm_consts_and_imports(
436    fm: &mut Frontmatter,
437    consts_raw: Option<&str>,
438    env_raw: Option<&str>,
439    env_values: &[(&str, crate::value::Value)],
440    parent_type_aliases: Option<&HashMap<String, VarType>>,
441    #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
442) -> FmResolutionResult {
443    let mut merged_aliases = if let Some(parent_aliases) = parent_type_aliases {
444        parent_aliases.clone()
445    } else {
446        HashMap::new()
447    };
448    for (k, v) in &fm.type_aliases {
449        merged_aliases.insert(k.clone(), v.clone());
450    }
451
452    let mut prelim_consts = HashMap::new();
453    let empty_imports = HashMap::new();
454    let empty_consts = HashMap::new();
455
456    // Resolve env declarations first so they're available for import path interpolation.
457    if let Some(raw) = env_raw {
458        let (mut env_decls, _) =
459            parse_declarations(raw, &merged_aliases, &empty_imports, false, &empty_consts)?;
460        for decl in &mut env_decls {
461            // Look up in provided env_values.
462            if let Some((_, provided_val)) = env_values.iter().find(|(k, _)| *k == decl.name) {
463                let val = validate_env_value(&decl.name, provided_val, &decl.var_type)?;
464                prelim_consts.insert(decl.name.clone(), val.clone());
465                decl.default_value = Some(val);
466            } else if let Some(ref default) = decl.default_value {
467                prelim_consts.insert(decl.name.clone(), default.clone());
468            } else {
469                return Err(TemplateError::syntax(format!(
470                    "env '{}': no value provided and no default",
471                    decl.name
472                )));
473            }
474        }
475        fm.env = env_decls;
476    }
477
478    if let Some(raw) = consts_raw {
479        // NOLINT: const parsing failure here is non-fatal — full validation catches errors later
480        if let Ok((decls, _)) =
481            parse_declarations(raw, &merged_aliases, &empty_imports, true, &prelim_consts)
482        {
483            let const_map = build_available_consts(&decls, &HashMap::new());
484            for (k, v) in const_map {
485                prelim_consts.insert(k, v);
486            }
487        }
488    }
489
490    #[cfg(feature = "std")]
491    let resolved_imports = if let Some(dir) = base_dir {
492        if fm.imports.is_empty() {
493            HashMap::new()
494        } else {
495            let mut visited = std::collections::HashSet::new();
496            resolve_imports_with_consts(&mut fm.imports, dir, &mut visited, &prelim_consts)?
497        }
498    } else {
499        if !fm.imports.is_empty() {
500            interpolate_imports(&mut fm.imports, &prelim_consts)?;
501        }
502        HashMap::new()
503    };
504
505    #[cfg(not(feature = "std"))]
506    let resolved_imports = {
507        if !fm.imports.is_empty() {
508            interpolate_imports(&mut fm.imports, &prelim_consts)?;
509        }
510        HashMap::new()
511    };
512
513    #[cfg(feature = "std")]
514    inject_imported_consts(fm, &resolved_imports);
515
516    if let Some(raw) = consts_raw {
517        fm.consts = parse_declarations(
518            raw,
519            &merged_aliases,
520            &resolved_imports,
521            true,
522            &prelim_consts,
523        )?
524        .0;
525    }
526
527    let mut available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
528    // Merge env values into available_consts so params can reference them.
529    for decl in &fm.env {
530        if let Some(val) = prelim_consts.get(&decl.name) {
531            available_consts
532                .entry(decl.name.clone())
533                .or_insert_with(|| val.clone());
534        }
535    }
536    Ok((merged_aliases, resolved_imports, available_consts))
537}
538
539fn parse_frontmatter_impl<'a>(
540    source: &'a str,
541    #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
542    parent_type_aliases: Option<&HashMap<String, VarType>>,
543    allow_missing_fm: bool,
544    env_values: &[(&str, crate::value::Value)],
545) -> Result<(Frontmatter, &'a str), TemplateError> {
546    let (logical_lines, body) = extract_yaml_logical_lines(source, allow_missing_fm)?;
547    if logical_lines.is_empty()
548        && allow_missing_fm
549        && !source.trim_start().starts_with(FM_DELIMITER)
550    {
551        return Ok((Frontmatter::default(), body));
552    }
553
554    let mut fm = Frontmatter::default();
555    let mut params_raw: Option<String> = None;
556    let mut consts_raw: Option<String> = None;
557    let mut env_raw: Option<String> = None;
558
559    for line in &logical_lines {
560        let line = line.trim();
561        if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
562            fm.name = Some(rest.trim().to_string());
563        } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
564            fm.description = Some(rest.trim().to_string());
565        } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
566            fm.type_aliases = parse_types_value(rest)?;
567        } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
568            fm.imports = parse_imports_value(rest)?;
569        } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
570            params_raw = Some(rest.to_string());
571        } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
572            consts_raw = Some(rest.to_string());
573        } else if let Some(rest) = line.strip_prefix(FM_ENV_PREFIX) {
574            env_raw = Some(rest.to_string());
575        } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
576            fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
577        }
578    }
579
580    let (merged_aliases, resolved_imports, available_consts) = resolve_fm_consts_and_imports(
581        &mut fm,
582        consts_raw.as_deref(),
583        env_raw.as_deref(),
584        env_values,
585        parent_type_aliases,
586        #[cfg(feature = "std")]
587        base_dir,
588    )?;
589
590    if let Some(raw) = params_raw {
591        let (decls, import_refs) = parse_declarations(
592            &raw,
593            &merged_aliases,
594            &resolved_imports,
595            false,
596            &available_consts,
597        )?;
598        fm.params = decls.iter().map(|d| d.name.clone()).collect();
599        fm.declarations = decls;
600        fm.has_params = true;
601        fm.imported_type_params = import_refs;
602    }
603
604    validate_collision_rules(&fm)?;
605    add_implicit_param_types(&mut fm);
606
607    Ok((fm, body))
608}
609
610/// Inject imported constants and enum type namespace dicts into `fm`.
611///
612/// For each import namespace, copies over user-defined constants and
613/// synthesizes enum type namespace dicts so that `{{ lib.EnumType.Variant }}`
614/// expressions work.
615#[cfg(feature = "std")]
616fn inject_imported_consts(
617    fm: &mut Frontmatter,
618    resolved_imports: &HashMap<String, ImportedNamespace>,
619) {
620    for (stem, ns) in resolved_imports {
621        for (name, val) in &ns.consts {
622            fm.imported_consts
623                .insert(format!("{stem}.{name}"), val.clone());
624        }
625        // Inject enum type aliases from the imported namespace as constants,
626        // enabling `{{ lib.EnumType.Variant }}` expressions.
627        for (type_name, var_type) in &ns.type_aliases {
628            let VarType::Enum(variants) = var_type else {
629                continue;
630            };
631            let key = format!("{stem}.{type_name}");
632            // Don't overwrite a user-defined constant with the same name.
633            if fm.imported_consts.contains_key(&key) {
634                continue;
635            }
636            let mut variant_map = HashMap::new();
637            let mut variant_names = Vec::with_capacity(variants.len());
638            for variant in variants {
639                variant_names.push(crate::value::Value::Str(variant.name.clone()));
640                if variant.fields.is_empty() {
641                    variant_map.insert(
642                        variant.name.clone(),
643                        crate::value::Value::Str(variant.name.clone()),
644                    );
645                } else {
646                    let mut partial = HashMap::new();
647                    partial.insert(
648                        crate::consts::ENUM_TAG_KEY.into(),
649                        crate::value::Value::Str(variant.name.clone()),
650                    );
651                    variant_map.insert(
652                        variant.name.clone(),
653                        crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
654                    );
655                }
656            }
657            variant_map.insert(
658                crate::consts::ENUM_VARIANTS_KEY.into(),
659                crate::value::Value::List(alloc::sync::Arc::new(variant_names)),
660            );
661            fm.imported_consts.insert(
662                key.clone(),
663                crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
664            );
665            fm.imported_enum_type_keys.push(key);
666        }
667        // Build a typed namespace struct so the type checker can resolve paths
668        // through the import stem (e.g. `artist.SEVERITY_LADDER`) instead of
669        // treating it as opaque.
670        //
671        // The struct must model *every* name reachable via the stem — not just
672        // consts — otherwise typing the stem would break access to imported
673        // types/enums (e.g. `kinds(lib.Priority)` or `lib.Status.Paused`).
674        // We therefore include type aliases and implicit param types alongside
675        // const types. Const types take precedence on any name collision.
676        //
677        // Scoped to imports that actually export typed consts: type-only imports
678        // remain opaque, preserving their established resolution behavior.
679        if !ns.const_types.is_empty() {
680            let mut field_types: HashMap<String, VarType> = HashMap::new();
681            for (name, var_type) in &ns.param_types {
682                field_types.insert(name.clone(), var_type.clone());
683            }
684            for (name, var_type) in &ns.type_aliases {
685                field_types.insert(name.clone(), var_type.clone());
686            }
687            for (name, var_type) in &ns.const_types {
688                field_types.insert(name.clone(), var_type.clone());
689            }
690            let fields: Vec<VarDecl> = field_types
691                .into_iter()
692                .map(|(name, var_type)| VarDecl {
693                    name,
694                    var_type,
695                    default_value: None,
696                })
697                .collect();
698            fm.imported_namespace_types
699                .insert(stem.clone(), VarType::Struct(fields));
700        }
701    }
702}
703
704/// Build a lookup map of available constants for use as param default values.
705///
706/// Merges local constants (from `consts:` declarations) with imported constants
707/// (from `imports:`) into a single flat map. Local consts are keyed by their
708/// bare name (e.g. `MAX`), imported consts are already keyed by `stem.NAME`
709/// (e.g. `lib.LIMIT`).
710fn build_available_consts(
711    consts: &[crate::types::VarDecl],
712    imported_consts: &HashMap<String, crate::value::Value>,
713) -> HashMap<String, crate::value::Value> {
714    let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
715    // Add local consts.
716    for d in consts {
717        if let Some(ref v) = d.default_value {
718            available.insert(d.name.clone(), v.clone());
719        }
720    }
721    // Add imported consts (stem.NAME keys).
722    for (k, v) in imported_consts {
723        available.insert(k.clone(), v.clone());
724    }
725    available
726}
727
728#[cfg(test)]
729mod tests;