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
193/// Strip YAML frontmatter delimited by `---` and return only the body text.
194///
195/// # Errors
196///
197/// Returns [`TemplateError::Syntax`] if the frontmatter block is missing or invalid.
198pub fn strip_frontmatter(source: &str) -> Result<&str, TemplateError> {
199    parse_frontmatter(source).map(|(_, body)| body)
200}
201
202/// Parse YAML frontmatter delimited by `---` lines.
203///
204/// Returns the parsed [`Frontmatter`] and a string slice pointing to the
205/// template body after the closing `---`.
206///
207/// # Errors
208///
209/// Returns [`TemplateError::Syntax`] if the frontmatter block is
210/// missing, unclosed, or contains invalid declarations.
211pub fn parse_frontmatter(source: &str) -> Result<(Frontmatter, &str), TemplateError> {
212    parse_frontmatter_impl(
213        source,
214        #[cfg(feature = "std")]
215        None,
216        None,
217        false,
218        &[],
219    )
220}
221
222/// Parse YAML frontmatter with compile-time environment values.
223///
224/// Like [`parse_frontmatter`], but resolves `env:` declarations against
225/// the provided name-value pairs.
226///
227/// # Errors
228///
229/// Returns [`TemplateError::Syntax`] if the frontmatter block is
230/// missing, unclosed, or contains invalid declarations, or if an
231/// `env:` variable has no value and no default.
232pub fn parse_frontmatter_with_env<'a>(
233    source: &'a str,
234    env_values: &[(&str, crate::value::Value)],
235) -> Result<(Frontmatter, &'a str), TemplateError> {
236    parse_frontmatter_impl(
237        source,
238        #[cfg(feature = "std")]
239        None,
240        None,
241        false,
242        env_values,
243    )
244}
245
246/// Parse YAML frontmatter with cross-template import resolution.
247///
248/// Like [`parse_frontmatter`], but additionally resolves `imports:` entries
249/// by reading referenced template files from disk relative to `base_dir`.
250/// This allows params to reference imported types (e.g. `types.Severity`).
251///
252/// # Errors
253///
254/// Returns [`TemplateError::Syntax`] if the frontmatter block is invalid,
255/// an imported file cannot be read, or imported types cannot be resolved.
256#[cfg(feature = "std")]
257pub fn parse_frontmatter_with_base_dir<'a>(
258    source: &'a str,
259    base_dir: &std::path::Path,
260    env_values: &[(&str, crate::value::Value)],
261) -> Result<(Frontmatter, &'a str), TemplateError> {
262    parse_frontmatter_impl(source, Some(base_dir), None, false, env_values)
263}
264
265/// Parse YAML frontmatter with access to a parent template's type aliases.
266///
267/// Used for inline template definitions (`{% tmpl %}`) that can reference
268/// type aliases from the enclosing template.
269pub fn parse_frontmatter_with_parent_scope<'a>(
270    source: &'a str,
271    parent_type_aliases: &HashMap<String, VarType>,
272) -> Result<(Frontmatter, &'a str), TemplateError> {
273    parse_frontmatter_impl(
274        source,
275        #[cfg(feature = "std")]
276        None,
277        Some(parent_type_aliases),
278        true,
279        &[],
280    )
281}
282
283fn extract_yaml_logical_lines(
284    source: &str,
285    allow_missing_fm: bool,
286) -> Result<(Vec<String>, &str), TemplateError> {
287    let trimmed = source.trim_start();
288    if !trimmed.starts_with(FM_DELIMITER) {
289        if allow_missing_fm {
290            return Ok((Vec::new(), source));
291        }
292        return Err(TemplateError::syntax(
293            crate::consts::ERR_MISSING_FM.to_string(),
294        ));
295    }
296
297    let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
298    // An empty block: the closing delimiter follows the opener immediately
299    // (its separator newlines were consumed above), so the `\n---` search
300    // below cannot see it and would misreport the block as unclosed.
301    let (yaml_block, after_close) = if after_first.starts_with(FM_DELIMITER)
302        && matches!(
303            after_first.as_bytes().get(FM_DELIMITER.len()),
304            None | Some(b'\n' | b'\r')
305        ) {
306        ("", FM_DELIMITER.len())
307    } else {
308        let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
309            return Err(TemplateError::syntax(
310                crate::consts::ERR_UNCLOSED_FM.to_string(),
311            ));
312        };
313        (&after_first[..end], end + FM_DELIMITER_NEWLINE.len())
314    };
315    let body_start = if after_first[after_close..].starts_with('\n') {
316        after_close + 1
317    } else if after_first[after_close..].starts_with("\r\n") {
318        after_close + 2
319    } else {
320        after_close
321    };
322    let body = &after_first[body_start..];
323
324    let mut in_block_list = false;
325    let mut had_blank_line = true;
326    for line in yaml_block.lines() {
327        let trimmed = line.trim();
328        if trimmed.is_empty() {
329            had_blank_line = true;
330            continue;
331        }
332        let starts_with_section = line.starts_with(FM_NAME_PREFIX)
333            || line.starts_with(FM_DESC_PREFIX)
334            || line.starts_with(FM_TYPES_PREFIX)
335            || line.starts_with(FM_IMPORTS_PREFIX)
336            || line.starts_with(FM_PARAMS_PREFIX)
337            || line.starts_with(FM_CONSTS_PREFIX)
338            || line.starts_with(FM_ENV_PREFIX)
339            || line.starts_with(FM_ALLOW_UNUSED_PREFIX);
340
341        if starts_with_section {
342            if in_block_list && !had_blank_line {
343                return Err(TemplateError::syntax(format!(
344                    "A blank line is required after a block list before '{trimmed}' so raw markdown renders correctly"
345                )));
346            }
347            in_block_list = false;
348        } else if trimmed.starts_with('-') {
349            in_block_list = true;
350        }
351        had_blank_line = false;
352    }
353
354    Ok((join_continuation_lines(yaml_block), body))
355}
356
357type FmResolutionResult = Result<
358    (
359        HashMap<String, VarType>,
360        HashMap<String, ImportedNamespace>,
361        HashMap<String, crate::value::Value>,
362    ),
363    TemplateError,
364>;
365
366/// Validate and coerce a provided [`Value`](crate::value::Value) to match the declared type.
367///
368/// If the value is already the correct type, it is returned as-is.
369/// If the value is `Value::Str` but the declared type is a scalar
370/// (int, bool, float), the string is auto-parsed for convenience.
371fn validate_env_value(
372    name: &str,
373    value: &crate::value::Value,
374    var_type: &VarType,
375) -> Result<crate::value::Value, TemplateError> {
376    use crate::value::Value;
377    match (value, var_type) {
378        // String auto-parse for scalar types.
379        (Value::Str(raw), VarType::Int) => raw
380            .parse::<i64>()
381            .map(Value::Int)
382            .map_err(|_| TemplateError::syntax(format!("env '{name}': expected int, got '{raw}'"))),
383        (Value::Str(raw), VarType::Bool) => match raw.as_str() {
384            crate::consts::LIT_TRUE => Ok(Value::Bool(true)),
385            crate::consts::LIT_FALSE => Ok(Value::Bool(false)),
386            _ => Err(TemplateError::syntax(format!(
387                "env '{name}': expected bool, got '{raw}'"
388            ))),
389        },
390        (Value::Str(raw), VarType::Float) => raw.parse::<f64>().map(Value::Float).map_err(|_| {
391            TemplateError::syntax(format!("env '{name}': expected float, got '{raw}'"))
392        }),
393        // Direct type matches and unknown combos: accept as-is.
394        // The template engine validates at render time via type declarations.
395        _ => Ok(value.clone()),
396    }
397}
398
399fn resolve_fm_consts_and_imports(
400    fm: &mut Frontmatter,
401    consts_raw: Option<&str>,
402    env_raw: Option<&str>,
403    env_values: &[(&str, crate::value::Value)],
404    parent_type_aliases: Option<&HashMap<String, VarType>>,
405    #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
406) -> FmResolutionResult {
407    let mut merged_aliases = if let Some(parent_aliases) = parent_type_aliases {
408        parent_aliases.clone()
409    } else {
410        HashMap::new()
411    };
412    for (k, v) in &fm.type_aliases {
413        merged_aliases.insert(k.clone(), v.clone());
414    }
415
416    let mut prelim_consts = HashMap::new();
417    let empty_imports = HashMap::new();
418    let empty_consts = HashMap::new();
419
420    // Resolve env declarations first so they're available for import path interpolation.
421    if let Some(raw) = env_raw {
422        let (mut env_decls, _) =
423            parse_declarations(raw, &merged_aliases, &empty_imports, false, &empty_consts)?;
424        for decl in &mut env_decls {
425            // Look up in provided env_values.
426            if let Some((_, provided_val)) = env_values.iter().find(|(k, _)| *k == decl.name) {
427                let val = validate_env_value(&decl.name, provided_val, &decl.var_type)?;
428                prelim_consts.insert(decl.name.clone(), val.clone());
429                decl.default_value = Some(val);
430            } else if let Some(ref default) = decl.default_value {
431                prelim_consts.insert(decl.name.clone(), default.clone());
432            } else {
433                return Err(TemplateError::syntax(format!(
434                    "env '{}': no value provided and no default",
435                    decl.name
436                )));
437            }
438        }
439        fm.env = env_decls;
440    }
441
442    if let Some(raw) = consts_raw {
443        // NOLINT: const parsing failure here is non-fatal — full validation catches errors later
444        if let Ok((decls, _)) =
445            parse_declarations(raw, &merged_aliases, &empty_imports, true, &prelim_consts)
446        {
447            let const_map = build_available_consts(&decls, &HashMap::new());
448            for (k, v) in const_map {
449                prelim_consts.insert(k, v);
450            }
451        }
452    }
453
454    #[cfg(feature = "std")]
455    let resolved_imports = if let Some(dir) = base_dir {
456        if fm.imports.is_empty() {
457            HashMap::new()
458        } else {
459            let mut visited = std::collections::HashSet::new();
460            resolve_imports_with_consts(&mut fm.imports, dir, &mut visited, &prelim_consts)?
461        }
462    } else {
463        if !fm.imports.is_empty() {
464            interpolate_imports(&mut fm.imports, &prelim_consts)?;
465        }
466        HashMap::new()
467    };
468
469    #[cfg(not(feature = "std"))]
470    let resolved_imports = {
471        if !fm.imports.is_empty() {
472            interpolate_imports(&mut fm.imports, &prelim_consts)?;
473        }
474        HashMap::new()
475    };
476
477    #[cfg(feature = "std")]
478    inject_imported_consts(fm, &resolved_imports);
479
480    if let Some(raw) = consts_raw {
481        fm.consts = parse_declarations(
482            raw,
483            &merged_aliases,
484            &resolved_imports,
485            true,
486            &prelim_consts,
487        )?
488        .0;
489    }
490
491    let mut available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
492    // Merge env values into available_consts so params can reference them.
493    for decl in &fm.env {
494        if let Some(val) = prelim_consts.get(&decl.name) {
495            available_consts
496                .entry(decl.name.clone())
497                .or_insert_with(|| val.clone());
498        }
499    }
500    Ok((merged_aliases, resolved_imports, available_consts))
501}
502
503fn parse_frontmatter_impl<'a>(
504    source: &'a str,
505    #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
506    parent_type_aliases: Option<&HashMap<String, VarType>>,
507    allow_missing_fm: bool,
508    env_values: &[(&str, crate::value::Value)],
509) -> Result<(Frontmatter, &'a str), TemplateError> {
510    let (logical_lines, body) = extract_yaml_logical_lines(source, allow_missing_fm)?;
511    if logical_lines.is_empty()
512        && allow_missing_fm
513        && !source.trim_start().starts_with(FM_DELIMITER)
514    {
515        return Ok((Frontmatter::default(), body));
516    }
517
518    let mut fm = Frontmatter::default();
519    let mut params_raw: Option<String> = None;
520    let mut consts_raw: Option<String> = None;
521    let mut env_raw: Option<String> = None;
522
523    for line in &logical_lines {
524        let line = line.trim();
525        if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
526            fm.name = Some(rest.trim().to_string());
527        } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
528            fm.description = Some(rest.trim().to_string());
529        } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
530            fm.type_aliases = parse_types_value(rest)?;
531        } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
532            fm.imports = parse_imports_value(rest)?;
533        } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
534            params_raw = Some(rest.to_string());
535        } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
536            consts_raw = Some(rest.to_string());
537        } else if let Some(rest) = line.strip_prefix(FM_ENV_PREFIX) {
538            env_raw = Some(rest.to_string());
539        } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
540            fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
541        }
542    }
543
544    let (merged_aliases, resolved_imports, available_consts) = resolve_fm_consts_and_imports(
545        &mut fm,
546        consts_raw.as_deref(),
547        env_raw.as_deref(),
548        env_values,
549        parent_type_aliases,
550        #[cfg(feature = "std")]
551        base_dir,
552    )?;
553
554    if let Some(raw) = params_raw {
555        let (decls, import_refs) = parse_declarations(
556            &raw,
557            &merged_aliases,
558            &resolved_imports,
559            false,
560            &available_consts,
561        )?;
562        fm.params = decls.iter().map(|d| d.name.clone()).collect();
563        fm.declarations = decls;
564        fm.has_params = true;
565        fm.imported_type_params = import_refs;
566    }
567
568    validate_collision_rules(&fm)?;
569    add_implicit_param_types(&mut fm);
570
571    Ok((fm, body))
572}
573
574/// Inject imported constants and enum type namespace dicts into `fm`.
575///
576/// For each import namespace, copies over user-defined constants and
577/// synthesizes enum type namespace dicts so that `{{ lib.EnumType.Variant }}`
578/// expressions work.
579#[cfg(feature = "std")]
580fn inject_imported_consts(
581    fm: &mut Frontmatter,
582    resolved_imports: &HashMap<String, ImportedNamespace>,
583) {
584    for (stem, ns) in resolved_imports {
585        for (name, val) in &ns.consts {
586            fm.imported_consts
587                .insert(format!("{stem}.{name}"), val.clone());
588        }
589        // Inject enum type aliases from the imported namespace as constants,
590        // enabling `{{ lib.EnumType.Variant }}` expressions.
591        for (type_name, var_type) in &ns.type_aliases {
592            let VarType::Enum(variants) = var_type else {
593                continue;
594            };
595            let key = format!("{stem}.{type_name}");
596            // Don't overwrite a user-defined constant with the same name.
597            if fm.imported_consts.contains_key(&key) {
598                continue;
599            }
600            let mut variant_map = HashMap::new();
601            let mut variant_names = Vec::with_capacity(variants.len());
602            for variant in variants {
603                variant_names.push(crate::value::Value::Str(variant.name.clone()));
604                if variant.fields.is_empty() {
605                    variant_map.insert(
606                        variant.name.clone(),
607                        crate::value::Value::Str(variant.name.clone()),
608                    );
609                } else {
610                    let mut partial = HashMap::new();
611                    partial.insert(
612                        crate::consts::ENUM_TAG_KEY.into(),
613                        crate::value::Value::Str(variant.name.clone()),
614                    );
615                    variant_map.insert(
616                        variant.name.clone(),
617                        crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
618                    );
619                }
620            }
621            variant_map.insert(
622                crate::consts::ENUM_VARIANTS_KEY.into(),
623                crate::value::Value::List(alloc::sync::Arc::new(variant_names)),
624            );
625            fm.imported_consts.insert(
626                key.clone(),
627                crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
628            );
629            fm.imported_enum_type_keys.push(key);
630        }
631        // Build a typed namespace struct so the type checker can resolve paths
632        // through the import stem (e.g. `artist.SEVERITY_LADDER`) instead of
633        // treating it as opaque.
634        //
635        // The struct must model *every* name reachable via the stem — not just
636        // consts — otherwise typing the stem would break access to imported
637        // types/enums (e.g. `kinds(lib.Priority)` or `lib.Status.Paused`).
638        // We therefore include type aliases and implicit param types alongside
639        // const types. Const types take precedence on any name collision.
640        //
641        // Scoped to imports that actually export typed consts: type-only imports
642        // remain opaque, preserving their established resolution behavior.
643        if !ns.const_types.is_empty() {
644            let mut field_types: HashMap<String, VarType> = HashMap::new();
645            for (name, var_type) in &ns.param_types {
646                field_types.insert(name.clone(), var_type.clone());
647            }
648            for (name, var_type) in &ns.type_aliases {
649                field_types.insert(name.clone(), var_type.clone());
650            }
651            for (name, var_type) in &ns.const_types {
652                field_types.insert(name.clone(), var_type.clone());
653            }
654            let fields: Vec<VarDecl> = field_types
655                .into_iter()
656                .map(|(name, var_type)| VarDecl {
657                    name,
658                    var_type,
659                    default_value: None,
660                })
661                .collect();
662            fm.imported_namespace_types
663                .insert(stem.clone(), VarType::Struct(fields));
664        }
665    }
666}
667
668/// Build a lookup map of available constants for use as param default values.
669///
670/// Merges local constants (from `consts:` declarations) with imported constants
671/// (from `imports:`) into a single flat map. Local consts are keyed by their
672/// bare name (e.g. `MAX`), imported consts are already keyed by `stem.NAME`
673/// (e.g. `lib.LIMIT`).
674fn build_available_consts(
675    consts: &[crate::types::VarDecl],
676    imported_consts: &HashMap<String, crate::value::Value>,
677) -> HashMap<String, crate::value::Value> {
678    let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
679    // Add local consts.
680    for d in consts {
681        if let Some(ref v) = d.default_value {
682            available.insert(d.name.clone(), v.clone());
683        }
684    }
685    // Add imported consts (stem.NAME keys).
686    for (k, v) in imported_consts {
687        available.insert(k.clone(), v.clone());
688    }
689    available
690}
691
692#[cfg(test)]
693mod tests;