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_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#[derive(Debug, Clone)]
51pub struct Import {
52 pub stem: String,
54 #[cfg(feature = "std")]
56 pub path: PathBuf,
57 #[cfg(not(feature = "std"))]
59 pub path: alloc::string::String,
60}
61
62#[derive(Debug, Clone, Default)]
64pub struct ImportedNamespace {
65 pub type_aliases: HashMap<String, VarType>,
67 pub param_types: HashMap<String, VarType>,
69 pub consts: HashMap<String, crate::value::Value>,
71}
72
73#[derive(Debug, Clone, Default)]
75pub struct Frontmatter {
76 pub name: Option<String>,
78 pub description: Option<String>,
80 pub declarations: Vec<VarDecl>,
82 pub params: Vec<String>,
84 pub has_params: bool,
86 pub allow_unused: bool,
91 pub type_aliases: HashMap<String, VarType>,
95 pub imports: Vec<Import>,
97 pub consts: Vec<VarDecl>,
99 pub imported_consts: HashMap<String, crate::value::Value>,
101 pub imported_enum_type_keys: Vec<String>,
105}
106
107pub fn strip_frontmatter(source: &str) -> Result<&str, TemplateError> {
113 parse_frontmatter(source).map(|(_, body)| body)
114}
115
116pub fn parse_frontmatter(source: &str) -> Result<(Frontmatter, &str), TemplateError> {
126 let trimmed = source.trim_start();
127 if !trimmed.starts_with(FM_DELIMITER) {
128 return Err(TemplateError::syntax(
129 crate::consts::ERR_MISSING_FM.to_string(),
130 ));
131 }
132
133 let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
135 let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
136 return Err(TemplateError::syntax(
137 crate::consts::ERR_UNCLOSED_FM.to_string(),
138 ));
139 };
140
141 let yaml_block = &after_first[..end];
142 let after_close = end + FM_DELIMITER_NEWLINE.len();
144 let body_start = if after_first[after_close..].starts_with('\n') {
145 after_close + 1
146 } else if after_first[after_close..].starts_with("\r\n") {
147 after_close + 2
148 } else {
149 after_close
150 };
151 let body = &after_first[body_start..];
152
153 let mut fm = Frontmatter::default();
154
155 let mut in_block_list = false;
159 let mut had_blank_line = true;
160 for line in yaml_block.lines() {
161 let trimmed = line.trim();
162 if trimmed.is_empty() {
163 had_blank_line = true;
164 continue;
165 }
166 let starts_with_section = line.starts_with(FM_NAME_PREFIX)
167 || line.starts_with(FM_DESC_PREFIX)
168 || line.starts_with(FM_TYPES_PREFIX)
169 || line.starts_with(FM_IMPORTS_PREFIX)
170 || line.starts_with(FM_PARAMS_PREFIX)
171 || line.starts_with(FM_CONSTS_PREFIX)
172 || line.starts_with(FM_ALLOW_UNUSED_PREFIX);
173
174 if starts_with_section {
175 if in_block_list && !had_blank_line {
176 return Err(TemplateError::syntax(format!(
177 "A blank line is required after a block list before '{trimmed}' so raw markdown renders correctly"
178 )));
179 }
180 in_block_list = false;
181 } else if trimmed.starts_with('-') {
182 in_block_list = true;
183 }
184 had_blank_line = false;
185 }
186
187 let logical_lines = join_continuation_lines(yaml_block);
191
192 let mut params_raw: Option<String> = None;
194 let mut consts_raw: Option<String> = None;
195
196 for line in &logical_lines {
197 let line = line.trim();
198 if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
199 fm.name = Some(rest.trim().to_string());
200 } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
201 fm.description = Some(rest.trim().to_string());
202 } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
203 fm.type_aliases = parse_types_value(rest)?;
204 } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
205 fm.imports = parse_imports_value(rest)?;
206 } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
207 params_raw = Some(rest.to_string());
208 } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
209 consts_raw = Some(rest.to_string());
210 } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
211 fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
212 }
213 }
214
215 let resolved_imports = HashMap::new();
217 let empty_consts = HashMap::new();
218 if let Some(raw) = consts_raw {
219 fm.consts = parse_declarations(
220 &raw,
221 &fm.type_aliases,
222 &resolved_imports,
223 true,
224 &empty_consts,
225 )?;
226 }
227
228 let available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
231
232 if let Some(raw) = params_raw {
233 let decls = parse_declarations(
234 &raw,
235 &fm.type_aliases,
236 &resolved_imports,
237 false,
238 &available_consts,
239 )?;
240 fm.params = decls.iter().map(|d| d.name.clone()).collect();
241 fm.declarations = decls;
242 fm.has_params = true;
243 }
244
245 validate_collision_rules(&fm)?;
246 add_implicit_param_types(&mut fm);
247
248 Ok((fm, body))
249}
250
251#[cfg(feature = "std")]
262pub fn parse_frontmatter_with_base_dir<'a>(
263 source: &'a str,
264 base_dir: &std::path::Path,
265) -> Result<(Frontmatter, &'a str), TemplateError> {
266 let trimmed = source.trim_start();
267 if !trimmed.starts_with(FM_DELIMITER) {
268 return Err(TemplateError::syntax(
269 crate::consts::ERR_MISSING_FM.to_string(),
270 ));
271 }
272
273 let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
275 let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
276 return Err(TemplateError::syntax(
277 crate::consts::ERR_UNCLOSED_FM.to_string(),
278 ));
279 };
280
281 let yaml_block = &after_first[..end];
282 let after_close = end + FM_DELIMITER_NEWLINE.len();
283 let body_start = if after_first[after_close..].starts_with('\n') {
284 after_close + 1
285 } else if after_first[after_close..].starts_with("\r\n") {
286 after_close + 2
287 } else {
288 after_close
289 };
290 let body = &after_first[body_start..];
291
292 let mut fm = Frontmatter::default();
293 let logical_lines = join_continuation_lines(yaml_block);
294
295 let mut params_raw: Option<String> = None;
297 let mut consts_raw: Option<String> = None;
298
299 for line in &logical_lines {
300 let line = line.trim();
301 if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
302 fm.name = Some(rest.trim().to_string());
303 } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
304 fm.description = Some(rest.trim().to_string());
305 } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
306 fm.type_aliases = parse_types_value(rest)?;
307 } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
308 fm.imports = parse_imports_value(rest)?;
309 } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
310 params_raw = Some(rest.to_string());
311 } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
312 consts_raw = Some(rest.to_string());
313 } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
314 fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
315 }
316 }
317
318 let resolved_imports = if fm.imports.is_empty() {
319 HashMap::new()
320 } else {
321 let mut visited = std::collections::HashSet::new();
322 resolve_imports(&fm.imports, base_dir, &mut visited)?
323 };
324
325 inject_imported_consts(&mut fm, &resolved_imports);
326
327 let empty_consts = HashMap::new();
328 if let Some(raw) = consts_raw {
329 fm.consts = parse_declarations(
330 &raw,
331 &fm.type_aliases,
332 &resolved_imports,
333 true,
334 &empty_consts,
335 )?;
336 }
337
338 let available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
341
342 if let Some(raw) = params_raw {
343 let decls = parse_declarations(
344 &raw,
345 &fm.type_aliases,
346 &resolved_imports,
347 false,
348 &available_consts,
349 )?;
350 fm.params = decls.iter().map(|d| d.name.clone()).collect();
351 fm.declarations = decls;
352 fm.has_params = true;
353 }
354
355 validate_collision_rules(&fm)?;
356 add_implicit_param_types(&mut fm);
357
358 Ok((fm, body))
359}
360
361pub fn parse_frontmatter_with_parent_scope<'a>(
370 source: &'a str,
371 parent_type_aliases: &HashMap<String, VarType>,
372) -> Result<(Frontmatter, &'a str), TemplateError> {
373 let trimmed = source.trim_start();
374 if !trimmed.starts_with(FM_DELIMITER) {
375 return Ok((Frontmatter::default(), source));
379 }
380
381 let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
382 let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
383 return Err(TemplateError::syntax(
384 crate::consts::ERR_UNCLOSED_FM.to_string(),
385 ));
386 };
387
388 let yaml_block = &after_first[..end];
389 let after_close = end + FM_DELIMITER_NEWLINE.len();
390 let body_start = if after_first[after_close..].starts_with('\n') {
391 after_close + 1
392 } else if after_first[after_close..].starts_with("\r\n") {
393 after_close + 2
394 } else {
395 after_close
396 };
397 let body = &after_first[body_start..];
398
399 let mut fm = Frontmatter::default();
400 let logical_lines = join_continuation_lines(yaml_block);
401
402 let mut params_raw: Option<String> = None;
404 let mut consts_raw: Option<String> = None;
405
406 for line in &logical_lines {
407 let line = line.trim();
408 if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
409 fm.name = Some(rest.trim().to_string());
410 } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
411 fm.description = Some(rest.trim().to_string());
412 } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
413 fm.type_aliases = parse_types_value(rest)?;
414 } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
415 fm.imports = parse_imports_value(rest)?;
416 } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
417 params_raw = Some(rest.to_string());
418 } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
419 consts_raw = Some(rest.to_string());
420 } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
421 fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
422 }
423 }
424
425 let mut merged_aliases = parent_type_aliases.clone();
428 for (k, v) in &fm.type_aliases {
429 merged_aliases.insert(k.clone(), v.clone());
430 }
431 let resolved_imports = HashMap::new();
432 let empty_consts = HashMap::new();
433 if let Some(raw) = consts_raw {
434 fm.consts = parse_declarations(
435 &raw,
436 &merged_aliases,
437 &resolved_imports,
438 true,
439 &empty_consts,
440 )?;
441 }
442
443 let available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
446
447 if let Some(raw) = params_raw {
448 let decls = parse_declarations(
449 &raw,
450 &merged_aliases,
451 &resolved_imports,
452 false,
453 &available_consts,
454 )?;
455 fm.params = decls.iter().map(|d| d.name.clone()).collect();
456 fm.declarations = decls;
457 fm.has_params = true;
458 }
459
460 validate_collision_rules(&fm)?;
461 add_implicit_param_types(&mut fm);
462
463 Ok((fm, body))
464}
465
466#[cfg(feature = "std")]
472fn inject_imported_consts(
473 fm: &mut Frontmatter,
474 resolved_imports: &HashMap<String, ImportedNamespace>,
475) {
476 for (stem, ns) in resolved_imports {
477 for (name, val) in &ns.consts {
478 fm.imported_consts
479 .insert(format!("{stem}.{name}"), val.clone());
480 }
481 for (type_name, var_type) in &ns.type_aliases {
484 let VarType::Enum(variants) = var_type else {
485 continue;
486 };
487 let key = format!("{stem}.{type_name}");
488 if fm.imported_consts.contains_key(&key) {
490 continue;
491 }
492 let mut variant_map = HashMap::new();
493 for variant in variants {
494 if variant.fields.is_empty() {
495 variant_map.insert(
496 variant.name.clone(),
497 crate::value::Value::Str(variant.name.clone()),
498 );
499 } else {
500 let mut partial = HashMap::new();
501 partial.insert(
502 crate::consts::ENUM_TAG_KEY.into(),
503 crate::value::Value::Str(variant.name.clone()),
504 );
505 variant_map.insert(
506 variant.name.clone(),
507 crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
508 );
509 }
510 }
511 fm.imported_consts.insert(
512 key.clone(),
513 crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
514 );
515 fm.imported_enum_type_keys.push(key);
516 }
517 }
518}
519
520fn build_available_consts(
527 consts: &[crate::types::VarDecl],
528 imported_consts: &HashMap<String, crate::value::Value>,
529) -> HashMap<String, crate::value::Value> {
530 let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
531 for d in consts {
533 if let Some(ref v) = d.default_value {
534 available.insert(d.name.clone(), v.clone());
535 }
536 }
537 for (k, v) in imported_consts {
539 available.insert(k.clone(), v.clone());
540 }
541 available
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use crate::value::Value;
548
549 fn parse_type_annotation(s: &str) -> Result<VarType, String> {
551 let empty_aliases = HashMap::new();
552 let empty_imports = HashMap::new();
553 super::parse_type_annotation(s, &empty_aliases, &empty_imports)
554 }
555
556 fn parse_params_value(rest: &str) -> Result<Vec<VarDecl>, TemplateError> {
558 let empty_aliases = HashMap::new();
559 let empty_imports = HashMap::new();
560 let empty_consts = HashMap::new();
561 super::parse_declarations(rest, &empty_aliases, &empty_imports, false, &empty_consts)
562 }
563
564 #[test]
565 fn parse_empty_source() {
566 let err = parse_frontmatter("").unwrap_err();
567 assert!(
568 err.to_string()
569 .contains("missing mandatory YAML frontmatter block")
570 );
571 }
572
573 #[test]
574 fn parse_no_frontmatter() {
575 let source = "Hello {{ name }}!";
576 let err = parse_frontmatter(source).unwrap_err();
577 assert!(
578 err.to_string()
579 .contains("missing mandatory YAML frontmatter block")
580 );
581 }
582
583 #[test]
584 fn parse_basic_frontmatter() {
585 let source = r"---
586name: greeting
587description: A greeting template
588params: [name = str, count = int]
589---
590Hello {{ name }}!";
591 let (fm, body) = parse_frontmatter(source).unwrap();
592 assert_eq!(fm.name, Some("greeting".to_string()));
593 assert_eq!(fm.description, Some("A greeting template".to_string()));
594 assert_eq!(fm.params, vec!["name", "count"]);
595 assert_eq!(fm.declarations.len(), 2);
596 assert_eq!(fm.declarations[0].name, "name");
597 assert_eq!(fm.declarations[0].var_type, VarType::Str);
598 assert_eq!(fm.declarations[1].name, "count");
599 assert_eq!(fm.declarations[1].var_type, VarType::Int);
600 assert_eq!(body, "Hello {{ name }}!");
601 }
602
603 #[test]
604 fn reject_untyped_params() {
605 let source = r"---
606params: [a, b, c]
607---
608body";
609 let err = parse_frontmatter(source).unwrap_err();
610 assert!(err.to_string().contains("missing a type annotation"));
611 }
612
613 #[test]
614 fn parse_multiline_block_format() {
615 let source = r"---
616name: test
617params:
618 - a = str
619 - b = int
620---
621{{ a }} {{ b }}";
622 let (fm, body) = parse_frontmatter(source).unwrap();
623 assert_eq!(fm.params, vec!["a", "b"]);
624 assert_eq!(fm.declarations[0].var_type, VarType::Str);
625 assert_eq!(fm.declarations[1].var_type, VarType::Int);
626 assert_eq!(body, "{{ a }} {{ b }}");
627 }
628
629 #[test]
630 fn parse_list_with_fields() {
631 let source = r"---
632params: [items = list(title = str, score = float)]
633---
634body";
635 let (fm, _) = parse_frontmatter(source).unwrap();
636 assert_eq!(fm.declarations.len(), 1);
637 assert_eq!(fm.declarations[0].name, "items");
638 match &fm.declarations[0].var_type {
639 VarType::List(fields) => {
640 assert_eq!(fields.len(), 2);
641 assert_eq!(fields[0].name, "title");
642 assert_eq!(fields[0].var_type, VarType::Str);
643 assert_eq!(fields[1].name, "score");
644 assert_eq!(fields[1].var_type, VarType::Float);
645 }
646 other => panic!("Expected List, got {other:?}"),
647 }
648 }
649
650 #[test]
651 fn parse_struct_type() {
652 let source = r"---
653params: [config = struct(key = str, enabled = bool)]
654---
655body";
656 let (fm, _) = parse_frontmatter(source).unwrap();
657 assert_eq!(fm.declarations.len(), 1);
658 match &fm.declarations[0].var_type {
659 VarType::Struct(fields) => {
660 assert_eq!(fields.len(), 2);
661 assert_eq!(fields[0].name, "key");
662 assert_eq!(fields[0].var_type, VarType::Str);
663 assert_eq!(fields[1].name, "enabled");
664 assert_eq!(fields[1].var_type, VarType::Bool);
665 }
666 other => panic!("Expected Struct, got {other:?}"),
667 }
668 }
669
670 #[test]
671 fn reject_bare_list_type() {
672 let source = r"---
673params: [items = list]
674---
675body";
676 let err = parse_frontmatter(source).unwrap_err();
677 assert!(err.to_string().contains("unknown type"));
678 }
679
680 #[test]
681 fn parse_float_type() {
682 let source = r"---
683params: [score = float]
684---
685body";
686 let (fm, _) = parse_frontmatter(source).unwrap();
687 assert_eq!(fm.declarations[0].var_type, VarType::Float);
688 }
689
690 #[test]
691 fn parse_bool_type() {
692 let source = r"---
693params: [active = bool]
694---
695body";
696 let (fm, _) = parse_frontmatter(source).unwrap();
697 assert_eq!(fm.declarations[0].var_type, VarType::Bool);
698 }
699
700 #[test]
701 fn reject_unknown_type() {
702 let source = r"---
703params: [x = unknown_type]
704---
705body";
706 let err = parse_frontmatter(source).unwrap_err();
707 assert!(err.to_string().contains("unknown type 'unknown_type'"));
708 }
709
710 #[test]
711 fn reject_mixed_typed_and_untyped() {
712 let source = r"---
713params: [name = str, label, count = int]
714---
715body";
716 let err = parse_frontmatter(source).unwrap_err();
717 assert!(err.to_string().contains("missing a type annotation"));
718 }
719
720 #[test]
721 fn parse_empty_params_list() {
722 let source = r"---
723params: []
724---
725body";
726 let (fm, _) = parse_frontmatter(source).unwrap();
727 assert!(fm.declarations.is_empty());
728 assert!(fm.params.is_empty());
729 }
730
731 #[test]
732 fn reject_missing_blank_line_after_block_list() {
733 let source = r"---
734consts:
735 - FOO = str := 'bar'
736params:
737 - x = int
738---
739body";
740 let err = parse_frontmatter(source).unwrap_err();
741 assert!(
742 err.to_string()
743 .contains("A blank line is required after a block list"),
744 "got: {err}"
745 );
746 }
747
748 #[test]
749 fn types_only_template_no_params_block() {
750 let source = r"---
751name: types
752types:
753 - Priority = enum(High, Medium, Low)
754---
755{# no body #}";
756 let (fm, body) = parse_frontmatter(source).unwrap();
757 assert_eq!(fm.name, Some("types".to_string()));
758 assert!(fm.declarations.is_empty());
759 assert!(fm.params.is_empty());
760 assert!(!fm.has_params);
761 assert!(fm.type_aliases.contains_key("Priority"));
762 assert_eq!(body, "{# no body #}");
763 }
764
765 #[test]
766 fn frontmatter_not_at_start() {
767 let source = "some text\n---\nname: test\n---\nbody";
768 let err = parse_frontmatter(source).unwrap_err();
769 assert!(
770 err.to_string()
771 .contains("missing mandatory YAML frontmatter block")
772 );
773 }
774
775 #[test]
776 fn frontmatter_without_closing_delimiter() {
777 let source = r"---
778name: test
779no closing delimiter";
780 let err = parse_frontmatter(source).unwrap_err();
781 assert!(err.to_string().contains("unclosed YAML frontmatter block"));
782 }
783
784 #[test]
785 fn join_continuation_lines_basic() {
786 let block = "key1: val1\nkey2:\n continued\n more";
787 let lines = join_continuation_lines(block);
788 assert_eq!(lines.len(), 2);
789 assert_eq!(lines[0], "key1: val1");
790 assert!(lines[1].contains("continued"));
791 assert!(lines[1].contains("more"));
792 }
793
794 #[test]
795 fn parse_type_annotation_all_simple_types() {
796 assert_eq!(parse_type_annotation("str").unwrap(), VarType::Str);
797 assert_eq!(parse_type_annotation("bool").unwrap(), VarType::Bool);
798 assert_eq!(parse_type_annotation("int").unwrap(), VarType::Int);
799 assert_eq!(parse_type_annotation("float").unwrap(), VarType::Float);
800 parse_type_annotation("garbage").expect_err("unknown type 'garbage' should be rejected");
801 parse_type_annotation("list").expect_err("bare 'list' without <fields> should be rejected");
802 parse_type_annotation("struct")
803 .expect_err("bare 'struct' without <fields> should be rejected");
804 }
805
806 #[test]
807 fn parse_type_annotation_with_whitespace() {
808 assert_eq!(parse_type_annotation(" str ").unwrap(), VarType::Str);
809 assert_eq!(parse_type_annotation("\tint\t").unwrap(), VarType::Int);
810 }
811
812 #[test]
813 fn parse_params_complex() {
814 let rest = "[name = str, items = list(label = str, count = int), active = bool]";
815 let decls = parse_params_value(rest).unwrap();
816 assert_eq!(decls.len(), 3);
817 assert_eq!(decls[0].name, "name");
818 assert_eq!(decls[0].var_type, VarType::Str);
819 assert_eq!(decls[2].name, "active");
820 assert_eq!(decls[2].var_type, VarType::Bool);
821 match &decls[1].var_type {
822 VarType::List(fields) => {
823 assert_eq!(fields.len(), 2);
824 assert_eq!(fields[0].name, "label");
825 assert_eq!(fields[1].name, "count");
826 }
827 other => panic!("Expected List, got {other:?}"),
828 }
829 }
830
831 #[test]
832 fn parse_enum_with_associated_data() {
833 let rest = "[outcome = enum(Confirmed(evidence = list(text = str)), Inconclusive)]";
834 let decls = parse_params_value(rest).unwrap();
835 assert_eq!(decls.len(), 1);
836 assert_eq!(decls[0].name, "outcome");
837 match &decls[0].var_type {
838 VarType::Enum(variants) => {
839 assert_eq!(variants.len(), 2);
840 assert_eq!(variants[0].name, "Confirmed");
841 assert_eq!(variants[0].fields.len(), 1);
842 assert_eq!(variants[0].fields[0].name, "evidence");
843 assert_eq!(variants[1].name, "Inconclusive");
844 assert!(variants[1].fields.is_empty());
845 }
846 other => panic!("Expected Enum, got {other:?}"),
847 }
848 }
849
850 #[test]
853 fn parse_string_default() {
854 let source = r#"---
855params: [name = str := "hello world"]
856---
857body"#;
858 let (fm, _) = parse_frontmatter(source).unwrap();
859 assert_eq!(fm.declarations[0].name, "name");
860 assert_eq!(fm.declarations[0].var_type, VarType::Str);
861 assert_eq!(
862 fm.declarations[0].default_value,
863 Some(Value::Str("hello world".to_string()))
864 );
865 }
866
867 #[test]
868 fn parse_int_default() {
869 let source = r"---
870params: [count = int := 42]
871---
872body";
873 let (fm, _) = parse_frontmatter(source).unwrap();
874 assert_eq!(fm.declarations[0].var_type, VarType::Int);
875 assert_eq!(fm.declarations[0].default_value, Some(Value::Int(42)));
876 }
877
878 #[test]
879 fn parse_bool_default() {
880 let source = r"---
881params: [active = bool := true]
882---
883body";
884 let (fm, _) = parse_frontmatter(source).unwrap();
885 assert_eq!(fm.declarations[0].var_type, VarType::Bool);
886 assert_eq!(fm.declarations[0].default_value, Some(Value::Bool(true)));
887 }
888
889 #[test]
890 fn parse_float_default() {
891 let source = r"---
892params: [score = float := 3.15]
893---
894body";
895 let (fm, _) = parse_frontmatter(source).unwrap();
896 assert_eq!(fm.declarations[0].var_type, VarType::Float);
897 assert_eq!(fm.declarations[0].default_value, Some(Value::Float(3.15)));
898 }
899
900 #[test]
901 fn parse_mixed_defaults_and_required() {
902 let source = r"---
903params: [name = str, count = int := 10]
904---
905body";
906 let (fm, _) = parse_frontmatter(source).unwrap();
907 assert_eq!(fm.declarations[0].default_value, None);
908 assert_eq!(fm.declarations[1].default_value, Some(Value::Int(10)));
909 }
910
911 #[test]
912 fn default_does_not_confuse_with_inner_colons() {
913 let source = r"---
916params: [tasks = list(title = str)]
917---
918body";
919 let (fm, _) = parse_frontmatter(source).unwrap();
920 assert_eq!(fm.declarations[0].default_value, None);
921 match &fm.declarations[0].var_type {
922 VarType::List(fields) => {
923 assert_eq!(fields[0].name, "title");
924 assert_eq!(fields[0].var_type, VarType::Str);
925 }
926 other => panic!("Expected List, got {other:?}"),
927 }
928 }
929
930 #[test]
931 fn parse_default_value_types() {
932 assert_eq!(
933 parse_default_value("\"hello\""),
934 Some(Value::Str("hello".to_string()))
935 );
936 assert_eq!(
937 parse_default_value("'world'"),
938 Some(Value::Str("world".to_string()))
939 );
940 assert_eq!(parse_default_value("42"), Some(Value::Int(42)));
941 assert_eq!(parse_default_value("-1"), Some(Value::Int(-1)));
942 assert_eq!(parse_default_value("3.15"), Some(Value::Float(3.15)));
943 assert_eq!(parse_default_value("true"), Some(Value::Bool(true)));
944 assert_eq!(parse_default_value("false"), Some(Value::Bool(false)));
945 assert_eq!(parse_default_value(""), None);
946 }
947
948 #[test]
949 fn parse_block_format_with_defaults() {
950 let source = r#"---
951params:
952 - name = str
953 - count = int := 5
954 - label = str := "default"
955---
956body"#;
957 let (fm, _) = parse_frontmatter(source).unwrap();
958 assert_eq!(fm.declarations.len(), 3);
959 assert_eq!(fm.declarations[0].default_value, None);
960 assert_eq!(fm.declarations[1].default_value, Some(Value::Int(5)));
961 assert_eq!(
962 fm.declarations[2].default_value,
963 Some(Value::Str("default".to_string()))
964 );
965 }
966
967 #[test]
968 fn parse_nested_types() {
969 let source = r"---
970params: [data = list(item = struct(name = str, tags = list(label = str)))]
971---
972body";
973 let (fm, _) = parse_frontmatter(source).unwrap();
974 match &fm.declarations[0].var_type {
975 VarType::List(fields) => {
976 assert_eq!(fields[0].name, "item");
977 match &fields[0].var_type {
978 VarType::Struct(struct_fields) => {
979 assert_eq!(struct_fields[0].name, "name");
980 assert_eq!(struct_fields[0].var_type, VarType::Str);
981 match &struct_fields[1].var_type {
982 VarType::List(inner) => {
983 assert_eq!(inner[0].name, "label");
984 assert_eq!(inner[0].var_type, VarType::Str);
985 }
986 other => panic!("Expected inner List, got {other:?}"),
987 }
988 }
989 other => panic!("Expected Struct, got {other:?}"),
990 }
991 }
992 other => panic!("Expected List, got {other:?}"),
993 }
994 }
995
996 #[test]
997 fn default_value_accessor() {
998 let decl = VarDecl {
999 name: "test".to_string(),
1000 var_type: VarType::Str,
1001 default_value: Some(Value::Str("hello".to_string())),
1002 };
1003 assert_eq!(decl.default_value(), Some(&Value::Str("hello".to_string())));
1004
1005 let no_default = VarDecl {
1006 name: "test".to_string(),
1007 var_type: VarType::Int,
1008 default_value: None,
1009 };
1010 assert_eq!(no_default.default_value(), None);
1011 }
1012
1013 #[test]
1016 fn reject_int_default_for_str_type() {
1017 let source = r"---
1018params: [name = str := 42]
1019---
1020body";
1021 let err = parse_frontmatter(source).unwrap_err();
1022 assert!(
1023 err.to_string().contains("value has type"),
1024 "expected type mismatch error, got: {err}"
1025 );
1026 }
1027
1028 #[test]
1029 fn reject_str_default_for_int_type() {
1030 let source = r#"---
1031params: [count = int := "hello"]
1032---
1033body"#;
1034 let err = parse_frontmatter(source).unwrap_err();
1035 assert!(
1036 err.to_string().contains("value has type"),
1037 "expected type mismatch error, got: {err}"
1038 );
1039 }
1040
1041 #[test]
1042 fn reject_bool_default_for_float_type() {
1043 let source = r"---
1044params: [score = float := true]
1045---
1046body";
1047 let err = parse_frontmatter(source).unwrap_err();
1048 assert!(
1049 err.to_string().contains("value has type"),
1050 "expected type mismatch error, got: {err}"
1051 );
1052 }
1053
1054 #[test]
1055 fn reject_float_default_for_bool_type() {
1056 let source = r"---
1057params: [active = bool := 3.15]
1058---
1059body";
1060 let err = parse_frontmatter(source).unwrap_err();
1061 assert!(
1062 err.to_string().contains("value has type"),
1063 "expected type mismatch error, got: {err}"
1064 );
1065 }
1066
1067 #[test]
1068 fn accept_matching_int_default() {
1069 let source = r"---
1070params: [count = int := 0]
1071---
1072{{ count }}";
1073 let (fm, _) = parse_frontmatter(source).unwrap();
1074 assert_eq!(fm.declarations[0].default_value, Some(Value::Int(0)));
1075 }
1076
1077 #[test]
1078 fn accept_matching_str_default() {
1079 let source = r#"---
1080params: [name = str := "hi"]
1081---
1082{{ name }}"#;
1083 let (fm, _) = parse_frontmatter(source).unwrap();
1084 assert_eq!(
1085 fm.declarations[0].default_value,
1086 Some(Value::Str("hi".to_string()))
1087 );
1088 }
1089
1090 #[test]
1091 fn accept_matching_bool_default() {
1092 let source = r"---
1093params: [active = bool := false]
1094---
1095{{ active }}";
1096 let (fm, _) = parse_frontmatter(source).unwrap();
1097 assert_eq!(fm.declarations[0].default_value, Some(Value::Bool(false)));
1098 }
1099
1100 #[test]
1101 fn accept_matching_float_default() {
1102 let source = r"---
1103params: [score = float := -1.5]
1104---
1105{{ score }}";
1106 let (fm, _) = parse_frontmatter(source).unwrap();
1107 assert_eq!(fm.declarations[0].default_value, Some(Value::Float(-1.5)));
1108 }
1109
1110 #[test]
1111 fn reject_negative_int_for_str() {
1112 let source = r"---
1113params: [label = str := -99]
1114---
1115body";
1116 let err = parse_frontmatter(source).unwrap_err();
1117 assert!(err.to_string().contains("value has type"));
1118 }
1119
1120 #[test]
1123 fn allow_unused_suppresses_unused_type_alias() {
1124 let source = "\
1125---
1126
1127types:
1128 - Severity = enum(Low, Medium, High)
1129
1130params:
1131 - x = str
1132
1133allow_unused: true
1134---
1135type library";
1136 let (fm, _) = parse_frontmatter(source).unwrap();
1137 assert!(fm.allow_unused);
1138 assert!(fm.type_aliases.contains_key("Severity"));
1139 }
1140
1141 #[test]
1142 fn reject_unused_type_alias_without_allow_unused() {
1143 let source = "\
1146---
1147
1148types:
1149 - Config = struct(host = str, port = int)
1150
1151params:
1152 - x = str
1153---
1154{{ x }}";
1155 let err = parse_frontmatter(source).unwrap_err();
1156 assert!(
1157 err.to_string().contains("unused type alias"),
1158 "expected unused type alias error, got: {err}"
1159 );
1160 }
1161
1162 #[test]
1163 fn type_library_with_exported_types_and_params() {
1164 let source = "\
1165---
1166
1167name: types
1168types:
1169 - Labelled = enum(Known(label = str), Unknown)
1170 - Severity = enum(Informational, Low, Medium, High, Critical)
1171
1172params:
1173 - tasks = list(title = str, category = Labelled, component = Labelled)
1174 - post_types = list(tag = str)
1175
1176allow_unused: true
1177---
1178{# type library #}";
1179 let (fm, _) = parse_frontmatter(source).unwrap();
1180 assert_eq!(fm.declarations.len(), 2);
1181 assert!(fm.type_aliases.contains_key("Labelled"));
1183 assert!(
1186 fm.type_aliases.contains_key("Severity"),
1187 "Severity should remain in type_aliases with allow_unused: {:?}",
1188 fm.type_aliases.keys().collect::<Vec<_>>()
1189 );
1190 }
1191
1192 #[test]
1193 fn test_consts_referencing_previous_consts_in_list() {
1194 let source = "\
1195---
1196
1197name: test_const_ref
1198consts:
1199 - SCRATCH = str := \"scratch\"
1200 - EVIDENCE = str := \"evidence\"
1201 - DIRS = list(str) := [SCRATCH, EVIDENCE]
1202---
1203hello";
1204 let (fm, _) = parse_frontmatter(source).unwrap();
1205 assert_eq!(fm.consts.len(), 3);
1206 let dirs = fm.consts.iter().find(|d| d.name == "DIRS").unwrap();
1207 let val = dirs.default_value.as_ref().unwrap();
1208 match val {
1209 crate::value::Value::List(items) => {
1210 assert_eq!(items.len(), 2);
1211 assert_eq!(items[0], crate::value::Value::Str("scratch".to_string()));
1212 assert_eq!(items[1], crate::value::Value::Str("evidence".to_string()));
1213 }
1214 other => panic!("Expected List, got {other:?}"),
1215 }
1216 }
1217}