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