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 pub imported_type_params: HashMap<String, (String, String)>,
130}
131
132impl Frontmatter {
133 #[must_use]
151 pub fn validate_field_types(&self, segments: &[crate::compiled::Segment]) -> Vec<String> {
152 let mut opaque_roots: crate::compat::HashSet<String> = crate::compat::HashSet::new();
153 let mut declarations: Vec<VarDecl> = self.declarations.clone();
154
155 for import in &self.imports {
156 opaque_roots.insert(import.stem.clone());
163 if let Some(ns_type) = self.imported_namespace_types.get(&import.stem) {
170 declarations.push(VarDecl {
171 name: import.stem.clone(),
172 var_type: ns_type.clone(),
173 default_value: None,
174 });
175 }
176 }
177 for c in &self.consts {
178 declarations.push(c.clone());
179 }
180 for e in &self.env {
181 declarations.push(e.clone());
182 }
183
184 crate::compiled::validate_field_accesses_full(
185 segments,
186 &declarations,
187 &self.type_aliases,
188 &opaque_roots,
189 )
190 }
191}
192
193pub fn strip_frontmatter(source: &str) -> Result<&str, TemplateError> {
199 parse_frontmatter(source).map(|(_, body)| body)
200}
201
202pub fn parse_frontmatter(source: &str) -> Result<(Frontmatter, &str), TemplateError> {
212 parse_frontmatter_impl(
213 source,
214 #[cfg(feature = "std")]
215 None,
216 None,
217 false,
218 &[],
219 )
220}
221
222pub fn parse_frontmatter_with_env<'a>(
233 source: &'a str,
234 env_values: &[(&str, crate::value::Value)],
235) -> Result<(Frontmatter, &'a str), TemplateError> {
236 parse_frontmatter_impl(
237 source,
238 #[cfg(feature = "std")]
239 None,
240 None,
241 false,
242 env_values,
243 )
244}
245
246#[cfg(feature = "std")]
257pub fn parse_frontmatter_with_base_dir<'a>(
258 source: &'a str,
259 base_dir: &std::path::Path,
260 env_values: &[(&str, crate::value::Value)],
261) -> Result<(Frontmatter, &'a str), TemplateError> {
262 parse_frontmatter_impl(source, Some(base_dir), None, false, env_values)
263}
264
265pub fn parse_frontmatter_with_parent_scope<'a>(
270 source: &'a str,
271 parent_type_aliases: &HashMap<String, VarType>,
272) -> Result<(Frontmatter, &'a str), TemplateError> {
273 parse_frontmatter_impl(
274 source,
275 #[cfg(feature = "std")]
276 None,
277 Some(parent_type_aliases),
278 true,
279 &[],
280 )
281}
282
283fn extract_yaml_logical_lines(
284 source: &str,
285 allow_missing_fm: bool,
286) -> Result<(Vec<String>, &str), TemplateError> {
287 let trimmed = source.trim_start();
288 if !trimmed.starts_with(FM_DELIMITER) {
289 if allow_missing_fm {
290 return Ok((Vec::new(), source));
291 }
292 return Err(TemplateError::syntax(
293 crate::consts::ERR_MISSING_FM.to_string(),
294 ));
295 }
296
297 let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
298 let (yaml_block, after_close) = if after_first.starts_with(FM_DELIMITER)
302 && matches!(
303 after_first.as_bytes().get(FM_DELIMITER.len()),
304 None | Some(b'\n' | b'\r')
305 ) {
306 ("", FM_DELIMITER.len())
307 } else {
308 let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
309 return Err(TemplateError::syntax(
310 crate::consts::ERR_UNCLOSED_FM.to_string(),
311 ));
312 };
313 (&after_first[..end], end + FM_DELIMITER_NEWLINE.len())
314 };
315 let body_start = if after_first[after_close..].starts_with('\n') {
316 after_close + 1
317 } else if after_first[after_close..].starts_with("\r\n") {
318 after_close + 2
319 } else {
320 after_close
321 };
322 let body = &after_first[body_start..];
323
324 let mut in_block_list = false;
325 let mut had_blank_line = true;
326 for line in yaml_block.lines() {
327 let trimmed = line.trim();
328 if trimmed.is_empty() {
329 had_blank_line = true;
330 continue;
331 }
332 let starts_with_section = line.starts_with(FM_NAME_PREFIX)
333 || line.starts_with(FM_DESC_PREFIX)
334 || line.starts_with(FM_TYPES_PREFIX)
335 || line.starts_with(FM_IMPORTS_PREFIX)
336 || line.starts_with(FM_PARAMS_PREFIX)
337 || line.starts_with(FM_CONSTS_PREFIX)
338 || line.starts_with(FM_ENV_PREFIX)
339 || line.starts_with(FM_ALLOW_UNUSED_PREFIX);
340
341 if starts_with_section {
342 if in_block_list && !had_blank_line {
343 return Err(TemplateError::syntax(format!(
344 "A blank line is required after a block list before '{trimmed}' so raw markdown renders correctly"
345 )));
346 }
347 in_block_list = false;
348 } else if trimmed.starts_with('-') {
349 in_block_list = true;
350 }
351 had_blank_line = false;
352 }
353
354 Ok((join_continuation_lines(yaml_block), body))
355}
356
357type FmResolutionResult = Result<
358 (
359 HashMap<String, VarType>,
360 HashMap<String, ImportedNamespace>,
361 HashMap<String, crate::value::Value>,
362 ),
363 TemplateError,
364>;
365
366fn validate_env_value(
372 name: &str,
373 value: &crate::value::Value,
374 var_type: &VarType,
375) -> Result<crate::value::Value, TemplateError> {
376 use crate::value::Value;
377 match (value, var_type) {
378 (Value::Str(raw), VarType::Int) => raw
380 .parse::<i64>()
381 .map(Value::Int)
382 .map_err(|_| TemplateError::syntax(format!("env '{name}': expected int, got '{raw}'"))),
383 (Value::Str(raw), VarType::Bool) => match raw.as_str() {
384 crate::consts::LIT_TRUE => Ok(Value::Bool(true)),
385 crate::consts::LIT_FALSE => Ok(Value::Bool(false)),
386 _ => Err(TemplateError::syntax(format!(
387 "env '{name}': expected bool, got '{raw}'"
388 ))),
389 },
390 (Value::Str(raw), VarType::Float) => raw.parse::<f64>().map(Value::Float).map_err(|_| {
391 TemplateError::syntax(format!("env '{name}': expected float, got '{raw}'"))
392 }),
393 _ => Ok(value.clone()),
396 }
397}
398
399fn resolve_fm_consts_and_imports(
400 fm: &mut Frontmatter,
401 consts_raw: Option<&str>,
402 env_raw: Option<&str>,
403 env_values: &[(&str, crate::value::Value)],
404 parent_type_aliases: Option<&HashMap<String, VarType>>,
405 #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
406) -> FmResolutionResult {
407 let mut merged_aliases = if let Some(parent_aliases) = parent_type_aliases {
408 parent_aliases.clone()
409 } else {
410 HashMap::new()
411 };
412 for (k, v) in &fm.type_aliases {
413 merged_aliases.insert(k.clone(), v.clone());
414 }
415
416 let mut prelim_consts = HashMap::new();
417 let empty_imports = HashMap::new();
418 let empty_consts = HashMap::new();
419
420 if let Some(raw) = env_raw {
422 let (mut env_decls, _) =
423 parse_declarations(raw, &merged_aliases, &empty_imports, false, &empty_consts)?;
424 for decl in &mut env_decls {
425 if let Some((_, provided_val)) = env_values.iter().find(|(k, _)| *k == decl.name) {
427 let val = validate_env_value(&decl.name, provided_val, &decl.var_type)?;
428 prelim_consts.insert(decl.name.clone(), val.clone());
429 decl.default_value = Some(val);
430 } else if let Some(ref default) = decl.default_value {
431 prelim_consts.insert(decl.name.clone(), default.clone());
432 } else {
433 return Err(TemplateError::syntax(format!(
434 "env '{}': no value provided and no default",
435 decl.name
436 )));
437 }
438 }
439 fm.env = env_decls;
440 }
441
442 if let Some(raw) = consts_raw {
443 if let Ok((decls, _)) =
445 parse_declarations(raw, &merged_aliases, &empty_imports, true, &prelim_consts)
446 {
447 let const_map = build_available_consts(&decls, &HashMap::new());
448 for (k, v) in const_map {
449 prelim_consts.insert(k, v);
450 }
451 }
452 }
453
454 #[cfg(feature = "std")]
455 let resolved_imports = if let Some(dir) = base_dir {
456 if fm.imports.is_empty() {
457 HashMap::new()
458 } else {
459 let mut visited = std::collections::HashSet::new();
460 resolve_imports_with_consts(&mut fm.imports, dir, &mut visited, &prelim_consts)?
461 }
462 } else {
463 if !fm.imports.is_empty() {
464 interpolate_imports(&mut fm.imports, &prelim_consts)?;
465 }
466 HashMap::new()
467 };
468
469 #[cfg(not(feature = "std"))]
470 let resolved_imports = {
471 if !fm.imports.is_empty() {
472 interpolate_imports(&mut fm.imports, &prelim_consts)?;
473 }
474 HashMap::new()
475 };
476
477 #[cfg(feature = "std")]
478 inject_imported_consts(fm, &resolved_imports);
479
480 if let Some(raw) = consts_raw {
481 fm.consts = parse_declarations(
482 raw,
483 &merged_aliases,
484 &resolved_imports,
485 true,
486 &prelim_consts,
487 )?
488 .0;
489 }
490
491 let mut available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
492 for decl in &fm.env {
494 if let Some(val) = prelim_consts.get(&decl.name) {
495 available_consts
496 .entry(decl.name.clone())
497 .or_insert_with(|| val.clone());
498 }
499 }
500 Ok((merged_aliases, resolved_imports, available_consts))
501}
502
503fn parse_frontmatter_impl<'a>(
504 source: &'a str,
505 #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
506 parent_type_aliases: Option<&HashMap<String, VarType>>,
507 allow_missing_fm: bool,
508 env_values: &[(&str, crate::value::Value)],
509) -> Result<(Frontmatter, &'a str), TemplateError> {
510 let (logical_lines, body) = extract_yaml_logical_lines(source, allow_missing_fm)?;
511 if logical_lines.is_empty()
512 && allow_missing_fm
513 && !source.trim_start().starts_with(FM_DELIMITER)
514 {
515 return Ok((Frontmatter::default(), body));
516 }
517
518 let mut fm = Frontmatter::default();
519 let mut params_raw: Option<String> = None;
520 let mut consts_raw: Option<String> = None;
521 let mut env_raw: Option<String> = None;
522
523 for line in &logical_lines {
524 let line = line.trim();
525 if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
526 fm.name = Some(rest.trim().to_string());
527 } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
528 fm.description = Some(rest.trim().to_string());
529 } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
530 fm.type_aliases = parse_types_value(rest)?;
531 } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
532 fm.imports = parse_imports_value(rest)?;
533 } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
534 params_raw = Some(rest.to_string());
535 } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
536 consts_raw = Some(rest.to_string());
537 } else if let Some(rest) = line.strip_prefix(FM_ENV_PREFIX) {
538 env_raw = Some(rest.to_string());
539 } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
540 fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
541 }
542 }
543
544 let (merged_aliases, resolved_imports, available_consts) = resolve_fm_consts_and_imports(
545 &mut fm,
546 consts_raw.as_deref(),
547 env_raw.as_deref(),
548 env_values,
549 parent_type_aliases,
550 #[cfg(feature = "std")]
551 base_dir,
552 )?;
553
554 if let Some(raw) = params_raw {
555 let (decls, import_refs) = parse_declarations(
556 &raw,
557 &merged_aliases,
558 &resolved_imports,
559 false,
560 &available_consts,
561 )?;
562 fm.params = decls.iter().map(|d| d.name.clone()).collect();
563 fm.declarations = decls;
564 fm.has_params = true;
565 fm.imported_type_params = import_refs;
566 }
567
568 validate_collision_rules(&fm)?;
569 add_implicit_param_types(&mut fm);
570
571 Ok((fm, body))
572}
573
574#[cfg(feature = "std")]
580fn inject_imported_consts(
581 fm: &mut Frontmatter,
582 resolved_imports: &HashMap<String, ImportedNamespace>,
583) {
584 for (stem, ns) in resolved_imports {
585 for (name, val) in &ns.consts {
586 fm.imported_consts
587 .insert(format!("{stem}.{name}"), val.clone());
588 }
589 for (type_name, var_type) in &ns.type_aliases {
592 let VarType::Enum(variants) = var_type else {
593 continue;
594 };
595 let key = format!("{stem}.{type_name}");
596 if fm.imported_consts.contains_key(&key) {
598 continue;
599 }
600 let mut variant_map = HashMap::new();
601 let mut variant_names = Vec::with_capacity(variants.len());
602 for variant in variants {
603 variant_names.push(crate::value::Value::Str(variant.name.clone()));
604 if variant.fields.is_empty() {
605 variant_map.insert(
606 variant.name.clone(),
607 crate::value::Value::Str(variant.name.clone()),
608 );
609 } else {
610 let mut partial = HashMap::new();
611 partial.insert(
612 crate::consts::ENUM_TAG_KEY.into(),
613 crate::value::Value::Str(variant.name.clone()),
614 );
615 variant_map.insert(
616 variant.name.clone(),
617 crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
618 );
619 }
620 }
621 variant_map.insert(
622 crate::consts::ENUM_VARIANTS_KEY.into(),
623 crate::value::Value::List(alloc::sync::Arc::new(variant_names)),
624 );
625 fm.imported_consts.insert(
626 key.clone(),
627 crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
628 );
629 fm.imported_enum_type_keys.push(key);
630 }
631 if !ns.const_types.is_empty() {
644 let mut field_types: HashMap<String, VarType> = HashMap::new();
645 for (name, var_type) in &ns.param_types {
646 field_types.insert(name.clone(), var_type.clone());
647 }
648 for (name, var_type) in &ns.type_aliases {
649 field_types.insert(name.clone(), var_type.clone());
650 }
651 for (name, var_type) in &ns.const_types {
652 field_types.insert(name.clone(), var_type.clone());
653 }
654 let fields: Vec<VarDecl> = field_types
655 .into_iter()
656 .map(|(name, var_type)| VarDecl {
657 name,
658 var_type,
659 default_value: None,
660 })
661 .collect();
662 fm.imported_namespace_types
663 .insert(stem.clone(), VarType::Struct(fields));
664 }
665 }
666}
667
668fn build_available_consts(
675 consts: &[crate::types::VarDecl],
676 imported_consts: &HashMap<String, crate::value::Value>,
677) -> HashMap<String, crate::value::Value> {
678 let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
679 for d in consts {
681 if let Some(ref v) = d.default_value {
682 available.insert(d.name.clone(), v.clone());
683 }
684 }
685 for (k, v) in imported_consts {
687 available.insert(k.clone(), v.clone());
688 }
689 available
690}
691
692#[cfg(test)]
693mod tests;