Skip to main content

md_tmpl/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_IMPORTS_PREFIX, FM_NAME_PREFIX, FM_PARAMS_PREFIX, FM_TYPES_PREFIX,
43    },
44    error::TemplateError,
45    frontmatter::params::parse_declarations,
46    types::{VarDecl, VarType},
47};
48
49/// A template import declaration: `[stem](path.tmpl.md)`.
50#[derive(Debug, Clone)]
51pub struct Import {
52    /// Short alias used as namespace prefix, e.g. `other`.
53    pub stem: String,
54    /// Relative path to the imported template file.
55    #[cfg(feature = "std")]
56    pub path: PathBuf,
57    /// Relative path as a string (always available).
58    #[cfg(not(feature = "std"))]
59    pub path: alloc::string::String,
60}
61
62/// Resolved namespace from an imported template.
63#[derive(Debug, Clone, Default)]
64pub struct ImportedNamespace {
65    /// Type aliases exported by the imported template.
66    pub type_aliases: HashMap<String, VarType>,
67    /// Parameter types (for cross-template type references).
68    pub param_types: HashMap<String, VarType>,
69    /// Constants exported by the imported template.
70    pub consts: HashMap<String, crate::value::Value>,
71}
72
73/// Parsed YAML frontmatter from a `.tmpl.md` file.
74#[derive(Debug, Clone, Default)]
75pub struct Frontmatter {
76    /// Template name (matches SKILL.md `name:` convention).
77    pub name: Option<String>,
78    /// Description of the template's purpose.
79    pub description: Option<String>,
80    /// List of expected variable declarations (name + type + optional default).
81    pub declarations: Vec<VarDecl>,
82    /// Convenience: parameter names only (derived from `declarations`).
83    pub params: Vec<String>,
84    /// Whether the params: block was present in frontmatter.
85    pub has_params: bool,
86    /// Allow declared parameters that are never referenced in the body.
87    ///
88    /// Set via `allow_unused: true` in frontmatter. Useful for
89    /// dynamically-loaded templates where params may be conditionally used.
90    pub allow_unused: bool,
91    /// Type aliases defined via `types:` in frontmatter.
92    ///
93    /// Maps alias names (e.g. `Priority`) to their resolved [`VarType`].
94    pub type_aliases: HashMap<String, VarType>,
95    /// Import declarations defined via `imports:` in frontmatter.
96    pub imports: Vec<Import>,
97    /// Constants defined via `consts:` in frontmatter.
98    pub consts: Vec<VarDecl>,
99    /// Resolved constants from imports, keyed by `stem.NAME`.
100    pub imported_consts: HashMap<String, crate::value::Value>,
101    /// Keys in `imported_consts` that are enum type namespace dicts
102    /// (injected from imported enum type aliases). Used by the bare-enum-access
103    /// check to distinguish enum namespaces from struct constants.
104    pub imported_enum_type_keys: Vec<String>,
105}
106
107/// Strip YAML frontmatter delimited by `---` and return only the body text.
108///
109/// # Errors
110///
111/// Returns [`TemplateError::Syntax`] if the frontmatter block is missing or invalid.
112pub fn strip_frontmatter(source: &str) -> Result<&str, TemplateError> {
113    parse_frontmatter(source).map(|(_, body)| body)
114}
115
116/// Parse YAML frontmatter delimited by `---` lines.
117///
118/// Returns the parsed [`Frontmatter`] and a string slice pointing to the
119/// template body after the closing `---`.
120///
121/// # Errors
122///
123/// Returns [`TemplateError::Syntax`] if the frontmatter block is
124/// missing, unclosed, or contains invalid declarations.
125pub fn parse_frontmatter(source: &str) -> Result<(Frontmatter, &str), TemplateError> {
126    parse_frontmatter_impl(
127        source,
128        #[cfg(feature = "std")]
129        None,
130        None,
131        false,
132    )
133}
134
135/// Parse YAML frontmatter with cross-template import resolution.
136///
137/// Like [`parse_frontmatter`], but additionally resolves `imports:` entries
138/// by reading referenced template files from disk relative to `base_dir`.
139/// This allows params to reference imported types (e.g. `types.Severity`).
140///
141/// # Errors
142///
143/// Returns [`TemplateError::Syntax`] if the frontmatter block is invalid,
144/// an imported file cannot be read, or imported types cannot be resolved.
145#[cfg(feature = "std")]
146pub fn parse_frontmatter_with_base_dir<'a>(
147    source: &'a str,
148    base_dir: &std::path::Path,
149) -> Result<(Frontmatter, &'a str), TemplateError> {
150    parse_frontmatter_impl(source, Some(base_dir), None, false)
151}
152
153/// Parse YAML frontmatter with access to a parent template's type aliases.
154///
155/// Used for inline template definitions (`{% tmpl %}`) that can reference
156/// type aliases from the enclosing template.
157pub fn parse_frontmatter_with_parent_scope<'a>(
158    source: &'a str,
159    parent_type_aliases: &HashMap<String, VarType>,
160) -> Result<(Frontmatter, &'a str), TemplateError> {
161    parse_frontmatter_impl(
162        source,
163        #[cfg(feature = "std")]
164        None,
165        Some(parent_type_aliases),
166        true,
167    )
168}
169
170fn extract_yaml_logical_lines(
171    source: &str,
172    allow_missing_fm: bool,
173) -> Result<(Vec<String>, &str), TemplateError> {
174    let trimmed = source.trim_start();
175    if !trimmed.starts_with(FM_DELIMITER) {
176        if allow_missing_fm {
177            return Ok((Vec::new(), source));
178        }
179        return Err(TemplateError::syntax(
180            crate::consts::ERR_MISSING_FM.to_string(),
181        ));
182    }
183
184    let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
185    let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
186        return Err(TemplateError::syntax(
187            crate::consts::ERR_UNCLOSED_FM.to_string(),
188        ));
189    };
190
191    let yaml_block = &after_first[..end];
192    let after_close = end + FM_DELIMITER_NEWLINE.len();
193    let body_start = if after_first[after_close..].starts_with('\n') {
194        after_close + 1
195    } else if after_first[after_close..].starts_with("\r\n") {
196        after_close + 2
197    } else {
198        after_close
199    };
200    let body = &after_first[body_start..];
201
202    let mut in_block_list = false;
203    let mut had_blank_line = true;
204    for line in yaml_block.lines() {
205        let trimmed = line.trim();
206        if trimmed.is_empty() {
207            had_blank_line = true;
208            continue;
209        }
210        let starts_with_section = line.starts_with(FM_NAME_PREFIX)
211            || line.starts_with(FM_DESC_PREFIX)
212            || line.starts_with(FM_TYPES_PREFIX)
213            || line.starts_with(FM_IMPORTS_PREFIX)
214            || line.starts_with(FM_PARAMS_PREFIX)
215            || line.starts_with(FM_CONSTS_PREFIX)
216            || line.starts_with(FM_ALLOW_UNUSED_PREFIX);
217
218        if starts_with_section {
219            if in_block_list && !had_blank_line {
220                return Err(TemplateError::syntax(format!(
221                    "A blank line is required after a block list before '{trimmed}' so raw markdown renders correctly"
222                )));
223            }
224            in_block_list = false;
225        } else if trimmed.starts_with('-') {
226            in_block_list = true;
227        }
228        had_blank_line = false;
229    }
230
231    Ok((join_continuation_lines(yaml_block), body))
232}
233
234type FmResolutionResult = Result<
235    (
236        HashMap<String, VarType>,
237        HashMap<String, ImportedNamespace>,
238        HashMap<String, crate::value::Value>,
239    ),
240    TemplateError,
241>;
242
243fn resolve_fm_consts_and_imports(
244    fm: &mut Frontmatter,
245    consts_raw: Option<&str>,
246    parent_type_aliases: Option<&HashMap<String, VarType>>,
247    #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
248) -> FmResolutionResult {
249    let mut merged_aliases = if let Some(parent_aliases) = parent_type_aliases {
250        parent_aliases.clone()
251    } else {
252        HashMap::new()
253    };
254    for (k, v) in &fm.type_aliases {
255        merged_aliases.insert(k.clone(), v.clone());
256    }
257
258    let mut prelim_consts = HashMap::new();
259    let empty_imports = HashMap::new();
260    let empty_consts = HashMap::new();
261    if let Some(raw) = consts_raw {
262        if let Ok(decls) =
263            parse_declarations(raw, &merged_aliases, &empty_imports, true, &empty_consts)
264        {
265            prelim_consts = build_available_consts(&decls, &HashMap::new());
266        }
267    }
268
269    #[cfg(feature = "std")]
270    let resolved_imports = if let Some(dir) = base_dir {
271        if fm.imports.is_empty() {
272            HashMap::new()
273        } else {
274            let mut visited = std::collections::HashSet::new();
275            resolve_imports_with_consts(&mut fm.imports, dir, &mut visited, &prelim_consts)?
276        }
277    } else {
278        if !fm.imports.is_empty() {
279            interpolate_imports(&mut fm.imports, &prelim_consts)?;
280        }
281        HashMap::new()
282    };
283
284    #[cfg(not(feature = "std"))]
285    let resolved_imports = {
286        if !fm.imports.is_empty() {
287            interpolate_imports(&mut fm.imports, &prelim_consts)?;
288        }
289        HashMap::new()
290    };
291
292    #[cfg(feature = "std")]
293    inject_imported_consts(fm, &resolved_imports);
294
295    if let Some(raw) = consts_raw {
296        fm.consts =
297            parse_declarations(raw, &merged_aliases, &resolved_imports, true, &empty_consts)?;
298    }
299
300    let available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
301    Ok((merged_aliases, resolved_imports, available_consts))
302}
303
304fn parse_frontmatter_impl<'a>(
305    source: &'a str,
306    #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
307    parent_type_aliases: Option<&HashMap<String, VarType>>,
308    allow_missing_fm: bool,
309) -> Result<(Frontmatter, &'a str), TemplateError> {
310    let (logical_lines, body) = extract_yaml_logical_lines(source, allow_missing_fm)?;
311    if logical_lines.is_empty()
312        && allow_missing_fm
313        && !source.trim_start().starts_with(FM_DELIMITER)
314    {
315        return Ok((Frontmatter::default(), body));
316    }
317
318    let mut fm = Frontmatter::default();
319    let mut params_raw: Option<String> = None;
320    let mut consts_raw: Option<String> = None;
321
322    for line in &logical_lines {
323        let line = line.trim();
324        if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
325            fm.name = Some(rest.trim().to_string());
326        } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
327            fm.description = Some(rest.trim().to_string());
328        } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
329            fm.type_aliases = parse_types_value(rest)?;
330        } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
331            fm.imports = parse_imports_value(rest)?;
332        } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
333            params_raw = Some(rest.to_string());
334        } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
335            consts_raw = Some(rest.to_string());
336        } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
337            fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
338        }
339    }
340
341    let (merged_aliases, resolved_imports, available_consts) = resolve_fm_consts_and_imports(
342        &mut fm,
343        consts_raw.as_deref(),
344        parent_type_aliases,
345        #[cfg(feature = "std")]
346        base_dir,
347    )?;
348
349    if let Some(raw) = params_raw {
350        let decls = parse_declarations(
351            &raw,
352            &merged_aliases,
353            &resolved_imports,
354            false,
355            &available_consts,
356        )?;
357        fm.params = decls.iter().map(|d| d.name.clone()).collect();
358        fm.declarations = decls;
359        fm.has_params = true;
360    }
361
362    validate_collision_rules(&fm)?;
363    add_implicit_param_types(&mut fm);
364
365    Ok((fm, body))
366}
367
368/// Inject imported constants and enum type namespace dicts into `fm`.
369///
370/// For each import namespace, copies over user-defined constants and
371/// synthesizes enum type namespace dicts so that `{{ lib.EnumType.Variant }}`
372/// expressions work.
373#[cfg(feature = "std")]
374fn inject_imported_consts(
375    fm: &mut Frontmatter,
376    resolved_imports: &HashMap<String, ImportedNamespace>,
377) {
378    for (stem, ns) in resolved_imports {
379        for (name, val) in &ns.consts {
380            fm.imported_consts
381                .insert(format!("{stem}.{name}"), val.clone());
382        }
383        // Inject enum type aliases from the imported namespace as constants,
384        // enabling `{{ lib.EnumType.Variant }}` expressions.
385        for (type_name, var_type) in &ns.type_aliases {
386            let VarType::Enum(variants) = var_type else {
387                continue;
388            };
389            let key = format!("{stem}.{type_name}");
390            // Don't overwrite a user-defined constant with the same name.
391            if fm.imported_consts.contains_key(&key) {
392                continue;
393            }
394            let mut variant_map = HashMap::new();
395            let mut variant_names = Vec::with_capacity(variants.len());
396            for variant in variants {
397                variant_names.push(crate::value::Value::Str(variant.name.clone()));
398                if variant.fields.is_empty() {
399                    variant_map.insert(
400                        variant.name.clone(),
401                        crate::value::Value::Str(variant.name.clone()),
402                    );
403                } else {
404                    let mut partial = HashMap::new();
405                    partial.insert(
406                        crate::consts::ENUM_TAG_KEY.into(),
407                        crate::value::Value::Str(variant.name.clone()),
408                    );
409                    variant_map.insert(
410                        variant.name.clone(),
411                        crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
412                    );
413                }
414            }
415            variant_map.insert(
416                crate::consts::ENUM_VARIANTS_KEY.into(),
417                crate::value::Value::List(alloc::sync::Arc::new(variant_names)),
418            );
419            fm.imported_consts.insert(
420                key.clone(),
421                crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
422            );
423            fm.imported_enum_type_keys.push(key);
424        }
425    }
426}
427
428/// Build a lookup map of available constants for use as param default values.
429///
430/// Merges local constants (from `consts:` declarations) with imported constants
431/// (from `imports:`) into a single flat map. Local consts are keyed by their
432/// bare name (e.g. `MAX`), imported consts are already keyed by `stem.NAME`
433/// (e.g. `lib.LIMIT`).
434fn build_available_consts(
435    consts: &[crate::types::VarDecl],
436    imported_consts: &HashMap<String, crate::value::Value>,
437) -> HashMap<String, crate::value::Value> {
438    let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
439    // Add local consts.
440    for d in consts {
441        if let Some(ref v) = d.default_value {
442            available.insert(d.name.clone(), v.clone());
443        }
444    }
445    // Add imported consts (stem.NAME keys).
446    for (k, v) in imported_consts {
447        available.insert(k.clone(), v.clone());
448    }
449    available
450}
451
452#[cfg(test)]
453mod tests;