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 #[must_use]
199 pub fn validate_runtime_types(&self, segments: &[crate::compiled::Segment]) -> Vec<String> {
200 let mut opaque_roots: crate::compat::HashSet<String> = crate::compat::HashSet::new();
201 let mut declarations: Vec<VarDecl> = self.declarations.clone();
202
203 for import in &self.imports {
204 opaque_roots.insert(import.stem.clone());
205 if let Some(ns_type) = self.imported_namespace_types.get(&import.stem) {
206 declarations.push(VarDecl {
207 name: import.stem.clone(),
208 var_type: ns_type.clone(),
209 default_value: None,
210 });
211 }
212 }
213 for c in &self.consts {
214 declarations.push(c.clone());
215 }
216 for e in &self.env {
217 declarations.push(e.clone());
218 }
219
220 crate::compiled::validate_field_accesses_runtime(
221 segments,
222 &declarations,
223 &self.type_aliases,
224 &opaque_roots,
225 )
226 }
227}
228
229pub fn strip_frontmatter(source: &str) -> Result<&str, TemplateError> {
235 parse_frontmatter(source).map(|(_, body)| body)
236}
237
238pub fn parse_frontmatter(source: &str) -> Result<(Frontmatter, &str), TemplateError> {
248 parse_frontmatter_impl(
249 source,
250 #[cfg(feature = "std")]
251 None,
252 None,
253 false,
254 &[],
255 )
256}
257
258pub fn parse_frontmatter_with_env<'a>(
269 source: &'a str,
270 env_values: &[(&str, crate::value::Value)],
271) -> Result<(Frontmatter, &'a str), TemplateError> {
272 parse_frontmatter_impl(
273 source,
274 #[cfg(feature = "std")]
275 None,
276 None,
277 false,
278 env_values,
279 )
280}
281
282#[cfg(feature = "std")]
293pub fn parse_frontmatter_with_base_dir<'a>(
294 source: &'a str,
295 base_dir: &std::path::Path,
296 env_values: &[(&str, crate::value::Value)],
297) -> Result<(Frontmatter, &'a str), TemplateError> {
298 parse_frontmatter_impl(source, Some(base_dir), None, false, env_values)
299}
300
301pub fn parse_frontmatter_with_parent_scope<'a>(
306 source: &'a str,
307 parent_type_aliases: &HashMap<String, VarType>,
308) -> Result<(Frontmatter, &'a str), TemplateError> {
309 parse_frontmatter_impl(
310 source,
311 #[cfg(feature = "std")]
312 None,
313 Some(parent_type_aliases),
314 true,
315 &[],
316 )
317}
318
319fn extract_yaml_logical_lines(
320 source: &str,
321 allow_missing_fm: bool,
322) -> Result<(Vec<String>, &str), TemplateError> {
323 let trimmed = source.trim_start();
324 if !trimmed.starts_with(FM_DELIMITER) {
325 if allow_missing_fm {
326 return Ok((Vec::new(), source));
327 }
328 return Err(TemplateError::syntax(
329 crate::consts::ERR_MISSING_FM.to_string(),
330 ));
331 }
332
333 let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
334 let (yaml_block, after_close) = if after_first.starts_with(FM_DELIMITER)
338 && matches!(
339 after_first.as_bytes().get(FM_DELIMITER.len()),
340 None | Some(b'\n' | b'\r')
341 ) {
342 ("", FM_DELIMITER.len())
343 } else {
344 let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
345 return Err(TemplateError::syntax(
346 crate::consts::ERR_UNCLOSED_FM.to_string(),
347 ));
348 };
349 (&after_first[..end], end + FM_DELIMITER_NEWLINE.len())
350 };
351 let body_start = if after_first[after_close..].starts_with('\n') {
352 after_close + 1
353 } else if after_first[after_close..].starts_with("\r\n") {
354 after_close + 2
355 } else {
356 after_close
357 };
358 let body = &after_first[body_start..];
359
360 let mut in_block_list = false;
361 let mut had_blank_line = true;
362 for line in yaml_block.lines() {
363 let trimmed = line.trim();
364 if trimmed.is_empty() {
365 had_blank_line = true;
366 continue;
367 }
368 let starts_with_section = line.starts_with(FM_NAME_PREFIX)
369 || line.starts_with(FM_DESC_PREFIX)
370 || line.starts_with(FM_TYPES_PREFIX)
371 || line.starts_with(FM_IMPORTS_PREFIX)
372 || line.starts_with(FM_PARAMS_PREFIX)
373 || line.starts_with(FM_CONSTS_PREFIX)
374 || line.starts_with(FM_ENV_PREFIX)
375 || line.starts_with(FM_ALLOW_UNUSED_PREFIX);
376
377 if starts_with_section {
378 if in_block_list && !had_blank_line {
379 return Err(TemplateError::syntax(format!(
380 "A blank line is required after a block list before '{trimmed}' so raw markdown renders correctly"
381 )));
382 }
383 in_block_list = false;
384 } else if trimmed.starts_with('-') {
385 in_block_list = true;
386 }
387 had_blank_line = false;
388 }
389
390 Ok((join_continuation_lines(yaml_block), body))
391}
392
393type FmResolutionResult = Result<
394 (
395 HashMap<String, VarType>,
396 HashMap<String, ImportedNamespace>,
397 HashMap<String, crate::value::Value>,
398 ),
399 TemplateError,
400>;
401
402fn validate_env_value(
408 name: &str,
409 value: &crate::value::Value,
410 var_type: &VarType,
411) -> Result<crate::value::Value, TemplateError> {
412 use crate::value::Value;
413 match (value, var_type) {
414 (Value::Str(raw), VarType::Int) => raw
416 .parse::<i64>()
417 .map(Value::Int)
418 .map_err(|_| TemplateError::syntax(format!("env '{name}': expected int, got '{raw}'"))),
419 (Value::Str(raw), VarType::Bool) => match raw.as_str() {
420 crate::consts::LIT_TRUE => Ok(Value::Bool(true)),
421 crate::consts::LIT_FALSE => Ok(Value::Bool(false)),
422 _ => Err(TemplateError::syntax(format!(
423 "env '{name}': expected bool, got '{raw}'"
424 ))),
425 },
426 (Value::Str(raw), VarType::Float) => raw.parse::<f64>().map(Value::Float).map_err(|_| {
427 TemplateError::syntax(format!("env '{name}': expected float, got '{raw}'"))
428 }),
429 _ => Ok(value.clone()),
432 }
433}
434
435fn resolve_fm_consts_and_imports(
436 fm: &mut Frontmatter,
437 consts_raw: Option<&str>,
438 env_raw: Option<&str>,
439 env_values: &[(&str, crate::value::Value)],
440 parent_type_aliases: Option<&HashMap<String, VarType>>,
441 #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
442) -> FmResolutionResult {
443 let mut merged_aliases = if let Some(parent_aliases) = parent_type_aliases {
444 parent_aliases.clone()
445 } else {
446 HashMap::new()
447 };
448 for (k, v) in &fm.type_aliases {
449 merged_aliases.insert(k.clone(), v.clone());
450 }
451
452 let mut prelim_consts = HashMap::new();
453 let empty_imports = HashMap::new();
454 let empty_consts = HashMap::new();
455
456 if let Some(raw) = env_raw {
458 let (mut env_decls, _) =
459 parse_declarations(raw, &merged_aliases, &empty_imports, false, &empty_consts)?;
460 for decl in &mut env_decls {
461 if let Some((_, provided_val)) = env_values.iter().find(|(k, _)| *k == decl.name) {
463 let val = validate_env_value(&decl.name, provided_val, &decl.var_type)?;
464 prelim_consts.insert(decl.name.clone(), val.clone());
465 decl.default_value = Some(val);
466 } else if let Some(ref default) = decl.default_value {
467 prelim_consts.insert(decl.name.clone(), default.clone());
468 } else {
469 return Err(TemplateError::syntax(format!(
470 "env '{}': no value provided and no default",
471 decl.name
472 )));
473 }
474 }
475 fm.env = env_decls;
476 }
477
478 if let Some(raw) = consts_raw {
479 if let Ok((decls, _)) =
481 parse_declarations(raw, &merged_aliases, &empty_imports, true, &prelim_consts)
482 {
483 let const_map = build_available_consts(&decls, &HashMap::new());
484 for (k, v) in const_map {
485 prelim_consts.insert(k, v);
486 }
487 }
488 }
489
490 #[cfg(feature = "std")]
491 let resolved_imports = if let Some(dir) = base_dir {
492 if fm.imports.is_empty() {
493 HashMap::new()
494 } else {
495 let mut visited = std::collections::HashSet::new();
496 resolve_imports_with_consts(&mut fm.imports, dir, &mut visited, &prelim_consts)?
497 }
498 } else {
499 if !fm.imports.is_empty() {
500 interpolate_imports(&mut fm.imports, &prelim_consts)?;
501 }
502 HashMap::new()
503 };
504
505 #[cfg(not(feature = "std"))]
506 let resolved_imports = {
507 if !fm.imports.is_empty() {
508 interpolate_imports(&mut fm.imports, &prelim_consts)?;
509 }
510 HashMap::new()
511 };
512
513 #[cfg(feature = "std")]
514 inject_imported_consts(fm, &resolved_imports);
515
516 if let Some(raw) = consts_raw {
517 fm.consts = parse_declarations(
518 raw,
519 &merged_aliases,
520 &resolved_imports,
521 true,
522 &prelim_consts,
523 )?
524 .0;
525 }
526
527 let mut available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
528 for decl in &fm.env {
530 if let Some(val) = prelim_consts.get(&decl.name) {
531 available_consts
532 .entry(decl.name.clone())
533 .or_insert_with(|| val.clone());
534 }
535 }
536 Ok((merged_aliases, resolved_imports, available_consts))
537}
538
539fn parse_frontmatter_impl<'a>(
540 source: &'a str,
541 #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
542 parent_type_aliases: Option<&HashMap<String, VarType>>,
543 allow_missing_fm: bool,
544 env_values: &[(&str, crate::value::Value)],
545) -> Result<(Frontmatter, &'a str), TemplateError> {
546 let (logical_lines, body) = extract_yaml_logical_lines(source, allow_missing_fm)?;
547 if logical_lines.is_empty()
548 && allow_missing_fm
549 && !source.trim_start().starts_with(FM_DELIMITER)
550 {
551 return Ok((Frontmatter::default(), body));
552 }
553
554 let mut fm = Frontmatter::default();
555 let mut params_raw: Option<String> = None;
556 let mut consts_raw: Option<String> = None;
557 let mut env_raw: Option<String> = None;
558
559 for line in &logical_lines {
560 let line = line.trim();
561 if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
562 fm.name = Some(rest.trim().to_string());
563 } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
564 fm.description = Some(rest.trim().to_string());
565 } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
566 fm.type_aliases = parse_types_value(rest)?;
567 } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
568 fm.imports = parse_imports_value(rest)?;
569 } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
570 params_raw = Some(rest.to_string());
571 } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
572 consts_raw = Some(rest.to_string());
573 } else if let Some(rest) = line.strip_prefix(FM_ENV_PREFIX) {
574 env_raw = Some(rest.to_string());
575 } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
576 fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
577 }
578 }
579
580 let (merged_aliases, resolved_imports, available_consts) = resolve_fm_consts_and_imports(
581 &mut fm,
582 consts_raw.as_deref(),
583 env_raw.as_deref(),
584 env_values,
585 parent_type_aliases,
586 #[cfg(feature = "std")]
587 base_dir,
588 )?;
589
590 if let Some(raw) = params_raw {
591 let (decls, import_refs) = parse_declarations(
592 &raw,
593 &merged_aliases,
594 &resolved_imports,
595 false,
596 &available_consts,
597 )?;
598 fm.params = decls.iter().map(|d| d.name.clone()).collect();
599 fm.declarations = decls;
600 fm.has_params = true;
601 fm.imported_type_params = import_refs;
602 }
603
604 validate_collision_rules(&fm)?;
605 add_implicit_param_types(&mut fm);
606
607 Ok((fm, body))
608}
609
610#[cfg(feature = "std")]
616fn inject_imported_consts(
617 fm: &mut Frontmatter,
618 resolved_imports: &HashMap<String, ImportedNamespace>,
619) {
620 for (stem, ns) in resolved_imports {
621 for (name, val) in &ns.consts {
622 fm.imported_consts
623 .insert(format!("{stem}.{name}"), val.clone());
624 }
625 for (type_name, var_type) in &ns.type_aliases {
628 let VarType::Enum(variants) = var_type else {
629 continue;
630 };
631 let key = format!("{stem}.{type_name}");
632 if fm.imported_consts.contains_key(&key) {
634 continue;
635 }
636 let mut variant_map = HashMap::new();
637 let mut variant_names = Vec::with_capacity(variants.len());
638 for variant in variants {
639 variant_names.push(crate::value::Value::Str(variant.name.clone()));
640 if variant.fields.is_empty() {
641 variant_map.insert(
642 variant.name.clone(),
643 crate::value::Value::Str(variant.name.clone()),
644 );
645 } else {
646 let mut partial = HashMap::new();
647 partial.insert(
648 crate::consts::ENUM_TAG_KEY.into(),
649 crate::value::Value::Str(variant.name.clone()),
650 );
651 variant_map.insert(
652 variant.name.clone(),
653 crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
654 );
655 }
656 }
657 variant_map.insert(
658 crate::consts::ENUM_VARIANTS_KEY.into(),
659 crate::value::Value::List(alloc::sync::Arc::new(variant_names)),
660 );
661 fm.imported_consts.insert(
662 key.clone(),
663 crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
664 );
665 fm.imported_enum_type_keys.push(key);
666 }
667 if !ns.const_types.is_empty() {
680 let mut field_types: HashMap<String, VarType> = HashMap::new();
681 for (name, var_type) in &ns.param_types {
682 field_types.insert(name.clone(), var_type.clone());
683 }
684 for (name, var_type) in &ns.type_aliases {
685 field_types.insert(name.clone(), var_type.clone());
686 }
687 for (name, var_type) in &ns.const_types {
688 field_types.insert(name.clone(), var_type.clone());
689 }
690 let fields: Vec<VarDecl> = field_types
691 .into_iter()
692 .map(|(name, var_type)| VarDecl {
693 name,
694 var_type,
695 default_value: None,
696 })
697 .collect();
698 fm.imported_namespace_types
699 .insert(stem.clone(), VarType::Struct(fields));
700 }
701 }
702}
703
704fn build_available_consts(
711 consts: &[crate::types::VarDecl],
712 imported_consts: &HashMap<String, crate::value::Value>,
713) -> HashMap<String, crate::value::Value> {
714 let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
715 for d in consts {
717 if let Some(ref v) = d.default_value {
718 available.insert(d.name.clone(), v.clone());
719 }
720 }
721 for (k, v) in imported_consts {
723 available.insert(k.clone(), v.clone());
724 }
725 available
726}
727
728#[cfg(test)]
729mod tests;