1mod 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#[derive(Debug, Clone)]
52pub struct Import {
53 pub stem: String,
55 #[cfg(feature = "std")]
57 pub path: PathBuf,
58 #[cfg(not(feature = "std"))]
60 pub path: alloc::string::String,
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct ImportedNamespace {
66 pub type_aliases: HashMap<String, VarType>,
68 pub param_types: HashMap<String, VarType>,
70 pub consts: HashMap<String, crate::value::Value>,
72 pub const_types: HashMap<String, VarType>,
78}
79
80#[derive(Debug, Clone, Default)]
82pub struct Frontmatter {
83 pub name: Option<String>,
85 pub description: Option<String>,
87 pub declarations: Vec<VarDecl>,
89 pub params: Vec<String>,
91 pub has_params: bool,
93 pub allow_unused: bool,
98 pub type_aliases: HashMap<String, VarType>,
102 pub imports: Vec<Import>,
104 pub consts: Vec<VarDecl>,
106 pub env: Vec<VarDecl>,
109 pub imported_consts: HashMap<String, crate::value::Value>,
111 pub imported_enum_type_keys: Vec<String>,
115 pub imported_namespace_types: HashMap<String, VarType>,
122}
123
124impl Frontmatter {
125 #[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 opaque_roots.insert(import.stem.clone());
155 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
185pub fn strip_frontmatter(source: &str) -> Result<&str, TemplateError> {
191 parse_frontmatter(source).map(|(_, body)| body)
192}
193
194pub 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
214pub 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#[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
257pub 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
349fn 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 (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 _ => 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 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 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 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 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#[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 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 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 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
649fn 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 for d in consts {
662 if let Some(ref v) = d.default_value {
663 available.insert(d.name.clone(), v.clone());
664 }
665 }
666 for (k, v) in imported_consts {
668 available.insert(k.clone(), v.clone());
669 }
670 available
671}
672
673#[cfg(test)]
674mod tests;