mod imports;
mod params;
mod type_aliases;
mod validation;
use alloc::{
string::{String, ToString},
vec::Vec,
};
#[cfg(feature = "std")]
use std::path::PathBuf;
pub use imports::*;
pub use params::parse_type_annotation;
pub(crate) use params::*;
pub(crate) use type_aliases::*;
pub(crate) use validation::*;
use crate::{
compat::HashMap,
consts::{
FM_ALLOW_UNUSED_PREFIX, FM_CONSTS_PREFIX, FM_DELIMITER, FM_DELIMITER_NEWLINE,
FM_DESC_PREFIX, FM_ENV_PREFIX, FM_IMPORTS_PREFIX, FM_NAME_PREFIX, FM_PARAMS_PREFIX,
FM_TYPES_PREFIX,
},
error::TemplateError,
frontmatter::params::parse_declarations,
types::{VarDecl, VarType},
};
#[derive(Debug, Clone)]
pub struct Import {
pub stem: String,
#[cfg(feature = "std")]
pub path: PathBuf,
#[cfg(not(feature = "std"))]
pub path: alloc::string::String,
}
#[derive(Debug, Clone, Default)]
pub struct ImportedNamespace {
pub type_aliases: HashMap<String, VarType>,
pub param_types: HashMap<String, VarType>,
pub consts: HashMap<String, crate::value::Value>,
pub const_types: HashMap<String, VarType>,
}
#[derive(Debug, Clone, Default)]
pub struct Frontmatter {
pub name: Option<String>,
pub description: Option<String>,
pub declarations: Vec<VarDecl>,
pub params: Vec<String>,
pub has_params: bool,
pub allow_unused: bool,
pub type_aliases: HashMap<String, VarType>,
pub imports: Vec<Import>,
pub consts: Vec<VarDecl>,
pub env: Vec<VarDecl>,
pub imported_consts: HashMap<String, crate::value::Value>,
pub imported_enum_type_keys: Vec<String>,
pub imported_namespace_types: HashMap<String, VarType>,
pub imported_type_params: HashMap<String, (String, String)>,
}
impl Frontmatter {
#[must_use]
pub fn validate_field_types(&self, segments: &[crate::compiled::Segment]) -> Vec<String> {
let mut opaque_roots: crate::compat::HashSet<String> = crate::compat::HashSet::new();
let mut declarations: Vec<VarDecl> = self.declarations.clone();
for import in &self.imports {
opaque_roots.insert(import.stem.clone());
if let Some(ns_type) = self.imported_namespace_types.get(&import.stem) {
declarations.push(VarDecl {
name: import.stem.clone(),
var_type: ns_type.clone(),
default_value: None,
});
}
}
for c in &self.consts {
declarations.push(c.clone());
}
for e in &self.env {
declarations.push(e.clone());
}
crate::compiled::validate_field_accesses_full(
segments,
&declarations,
&self.type_aliases,
&opaque_roots,
)
}
}
pub fn strip_frontmatter(source: &str) -> Result<&str, TemplateError> {
parse_frontmatter(source).map(|(_, body)| body)
}
pub fn parse_frontmatter(source: &str) -> Result<(Frontmatter, &str), TemplateError> {
parse_frontmatter_impl(
source,
#[cfg(feature = "std")]
None,
None,
false,
&[],
)
}
pub fn parse_frontmatter_with_env<'a>(
source: &'a str,
env_values: &[(&str, crate::value::Value)],
) -> Result<(Frontmatter, &'a str), TemplateError> {
parse_frontmatter_impl(
source,
#[cfg(feature = "std")]
None,
None,
false,
env_values,
)
}
#[cfg(feature = "std")]
pub fn parse_frontmatter_with_base_dir<'a>(
source: &'a str,
base_dir: &std::path::Path,
env_values: &[(&str, crate::value::Value)],
) -> Result<(Frontmatter, &'a str), TemplateError> {
parse_frontmatter_impl(source, Some(base_dir), None, false, env_values)
}
pub fn parse_frontmatter_with_parent_scope<'a>(
source: &'a str,
parent_type_aliases: &HashMap<String, VarType>,
) -> Result<(Frontmatter, &'a str), TemplateError> {
parse_frontmatter_impl(
source,
#[cfg(feature = "std")]
None,
Some(parent_type_aliases),
true,
&[],
)
}
fn extract_yaml_logical_lines(
source: &str,
allow_missing_fm: bool,
) -> Result<(Vec<String>, &str), TemplateError> {
let trimmed = source.trim_start();
if !trimmed.starts_with(FM_DELIMITER) {
if allow_missing_fm {
return Ok((Vec::new(), source));
}
return Err(TemplateError::syntax(
crate::consts::ERR_MISSING_FM.to_string(),
));
}
let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
let (yaml_block, after_close) = if after_first.starts_with(FM_DELIMITER)
&& matches!(
after_first.as_bytes().get(FM_DELIMITER.len()),
None | Some(b'\n' | b'\r')
) {
("", FM_DELIMITER.len())
} else {
let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
return Err(TemplateError::syntax(
crate::consts::ERR_UNCLOSED_FM.to_string(),
));
};
(&after_first[..end], end + FM_DELIMITER_NEWLINE.len())
};
let body_start = if after_first[after_close..].starts_with('\n') {
after_close + 1
} else if after_first[after_close..].starts_with("\r\n") {
after_close + 2
} else {
after_close
};
let body = &after_first[body_start..];
let mut in_block_list = false;
let mut had_blank_line = true;
for line in yaml_block.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
had_blank_line = true;
continue;
}
let starts_with_section = line.starts_with(FM_NAME_PREFIX)
|| line.starts_with(FM_DESC_PREFIX)
|| line.starts_with(FM_TYPES_PREFIX)
|| line.starts_with(FM_IMPORTS_PREFIX)
|| line.starts_with(FM_PARAMS_PREFIX)
|| line.starts_with(FM_CONSTS_PREFIX)
|| line.starts_with(FM_ENV_PREFIX)
|| line.starts_with(FM_ALLOW_UNUSED_PREFIX);
if starts_with_section {
if in_block_list && !had_blank_line {
return Err(TemplateError::syntax(format!(
"A blank line is required after a block list before '{trimmed}' so raw markdown renders correctly"
)));
}
in_block_list = false;
} else if trimmed.starts_with('-') {
in_block_list = true;
}
had_blank_line = false;
}
Ok((join_continuation_lines(yaml_block), body))
}
type FmResolutionResult = Result<
(
HashMap<String, VarType>,
HashMap<String, ImportedNamespace>,
HashMap<String, crate::value::Value>,
),
TemplateError,
>;
fn validate_env_value(
name: &str,
value: &crate::value::Value,
var_type: &VarType,
) -> Result<crate::value::Value, TemplateError> {
use crate::value::Value;
match (value, var_type) {
(Value::Str(raw), VarType::Int) => raw
.parse::<i64>()
.map(Value::Int)
.map_err(|_| TemplateError::syntax(format!("env '{name}': expected int, got '{raw}'"))),
(Value::Str(raw), VarType::Bool) => match raw.as_str() {
crate::consts::LIT_TRUE => Ok(Value::Bool(true)),
crate::consts::LIT_FALSE => Ok(Value::Bool(false)),
_ => Err(TemplateError::syntax(format!(
"env '{name}': expected bool, got '{raw}'"
))),
},
(Value::Str(raw), VarType::Float) => raw.parse::<f64>().map(Value::Float).map_err(|_| {
TemplateError::syntax(format!("env '{name}': expected float, got '{raw}'"))
}),
_ => Ok(value.clone()),
}
}
fn resolve_fm_consts_and_imports(
fm: &mut Frontmatter,
consts_raw: Option<&str>,
env_raw: Option<&str>,
env_values: &[(&str, crate::value::Value)],
parent_type_aliases: Option<&HashMap<String, VarType>>,
#[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
) -> FmResolutionResult {
let mut merged_aliases = if let Some(parent_aliases) = parent_type_aliases {
parent_aliases.clone()
} else {
HashMap::new()
};
for (k, v) in &fm.type_aliases {
merged_aliases.insert(k.clone(), v.clone());
}
let mut prelim_consts = HashMap::new();
let empty_imports = HashMap::new();
let empty_consts = HashMap::new();
if let Some(raw) = env_raw {
let (mut env_decls, _) =
parse_declarations(raw, &merged_aliases, &empty_imports, false, &empty_consts)?;
for decl in &mut env_decls {
if let Some((_, provided_val)) = env_values.iter().find(|(k, _)| *k == decl.name) {
let val = validate_env_value(&decl.name, provided_val, &decl.var_type)?;
prelim_consts.insert(decl.name.clone(), val.clone());
decl.default_value = Some(val);
} else if let Some(ref default) = decl.default_value {
prelim_consts.insert(decl.name.clone(), default.clone());
} else {
return Err(TemplateError::syntax(format!(
"env '{}': no value provided and no default",
decl.name
)));
}
}
fm.env = env_decls;
}
if let Some(raw) = consts_raw {
if let Ok((decls, _)) =
parse_declarations(raw, &merged_aliases, &empty_imports, true, &prelim_consts)
{
let const_map = build_available_consts(&decls, &HashMap::new());
for (k, v) in const_map {
prelim_consts.insert(k, v);
}
}
}
#[cfg(feature = "std")]
let resolved_imports = if let Some(dir) = base_dir {
if fm.imports.is_empty() {
HashMap::new()
} else {
let mut visited = std::collections::HashSet::new();
resolve_imports_with_consts(&mut fm.imports, dir, &mut visited, &prelim_consts)?
}
} else {
if !fm.imports.is_empty() {
interpolate_imports(&mut fm.imports, &prelim_consts)?;
}
HashMap::new()
};
#[cfg(not(feature = "std"))]
let resolved_imports = {
if !fm.imports.is_empty() {
interpolate_imports(&mut fm.imports, &prelim_consts)?;
}
HashMap::new()
};
#[cfg(feature = "std")]
inject_imported_consts(fm, &resolved_imports);
if let Some(raw) = consts_raw {
fm.consts = parse_declarations(
raw,
&merged_aliases,
&resolved_imports,
true,
&prelim_consts,
)?
.0;
}
let mut available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
for decl in &fm.env {
if let Some(val) = prelim_consts.get(&decl.name) {
available_consts
.entry(decl.name.clone())
.or_insert_with(|| val.clone());
}
}
Ok((merged_aliases, resolved_imports, available_consts))
}
fn parse_frontmatter_impl<'a>(
source: &'a str,
#[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
parent_type_aliases: Option<&HashMap<String, VarType>>,
allow_missing_fm: bool,
env_values: &[(&str, crate::value::Value)],
) -> Result<(Frontmatter, &'a str), TemplateError> {
let (logical_lines, body) = extract_yaml_logical_lines(source, allow_missing_fm)?;
if logical_lines.is_empty()
&& allow_missing_fm
&& !source.trim_start().starts_with(FM_DELIMITER)
{
return Ok((Frontmatter::default(), body));
}
let mut fm = Frontmatter::default();
let mut params_raw: Option<String> = None;
let mut consts_raw: Option<String> = None;
let mut env_raw: Option<String> = None;
for line in &logical_lines {
let line = line.trim();
if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
fm.name = Some(rest.trim().to_string());
} else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
fm.description = Some(rest.trim().to_string());
} else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
fm.type_aliases = parse_types_value(rest)?;
} else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
fm.imports = parse_imports_value(rest)?;
} else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
params_raw = Some(rest.to_string());
} else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
consts_raw = Some(rest.to_string());
} else if let Some(rest) = line.strip_prefix(FM_ENV_PREFIX) {
env_raw = Some(rest.to_string());
} else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
}
}
let (merged_aliases, resolved_imports, available_consts) = resolve_fm_consts_and_imports(
&mut fm,
consts_raw.as_deref(),
env_raw.as_deref(),
env_values,
parent_type_aliases,
#[cfg(feature = "std")]
base_dir,
)?;
if let Some(raw) = params_raw {
let (decls, import_refs) = parse_declarations(
&raw,
&merged_aliases,
&resolved_imports,
false,
&available_consts,
)?;
fm.params = decls.iter().map(|d| d.name.clone()).collect();
fm.declarations = decls;
fm.has_params = true;
fm.imported_type_params = import_refs;
}
validate_collision_rules(&fm)?;
add_implicit_param_types(&mut fm);
Ok((fm, body))
}
#[cfg(feature = "std")]
fn inject_imported_consts(
fm: &mut Frontmatter,
resolved_imports: &HashMap<String, ImportedNamespace>,
) {
for (stem, ns) in resolved_imports {
for (name, val) in &ns.consts {
fm.imported_consts
.insert(format!("{stem}.{name}"), val.clone());
}
for (type_name, var_type) in &ns.type_aliases {
let VarType::Enum(variants) = var_type else {
continue;
};
let key = format!("{stem}.{type_name}");
if fm.imported_consts.contains_key(&key) {
continue;
}
let mut variant_map = HashMap::new();
let mut variant_names = Vec::with_capacity(variants.len());
for variant in variants {
variant_names.push(crate::value::Value::Str(variant.name.clone()));
if variant.fields.is_empty() {
variant_map.insert(
variant.name.clone(),
crate::value::Value::Str(variant.name.clone()),
);
} else {
let mut partial = HashMap::new();
partial.insert(
crate::consts::ENUM_TAG_KEY.into(),
crate::value::Value::Str(variant.name.clone()),
);
variant_map.insert(
variant.name.clone(),
crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
);
}
}
variant_map.insert(
crate::consts::ENUM_VARIANTS_KEY.into(),
crate::value::Value::List(alloc::sync::Arc::new(variant_names)),
);
fm.imported_consts.insert(
key.clone(),
crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
);
fm.imported_enum_type_keys.push(key);
}
if !ns.const_types.is_empty() {
let mut field_types: HashMap<String, VarType> = HashMap::new();
for (name, var_type) in &ns.param_types {
field_types.insert(name.clone(), var_type.clone());
}
for (name, var_type) in &ns.type_aliases {
field_types.insert(name.clone(), var_type.clone());
}
for (name, var_type) in &ns.const_types {
field_types.insert(name.clone(), var_type.clone());
}
let fields: Vec<VarDecl> = field_types
.into_iter()
.map(|(name, var_type)| VarDecl {
name,
var_type,
default_value: None,
})
.collect();
fm.imported_namespace_types
.insert(stem.clone(), VarType::Struct(fields));
}
}
}
fn build_available_consts(
consts: &[crate::types::VarDecl],
imported_consts: &HashMap<String, crate::value::Value>,
) -> HashMap<String, crate::value::Value> {
let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
for d in consts {
if let Some(ref v) = d.default_value {
available.insert(d.name.clone(), v.clone());
}
}
for (k, v) in imported_consts {
available.insert(k.clone(), v.clone());
}
available
}
#[cfg(test)]
mod tests;