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        if let Ok(decls) =
353            parse_declarations(raw, &merged_aliases, &empty_imports, true, &prelim_consts)
354        {
355            let const_map = build_available_consts(&decls, &HashMap::new());
356            for (k, v) in const_map {
357                prelim_consts.insert(k, v);
358            }
359        }
360    }
361
362    #[cfg(feature = "std")]
363    let resolved_imports = if let Some(dir) = base_dir {
364        if fm.imports.is_empty() {
365            HashMap::new()
366        } else {
367            let mut visited = std::collections::HashSet::new();
368            resolve_imports_with_consts(&mut fm.imports, dir, &mut visited, &prelim_consts)?
369        }
370    } else {
371        if !fm.imports.is_empty() {
372            interpolate_imports(&mut fm.imports, &prelim_consts)?;
373        }
374        HashMap::new()
375    };
376
377    #[cfg(not(feature = "std"))]
378    let resolved_imports = {
379        if !fm.imports.is_empty() {
380            interpolate_imports(&mut fm.imports, &prelim_consts)?;
381        }
382        HashMap::new()
383    };
384
385    #[cfg(feature = "std")]
386    inject_imported_consts(fm, &resolved_imports);
387
388    if let Some(raw) = consts_raw {
389        fm.consts = parse_declarations(
390            raw,
391            &merged_aliases,
392            &resolved_imports,
393            true,
394            &prelim_consts,
395        )?;
396    }
397
398    let mut available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
399    // Merge env values into available_consts so params can reference them.
400    for decl in &fm.env {
401        if let Some(val) = prelim_consts.get(&decl.name) {
402            available_consts
403                .entry(decl.name.clone())
404                .or_insert_with(|| val.clone());
405        }
406    }
407    Ok((merged_aliases, resolved_imports, available_consts))
408}
409
410fn parse_frontmatter_impl<'a>(
411    source: &'a str,
412    #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
413    parent_type_aliases: Option<&HashMap<String, VarType>>,
414    allow_missing_fm: bool,
415    env_values: &[(&str, crate::value::Value)],
416) -> Result<(Frontmatter, &'a str), TemplateError> {
417    let (logical_lines, body) = extract_yaml_logical_lines(source, allow_missing_fm)?;
418    if logical_lines.is_empty()
419        && allow_missing_fm
420        && !source.trim_start().starts_with(FM_DELIMITER)
421    {
422        return Ok((Frontmatter::default(), body));
423    }
424
425    let mut fm = Frontmatter::default();
426    let mut params_raw: Option<String> = None;
427    let mut consts_raw: Option<String> = None;
428    let mut env_raw: Option<String> = None;
429
430    for line in &logical_lines {
431        let line = line.trim();
432        if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
433            fm.name = Some(rest.trim().to_string());
434        } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
435            fm.description = Some(rest.trim().to_string());
436        } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
437            fm.type_aliases = parse_types_value(rest)?;
438        } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
439            fm.imports = parse_imports_value(rest)?;
440        } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
441            params_raw = Some(rest.to_string());
442        } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
443            consts_raw = Some(rest.to_string());
444        } else if let Some(rest) = line.strip_prefix(FM_ENV_PREFIX) {
445            env_raw = Some(rest.to_string());
446        } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
447            fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
448        }
449    }
450
451    let (merged_aliases, resolved_imports, available_consts) = resolve_fm_consts_and_imports(
452        &mut fm,
453        consts_raw.as_deref(),
454        env_raw.as_deref(),
455        env_values,
456        parent_type_aliases,
457        #[cfg(feature = "std")]
458        base_dir,
459    )?;
460
461    if let Some(raw) = params_raw {
462        let decls = parse_declarations(
463            &raw,
464            &merged_aliases,
465            &resolved_imports,
466            false,
467            &available_consts,
468        )?;
469        fm.params = decls.iter().map(|d| d.name.clone()).collect();
470        fm.declarations = decls;
471        fm.has_params = true;
472    }
473
474    validate_collision_rules(&fm)?;
475    add_implicit_param_types(&mut fm);
476
477    Ok((fm, body))
478}
479
480/// Inject imported constants and enum type namespace dicts into `fm`.
481///
482/// For each import namespace, copies over user-defined constants and
483/// synthesizes enum type namespace dicts so that `{{ lib.EnumType.Variant }}`
484/// expressions work.
485#[cfg(feature = "std")]
486fn inject_imported_consts(
487    fm: &mut Frontmatter,
488    resolved_imports: &HashMap<String, ImportedNamespace>,
489) {
490    for (stem, ns) in resolved_imports {
491        for (name, val) in &ns.consts {
492            fm.imported_consts
493                .insert(format!("{stem}.{name}"), val.clone());
494        }
495        // Inject enum type aliases from the imported namespace as constants,
496        // enabling `{{ lib.EnumType.Variant }}` expressions.
497        for (type_name, var_type) in &ns.type_aliases {
498            let VarType::Enum(variants) = var_type else {
499                continue;
500            };
501            let key = format!("{stem}.{type_name}");
502            // Don't overwrite a user-defined constant with the same name.
503            if fm.imported_consts.contains_key(&key) {
504                continue;
505            }
506            let mut variant_map = HashMap::new();
507            let mut variant_names = Vec::with_capacity(variants.len());
508            for variant in variants {
509                variant_names.push(crate::value::Value::Str(variant.name.clone()));
510                if variant.fields.is_empty() {
511                    variant_map.insert(
512                        variant.name.clone(),
513                        crate::value::Value::Str(variant.name.clone()),
514                    );
515                } else {
516                    let mut partial = HashMap::new();
517                    partial.insert(
518                        crate::consts::ENUM_TAG_KEY.into(),
519                        crate::value::Value::Str(variant.name.clone()),
520                    );
521                    variant_map.insert(
522                        variant.name.clone(),
523                        crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
524                    );
525                }
526            }
527            variant_map.insert(
528                crate::consts::ENUM_VARIANTS_KEY.into(),
529                crate::value::Value::List(alloc::sync::Arc::new(variant_names)),
530            );
531            fm.imported_consts.insert(
532                key.clone(),
533                crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
534            );
535            fm.imported_enum_type_keys.push(key);
536        }
537    }
538}
539
540/// Build a lookup map of available constants for use as param default values.
541///
542/// Merges local constants (from `consts:` declarations) with imported constants
543/// (from `imports:`) into a single flat map. Local consts are keyed by their
544/// bare name (e.g. `MAX`), imported consts are already keyed by `stem.NAME`
545/// (e.g. `lib.LIMIT`).
546fn build_available_consts(
547    consts: &[crate::types::VarDecl],
548    imported_consts: &HashMap<String, crate::value::Value>,
549) -> HashMap<String, crate::value::Value> {
550    let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
551    // Add local consts.
552    for d in consts {
553        if let Some(ref v) = d.default_value {
554            available.insert(d.name.clone(), v.clone());
555        }
556    }
557    // Add imported consts (stem.NAME keys).
558    for (k, v) in imported_consts {
559        available.insert(k.clone(), v.clone());
560    }
561    available
562}
563
564#[cfg(test)]
565mod tests;