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}
73
74/// Parsed YAML frontmatter from a `.tmpl.md` file.
75#[derive(Debug, Clone, Default)]
76pub struct Frontmatter {
77    /// Template name (matches SKILL.md `name:` convention).
78    pub name: Option<String>,
79    /// Description of the template's purpose.
80    pub description: Option<String>,
81    /// List of expected variable declarations (name + type + optional default).
82    pub declarations: Vec<VarDecl>,
83    /// Convenience: parameter names only (derived from `declarations`).
84    pub params: Vec<String>,
85    /// Whether the params: block was present in frontmatter.
86    pub has_params: bool,
87    /// Allow declared parameters that are never referenced in the body.
88    ///
89    /// Set via `allow_unused: true` in frontmatter. Useful for
90    /// dynamically-loaded templates where params may be conditionally used.
91    pub allow_unused: bool,
92    /// Type aliases defined via `types:` in frontmatter.
93    ///
94    /// Maps alias names (e.g. `Priority`) to their resolved [`VarType`].
95    pub type_aliases: HashMap<String, VarType>,
96    /// Import declarations defined via `imports:` in frontmatter.
97    pub imports: Vec<Import>,
98    /// Constants defined via `consts:` in frontmatter.
99    pub consts: Vec<VarDecl>,
100    /// Compile-time environment variable declarations.
101    /// Provided via `CompileOptions::env()` at compile time.
102    pub env: Vec<VarDecl>,
103    /// Resolved constants from imports, keyed by `stem.NAME`.
104    pub imported_consts: HashMap<String, crate::value::Value>,
105    /// Keys in `imported_consts` that are enum type namespace dicts
106    /// (injected from imported enum type aliases). Used by the bare-enum-access
107    /// check to distinguish enum namespaces from struct constants.
108    pub imported_enum_type_keys: Vec<String>,
109}
110
111/// Strip YAML frontmatter delimited by `---` and return only the body text.
112///
113/// # Errors
114///
115/// Returns [`TemplateError::Syntax`] if the frontmatter block is missing or invalid.
116pub fn strip_frontmatter(source: &str) -> Result<&str, TemplateError> {
117    parse_frontmatter(source).map(|(_, body)| body)
118}
119
120/// Parse YAML frontmatter delimited by `---` lines.
121///
122/// Returns the parsed [`Frontmatter`] and a string slice pointing to the
123/// template body after the closing `---`.
124///
125/// # Errors
126///
127/// Returns [`TemplateError::Syntax`] if the frontmatter block is
128/// missing, unclosed, or contains invalid declarations.
129pub fn parse_frontmatter(source: &str) -> Result<(Frontmatter, &str), TemplateError> {
130    parse_frontmatter_impl(
131        source,
132        #[cfg(feature = "std")]
133        None,
134        None,
135        false,
136        &[],
137    )
138}
139
140/// Parse YAML frontmatter with compile-time environment values.
141///
142/// Like [`parse_frontmatter`], but resolves `env:` declarations against
143/// the provided name-value pairs.
144///
145/// # Errors
146///
147/// Returns [`TemplateError::Syntax`] if the frontmatter block is
148/// missing, unclosed, or contains invalid declarations, or if an
149/// `env:` variable has no value and no default.
150pub fn parse_frontmatter_with_env<'a>(
151    source: &'a str,
152    env_values: &[(&str, crate::value::Value)],
153) -> Result<(Frontmatter, &'a str), TemplateError> {
154    parse_frontmatter_impl(
155        source,
156        #[cfg(feature = "std")]
157        None,
158        None,
159        false,
160        env_values,
161    )
162}
163
164/// Parse YAML frontmatter with cross-template import resolution.
165///
166/// Like [`parse_frontmatter`], but additionally resolves `imports:` entries
167/// by reading referenced template files from disk relative to `base_dir`.
168/// This allows params to reference imported types (e.g. `types.Severity`).
169///
170/// # Errors
171///
172/// Returns [`TemplateError::Syntax`] if the frontmatter block is invalid,
173/// an imported file cannot be read, or imported types cannot be resolved.
174#[cfg(feature = "std")]
175pub fn parse_frontmatter_with_base_dir<'a>(
176    source: &'a str,
177    base_dir: &std::path::Path,
178    env_values: &[(&str, crate::value::Value)],
179) -> Result<(Frontmatter, &'a str), TemplateError> {
180    parse_frontmatter_impl(source, Some(base_dir), None, false, env_values)
181}
182
183/// Parse YAML frontmatter with access to a parent template's type aliases.
184///
185/// Used for inline template definitions (`{% tmpl %}`) that can reference
186/// type aliases from the enclosing template.
187pub fn parse_frontmatter_with_parent_scope<'a>(
188    source: &'a str,
189    parent_type_aliases: &HashMap<String, VarType>,
190) -> Result<(Frontmatter, &'a str), TemplateError> {
191    parse_frontmatter_impl(
192        source,
193        #[cfg(feature = "std")]
194        None,
195        Some(parent_type_aliases),
196        true,
197        &[],
198    )
199}
200
201fn extract_yaml_logical_lines(
202    source: &str,
203    allow_missing_fm: bool,
204) -> Result<(Vec<String>, &str), TemplateError> {
205    let trimmed = source.trim_start();
206    if !trimmed.starts_with(FM_DELIMITER) {
207        if allow_missing_fm {
208            return Ok((Vec::new(), source));
209        }
210        return Err(TemplateError::syntax(
211            crate::consts::ERR_MISSING_FM.to_string(),
212        ));
213    }
214
215    let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
216    let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
217        return Err(TemplateError::syntax(
218            crate::consts::ERR_UNCLOSED_FM.to_string(),
219        ));
220    };
221
222    let yaml_block = &after_first[..end];
223    let after_close = end + FM_DELIMITER_NEWLINE.len();
224    let body_start = if after_first[after_close..].starts_with('\n') {
225        after_close + 1
226    } else if after_first[after_close..].starts_with("\r\n") {
227        after_close + 2
228    } else {
229        after_close
230    };
231    let body = &after_first[body_start..];
232
233    let mut in_block_list = false;
234    let mut had_blank_line = true;
235    for line in yaml_block.lines() {
236        let trimmed = line.trim();
237        if trimmed.is_empty() {
238            had_blank_line = true;
239            continue;
240        }
241        let starts_with_section = line.starts_with(FM_NAME_PREFIX)
242            || line.starts_with(FM_DESC_PREFIX)
243            || line.starts_with(FM_TYPES_PREFIX)
244            || line.starts_with(FM_IMPORTS_PREFIX)
245            || line.starts_with(FM_PARAMS_PREFIX)
246            || line.starts_with(FM_CONSTS_PREFIX)
247            || line.starts_with(FM_ENV_PREFIX)
248            || line.starts_with(FM_ALLOW_UNUSED_PREFIX);
249
250        if starts_with_section {
251            if in_block_list && !had_blank_line {
252                return Err(TemplateError::syntax(format!(
253                    "A blank line is required after a block list before '{trimmed}' so raw markdown renders correctly"
254                )));
255            }
256            in_block_list = false;
257        } else if trimmed.starts_with('-') {
258            in_block_list = true;
259        }
260        had_blank_line = false;
261    }
262
263    Ok((join_continuation_lines(yaml_block), body))
264}
265
266type FmResolutionResult = Result<
267    (
268        HashMap<String, VarType>,
269        HashMap<String, ImportedNamespace>,
270        HashMap<String, crate::value::Value>,
271    ),
272    TemplateError,
273>;
274
275/// Validate and coerce a provided [`Value`](crate::value::Value) to match the declared type.
276///
277/// If the value is already the correct type, it is returned as-is.
278/// If the value is `Value::Str` but the declared type is a scalar
279/// (int, bool, float), the string is auto-parsed for convenience.
280fn validate_env_value(
281    name: &str,
282    value: &crate::value::Value,
283    var_type: &VarType,
284) -> Result<crate::value::Value, TemplateError> {
285    use crate::value::Value;
286    match (value, var_type) {
287        // String auto-parse for scalar types.
288        (Value::Str(raw), VarType::Int) => raw
289            .parse::<i64>()
290            .map(Value::Int)
291            .map_err(|_| TemplateError::syntax(format!("env '{name}': expected int, got '{raw}'"))),
292        (Value::Str(raw), VarType::Bool) => match raw.as_str() {
293            "true" => Ok(Value::Bool(true)),
294            "false" => Ok(Value::Bool(false)),
295            _ => Err(TemplateError::syntax(format!(
296                "env '{name}': expected bool, got '{raw}'"
297            ))),
298        },
299        (Value::Str(raw), VarType::Float) => raw.parse::<f64>().map(Value::Float).map_err(|_| {
300            TemplateError::syntax(format!("env '{name}': expected float, got '{raw}'"))
301        }),
302        // Direct type matches and unknown combos: accept as-is.
303        // The template engine validates at render time via type declarations.
304        _ => Ok(value.clone()),
305    }
306}
307
308fn resolve_fm_consts_and_imports(
309    fm: &mut Frontmatter,
310    consts_raw: Option<&str>,
311    env_raw: Option<&str>,
312    env_values: &[(&str, crate::value::Value)],
313    parent_type_aliases: Option<&HashMap<String, VarType>>,
314    #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
315) -> FmResolutionResult {
316    let mut merged_aliases = if let Some(parent_aliases) = parent_type_aliases {
317        parent_aliases.clone()
318    } else {
319        HashMap::new()
320    };
321    for (k, v) in &fm.type_aliases {
322        merged_aliases.insert(k.clone(), v.clone());
323    }
324
325    let mut prelim_consts = HashMap::new();
326    let empty_imports = HashMap::new();
327    let empty_consts = HashMap::new();
328
329    // Resolve env declarations first so they're available for import path interpolation.
330    if let Some(raw) = env_raw {
331        let mut env_decls =
332            parse_declarations(raw, &merged_aliases, &empty_imports, false, &empty_consts)?;
333        for decl in &mut env_decls {
334            // Look up in provided env_values.
335            if let Some((_, provided_val)) = env_values.iter().find(|(k, _)| *k == decl.name) {
336                let val = validate_env_value(&decl.name, provided_val, &decl.var_type)?;
337                prelim_consts.insert(decl.name.clone(), val.clone());
338                decl.default_value = Some(val);
339            } else if let Some(ref default) = decl.default_value {
340                prelim_consts.insert(decl.name.clone(), default.clone());
341            } else {
342                return Err(TemplateError::syntax(format!(
343                    "env '{}': no value provided and no default",
344                    decl.name
345                )));
346            }
347        }
348        fm.env = env_decls;
349    }
350
351    if let Some(raw) = consts_raw {
352        // NOLINT: const parsing failure here is non-fatal — full validation catches errors later
353        if let Ok(decls) =
354            parse_declarations(raw, &merged_aliases, &empty_imports, true, &prelim_consts)
355        {
356            let const_map = build_available_consts(&decls, &HashMap::new());
357            for (k, v) in const_map {
358                prelim_consts.insert(k, v);
359            }
360        }
361    }
362
363    #[cfg(feature = "std")]
364    let resolved_imports = if let Some(dir) = base_dir {
365        if fm.imports.is_empty() {
366            HashMap::new()
367        } else {
368            let mut visited = std::collections::HashSet::new();
369            resolve_imports_with_consts(&mut fm.imports, dir, &mut visited, &prelim_consts)?
370        }
371    } else {
372        if !fm.imports.is_empty() {
373            interpolate_imports(&mut fm.imports, &prelim_consts)?;
374        }
375        HashMap::new()
376    };
377
378    #[cfg(not(feature = "std"))]
379    let resolved_imports = {
380        if !fm.imports.is_empty() {
381            interpolate_imports(&mut fm.imports, &prelim_consts)?;
382        }
383        HashMap::new()
384    };
385
386    #[cfg(feature = "std")]
387    inject_imported_consts(fm, &resolved_imports);
388
389    if let Some(raw) = consts_raw {
390        fm.consts = parse_declarations(
391            raw,
392            &merged_aliases,
393            &resolved_imports,
394            true,
395            &prelim_consts,
396        )?;
397    }
398
399    let mut available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
400    // Merge env values into available_consts so params can reference them.
401    for decl in &fm.env {
402        if let Some(val) = prelim_consts.get(&decl.name) {
403            available_consts
404                .entry(decl.name.clone())
405                .or_insert_with(|| val.clone());
406        }
407    }
408    Ok((merged_aliases, resolved_imports, available_consts))
409}
410
411fn parse_frontmatter_impl<'a>(
412    source: &'a str,
413    #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
414    parent_type_aliases: Option<&HashMap<String, VarType>>,
415    allow_missing_fm: bool,
416    env_values: &[(&str, crate::value::Value)],
417) -> Result<(Frontmatter, &'a str), TemplateError> {
418    let (logical_lines, body) = extract_yaml_logical_lines(source, allow_missing_fm)?;
419    if logical_lines.is_empty()
420        && allow_missing_fm
421        && !source.trim_start().starts_with(FM_DELIMITER)
422    {
423        return Ok((Frontmatter::default(), body));
424    }
425
426    let mut fm = Frontmatter::default();
427    let mut params_raw: Option<String> = None;
428    let mut consts_raw: Option<String> = None;
429    let mut env_raw: Option<String> = None;
430
431    for line in &logical_lines {
432        let line = line.trim();
433        if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
434            fm.name = Some(rest.trim().to_string());
435        } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
436            fm.description = Some(rest.trim().to_string());
437        } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
438            fm.type_aliases = parse_types_value(rest)?;
439        } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
440            fm.imports = parse_imports_value(rest)?;
441        } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
442            params_raw = Some(rest.to_string());
443        } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
444            consts_raw = Some(rest.to_string());
445        } else if let Some(rest) = line.strip_prefix(FM_ENV_PREFIX) {
446            env_raw = Some(rest.to_string());
447        } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
448            fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
449        }
450    }
451
452    let (merged_aliases, resolved_imports, available_consts) = resolve_fm_consts_and_imports(
453        &mut fm,
454        consts_raw.as_deref(),
455        env_raw.as_deref(),
456        env_values,
457        parent_type_aliases,
458        #[cfg(feature = "std")]
459        base_dir,
460    )?;
461
462    if let Some(raw) = params_raw {
463        let decls = parse_declarations(
464            &raw,
465            &merged_aliases,
466            &resolved_imports,
467            false,
468            &available_consts,
469        )?;
470        fm.params = decls.iter().map(|d| d.name.clone()).collect();
471        fm.declarations = decls;
472        fm.has_params = true;
473    }
474
475    validate_collision_rules(&fm)?;
476    add_implicit_param_types(&mut fm);
477
478    Ok((fm, body))
479}
480
481/// Inject imported constants and enum type namespace dicts into `fm`.
482///
483/// For each import namespace, copies over user-defined constants and
484/// synthesizes enum type namespace dicts so that `{{ lib.EnumType.Variant }}`
485/// expressions work.
486#[cfg(feature = "std")]
487fn inject_imported_consts(
488    fm: &mut Frontmatter,
489    resolved_imports: &HashMap<String, ImportedNamespace>,
490) {
491    for (stem, ns) in resolved_imports {
492        for (name, val) in &ns.consts {
493            fm.imported_consts
494                .insert(format!("{stem}.{name}"), val.clone());
495        }
496        // Inject enum type aliases from the imported namespace as constants,
497        // enabling `{{ lib.EnumType.Variant }}` expressions.
498        for (type_name, var_type) in &ns.type_aliases {
499            let VarType::Enum(variants) = var_type else {
500                continue;
501            };
502            let key = format!("{stem}.{type_name}");
503            // Don't overwrite a user-defined constant with the same name.
504            if fm.imported_consts.contains_key(&key) {
505                continue;
506            }
507            let mut variant_map = HashMap::new();
508            let mut variant_names = Vec::with_capacity(variants.len());
509            for variant in variants {
510                variant_names.push(crate::value::Value::Str(variant.name.clone()));
511                if variant.fields.is_empty() {
512                    variant_map.insert(
513                        variant.name.clone(),
514                        crate::value::Value::Str(variant.name.clone()),
515                    );
516                } else {
517                    let mut partial = HashMap::new();
518                    partial.insert(
519                        crate::consts::ENUM_TAG_KEY.into(),
520                        crate::value::Value::Str(variant.name.clone()),
521                    );
522                    variant_map.insert(
523                        variant.name.clone(),
524                        crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
525                    );
526                }
527            }
528            variant_map.insert(
529                crate::consts::ENUM_VARIANTS_KEY.into(),
530                crate::value::Value::List(alloc::sync::Arc::new(variant_names)),
531            );
532            fm.imported_consts.insert(
533                key.clone(),
534                crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
535            );
536            fm.imported_enum_type_keys.push(key);
537        }
538    }
539}
540
541/// Build a lookup map of available constants for use as param default values.
542///
543/// Merges local constants (from `consts:` declarations) with imported constants
544/// (from `imports:`) into a single flat map. Local consts are keyed by their
545/// bare name (e.g. `MAX`), imported consts are already keyed by `stem.NAME`
546/// (e.g. `lib.LIMIT`).
547fn build_available_consts(
548    consts: &[crate::types::VarDecl],
549    imported_consts: &HashMap<String, crate::value::Value>,
550) -> HashMap<String, crate::value::Value> {
551    let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
552    // Add local consts.
553    for d in consts {
554        if let Some(ref v) = d.default_value {
555            available.insert(d.name.clone(), v.clone());
556        }
557    }
558    // Add imported consts (stem.NAME keys).
559    for (k, v) in imported_consts {
560        available.insert(k.clone(), v.clone());
561    }
562    available
563}
564
565#[cfg(test)]
566mod tests;