1use crate::attrs::parse_attrs;
2use crate::error::{Diagnostic, Severity};
3use crate::types::{Attrs, Block, FrontMatter, Span, SurfDoc};
4
5#[derive(Debug, Clone)]
7pub struct ParseResult {
8 pub doc: SurfDoc,
10 pub diagnostics: Vec<Diagnostic>,
12}
13
14pub fn parse(input: &str) -> ParseResult {
19 let mut diagnostics = Vec::new();
20
21 let normalised = input.replace("\r\n", "\n");
23 let lines: Vec<&str> = normalised.split('\n').collect();
24
25 let (front_matter, body_start_line) = extract_front_matter(&lines, &normalised, &mut diagnostics);
29
30 let blocks = scan_blocks(&lines, body_start_line, &normalised, &mut diagnostics);
34
35 let blocks = blocks
39 .into_iter()
40 .map(|block| match block {
41 Block::Unknown { .. } => crate::blocks::resolve_block(block),
42 other => other,
43 })
44 .collect();
45
46 ParseResult {
47 doc: SurfDoc {
48 front_matter,
49 blocks,
50 source: normalised,
51 },
52 diagnostics,
53 }
54}
55
56fn extract_front_matter(
64 lines: &[&str],
65 source: &str,
66 diagnostics: &mut Vec<Diagnostic>,
67) -> (Option<FrontMatter>, usize) {
68 if lines.is_empty() || lines[0].trim() != "---" {
69 return (None, 0);
70 }
71
72 let mut end_idx = None;
74 for (i, line) in lines.iter().enumerate().skip(1) {
75 if line.trim() == "---" {
76 end_idx = Some(i);
77 break;
78 }
79 }
80
81 let end_idx = match end_idx {
82 Some(i) => i,
83 None => {
84 diagnostics.push(Diagnostic {
86 severity: Severity::Error,
87 message: "Front matter opened with `---` but never closed".into(),
88 span: Some(line_span(0, 0, source)),
89 code: Some("P002".into()),
90 fix: None,
91 });
92 return (None, 0);
93 }
94 };
95
96 let yaml_str: String = lines[1..end_idx].join("\n");
97 let fm_span = Span {
98 start_line: 1,
99 end_line: end_idx + 1,
100 start_offset: 0,
101 end_offset: byte_offset_end_of_line(end_idx, source),
102 };
103
104 match serde_yaml::from_str::<FrontMatter>(&yaml_str) {
105 Ok(fm) => (Some(fm), end_idx + 1),
106 Err(e) => {
107 let p005 = serde_yaml::from_str::<serde_yaml::Value>(&yaml_str)
113 .ok()
114 .as_ref()
115 .and_then(offending_front_matter_field);
116 let diagnostic = match p005 {
117 Some((field, value)) => Diagnostic {
118 severity: Severity::Warning,
119 message: p005_message(&field, &value),
120 span: Some(fm_span),
121 code: Some("P005".into()),
122 fix: None,
123 },
124 None => Diagnostic {
125 severity: Severity::Error,
126 message: format!("Failed to parse front matter YAML: {e}"),
127 span: Some(fm_span),
128 code: Some("P002".into()),
129 fix: None,
130 },
131 };
132 diagnostics.push(diagnostic);
133 (None, end_idx + 1)
134 }
135 }
136}
137
138pub(crate) fn p005_message(field: &str, value: &str) -> String {
142 format!("Front matter field '{field}' has unrecognized value '{value}'")
143}
144
145fn offending_front_matter_field(value: &serde_yaml::Value) -> Option<(String, String)> {
149 let map = value.as_mapping()?;
150 for (k, v) in map {
151 let Some(key) = k.as_str() else { continue };
152 let mut single = serde_yaml::Mapping::new();
153 single.insert(k.clone(), v.clone());
154 if serde_yaml::from_value::<FrontMatter>(serde_yaml::Value::Mapping(single)).is_err() {
155 return Some((key.to_string(), render_yaml_value(v)));
156 }
157 }
158 None
159}
160
161fn render_yaml_value(v: &serde_yaml::Value) -> String {
163 match v {
164 serde_yaml::Value::String(s) => s.clone(),
165 other => serde_yaml::to_string(other)
166 .unwrap_or_default()
167 .trim_end()
168 .replace('\n', " "),
169 }
170}
171
172struct OpenBlock {
178 name: String,
179 attrs: Attrs,
180 depth: usize, start_line: usize, start_offset: usize,
183 content_start_offset: usize, }
185
186fn scan_blocks(
192 lines: &[&str],
193 body_start: usize,
194 source: &str,
195 diagnostics: &mut Vec<Diagnostic>,
196) -> Vec<Block> {
197 let mut blocks: Vec<Block> = Vec::new();
198 let mut stack: Vec<OpenBlock> = Vec::new();
199
200 let mut md_start_line: Option<usize> = None; let mut md_start_offset: Option<usize> = None;
204
205 let mut in_fence = false;
211
212 for (idx, &line) in lines.iter().enumerate().skip(body_start) {
213 let trimmed = line.trim();
214 let line_offset = byte_offset_start_of_line(idx, source);
215
216 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
217 in_fence = !in_fence;
218 }
219
220 if let Some(close_depth) = closing_directive_depth(trimmed) {
222 if let Some(pos) = stack.iter().rposition(|b| b.depth == close_depth) {
224 while stack.len() > pos + 1 {
226 let orphan = stack.pop().unwrap();
227 diagnostics.push(Diagnostic {
228 severity: Severity::Warning,
229 message: format!(
230 "Unclosed block directive '{}' opened at line {}",
231 orphan.name, orphan.start_line
232 ),
233 span: Some(Span {
234 start_line: orphan.start_line,
235 end_line: idx + 1,
236 start_offset: orphan.start_offset,
237 end_offset: line_offset + line.len(),
238 }),
239 code: Some("P001".into()),
240 fix: None,
241 });
242 }
243
244 let open = stack.pop().unwrap(); if stack.is_empty() {
248 let content = &source[open.content_start_offset..line_offset];
249 let content = content.strip_suffix('\n').unwrap_or(content);
250
251 blocks.push(Block::Unknown {
252 name: open.name,
253 attrs: open.attrs,
254 content: content.to_string(),
255 span: Span {
256 start_line: open.start_line,
257 end_line: idx + 1,
258 start_offset: open.start_offset,
259 end_offset: line_offset + line.len(),
260 },
261 });
262
263 md_start_line = None;
264 md_start_offset = None;
265 }
266 continue;
268 }
269 if !in_fence && stack.is_empty() {
276 flush_markdown(
279 &mut blocks,
280 &mut md_start_line,
281 &mut md_start_offset,
282 idx,
283 source,
284 );
285 diagnostics.push(Diagnostic {
286 severity: Severity::Warning,
287 message: format!("Closing directive with no open block at line {}", idx + 1),
288 span: Some(line_span(idx, idx, source)),
289 code: Some("P006".into()),
290 fix: None,
291 });
292 continue;
293 }
294 }
296
297 let opener = opening_directive(trimmed).filter(|(depth, _, _)| {
302 !in_fence || directive_name_start(trimmed, *depth) == *depth
303 });
304 if let Some((depth, name, attrs_str)) = opener {
305 if !stack.is_empty()
312 && (name == "page" || name == "footer")
313 && stack[0].depth == depth
314 && stack[0].name == "page"
315 {
316 while stack.len() > 1 {
318 stack.pop();
319 }
320 let open = stack.pop().unwrap();
321
322 let content = &source[open.content_start_offset..line_offset];
323 let content = content.strip_suffix('\n').unwrap_or(content);
324
325 blocks.push(Block::Unknown {
326 name: open.name,
327 attrs: open.attrs,
328 content: content.to_string(),
329 span: Span {
330 start_line: open.start_line,
331 end_line: idx + 1,
332 start_offset: open.start_offset,
333 end_offset: line_offset,
334 },
335 });
336
337 md_start_line = None;
338 md_start_offset = None;
339 }
340
341 if stack.is_empty() {
343 flush_markdown(
344 &mut blocks,
345 &mut md_start_line,
346 &mut md_start_offset,
347 idx,
348 source,
349 );
350
351 let attrs = match parse_attrs(&attrs_str) {
352 Ok(a) => a,
353 Err(e) => {
354 diagnostics.push(Diagnostic {
355 severity: Severity::Warning,
356 message: format!("Invalid attributes on '::{}': {}", name, e),
357 span: Some(line_span(idx, idx, source)),
358 code: Some("P003".into()),
359 fix: None,
360 });
361 Attrs::new()
362 }
363 };
364
365 let is_page_family = name == "page" || name == "footer";
381 if !is_page_family && is_leaf_before_sibling(lines, idx + 1, depth) {
382 blocks.push(Block::Unknown {
383 name,
384 attrs,
385 content: String::new(),
386 span: line_span(idx, idx, source),
387 });
388
389 md_start_line = None;
390 md_start_offset = None;
391 continue;
392 }
393
394 let content_start = line_offset + line.len() + 1; let content_start = content_start.min(source.len());
396
397 stack.push(OpenBlock {
398 name,
399 attrs,
400 depth,
401 start_line: idx + 1,
402 start_offset: line_offset,
403 content_start_offset: content_start,
404 });
405 } else {
406 stack.push(OpenBlock {
410 name,
411 attrs: Attrs::new(),
412 depth,
413 start_line: idx + 1,
414 start_offset: line_offset,
415 content_start_offset: 0, });
417 }
418 continue;
419 }
420
421 if stack.is_empty() && md_start_line.is_none() {
423 md_start_line = Some(idx);
424 md_start_offset = Some(line_offset);
425 }
426 }
427
428 flush_markdown(
430 &mut blocks,
431 &mut md_start_line,
432 &mut md_start_offset,
433 lines.len(),
434 source,
435 );
436
437 while let Some(open) = stack.pop() {
440 let eof_offset = source.len();
441 let eof_line = lines.len();
442
443 diagnostics.push(Diagnostic {
444 severity: Severity::Warning,
445 message: format!(
446 "Unclosed block directive '{}' opened at line {}",
447 open.name, open.start_line
448 ),
449 span: Some(Span {
450 start_line: open.start_line,
451 end_line: eof_line,
452 start_offset: open.start_offset,
453 end_offset: eof_offset,
454 }),
455 code: Some("P001".into()),
456 fix: None,
457 });
458
459 if stack.is_empty() {
461 let content = if open.content_start_offset <= eof_offset {
462 &source[open.content_start_offset..eof_offset]
463 } else {
464 ""
465 };
466 let content = content.strip_suffix('\n').unwrap_or(content);
467
468 blocks.push(Block::Unknown {
469 name: open.name,
470 attrs: open.attrs,
471 content: content.to_string(),
472 span: Span {
473 start_line: open.start_line,
474 end_line: eof_line,
475 start_offset: open.start_offset,
476 end_offset: eof_offset,
477 },
478 });
479 }
480 }
481
482 blocks
483}
484
485fn flush_markdown(
487 blocks: &mut Vec<Block>,
488 md_start_line: &mut Option<usize>,
489 md_start_offset: &mut Option<usize>,
490 current_idx: usize,
491 source: &str,
492) {
493 if let (Some(start_idx), Some(start_off)) = (*md_start_line, *md_start_offset) {
494 let mut end_idx = current_idx.saturating_sub(1);
495
496 let source_lines: Vec<&str> = source.split('\n').collect();
498 while end_idx > start_idx && source_lines.get(end_idx).is_some_and(|l| l.trim().is_empty())
499 {
500 end_idx -= 1;
501 }
502
503 let end_offset = byte_offset_end_of_line(end_idx, source);
504 let content = &source[start_off..end_offset];
505
506 let trimmed = content.trim();
508 if !trimmed.is_empty() {
509 blocks.push(Block::Markdown {
510 content: content.to_string(),
511 span: Span {
512 start_line: start_idx + 1,
513 end_line: end_idx + 1,
514 start_offset: start_off,
515 end_offset,
516 },
517 });
518 }
519
520 *md_start_line = None;
521 *md_start_offset = None;
522 }
523}
524
525fn is_leaf_before_sibling(lines: &[&str], after_idx: usize, depth: usize) -> bool {
538 let mut nesting = 0usize;
539 for line in lines.iter().skip(after_idx) {
540 let trimmed = line.trim();
541 if let Some(close_depth) = closing_directive_depth(trimmed) {
542 if nesting == 0 && close_depth == depth {
543 return false; }
545 if nesting > 0 {
546 nesting -= 1;
547 }
548 continue;
549 }
550 if let Some((nd, _, _)) = opening_directive(trimmed) {
551 if nd == depth && nesting == 0 {
552 return true; }
554 if nd > depth {
555 nesting += 1;
556 }
557 }
558 }
559 false }
561
562pub fn closing_directive_depth(trimmed: &str) -> Option<usize> {
567 if trimmed.is_empty() {
568 return None;
569 }
570 if trimmed.chars().all(|c| c == ':') && trimmed.len() >= 2 {
572 Some(trimmed.len())
573 } else {
574 None
575 }
576}
577
578pub fn opening_directive(trimmed: &str) -> Option<(usize, String, String)> {
586 if !trimmed.starts_with("::") {
587 return None;
588 }
589
590 let depth = trimmed.chars().take_while(|&c| c == ':').count();
592 if depth < 2 {
593 return None;
594 }
595
596 let rest = trimmed[depth..].trim_start_matches([' ', '\t']);
597 if rest.is_empty() {
598 return None;
600 }
601
602 let first_char = rest.chars().next()?;
604 if !first_char.is_alphabetic() {
605 return None;
606 }
607
608 let name_end = rest
610 .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
611 .unwrap_or(rest.len());
612 let name = rest[..name_end].to_string();
613 let remainder = &rest[name_end..];
614
615 let attrs_str = if remainder.starts_with('[') {
621 if let Some(close) = find_attr_close(remainder) {
622 remainder[..=close].to_string()
623 } else {
624 remainder.to_string()
626 }
627 } else {
628 String::new()
629 };
630
631 Some((depth, name, attrs_str))
632}
633
634pub(crate) fn directive_name_start(trimmed: &str, depth: usize) -> usize {
639 let after = &trimmed[depth..];
640 depth + (after.len() - after.trim_start_matches([' ', '\t']).len())
641}
642
643pub(crate) fn find_attr_close(s: &str) -> Option<usize> {
648 let mut depth: i32 = 0;
649 let mut in_quote = false;
650 let mut escaped = false;
651 for (i, b) in s.bytes().enumerate() {
652 if in_quote {
653 if escaped {
654 escaped = false;
655 } else if b == b'\\' {
656 escaped = true;
657 } else if b == b'"' {
658 in_quote = false;
659 }
660 continue;
661 }
662 match b {
663 b'"' => in_quote = true,
664 b'[' => depth += 1,
665 b']' => {
666 depth -= 1;
667 if depth == 0 {
668 return Some(i);
669 }
670 }
671 _ => {}
672 }
673 }
674 None
675}
676
677fn byte_offset_start_of_line(idx: usize, source: &str) -> usize {
683 let mut offset = 0;
684 for (i, line) in source.split('\n').enumerate() {
685 if i == idx {
686 return offset;
687 }
688 offset += line.len() + 1; }
690 source.len()
691}
692
693fn byte_offset_end_of_line(idx: usize, source: &str) -> usize {
695 let mut offset = 0;
696 for (i, line) in source.split('\n').enumerate() {
697 offset += line.len();
698 if i == idx {
699 return offset;
700 }
701 offset += 1; }
703 source.len()
704}
705
706fn line_span(start_idx: usize, end_idx: usize, source: &str) -> Span {
708 Span {
709 start_line: start_idx + 1,
710 end_line: end_idx + 1,
711 start_offset: byte_offset_start_of_line(start_idx, source),
712 end_offset: byte_offset_end_of_line(end_idx, source),
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use pretty_assertions::assert_eq;
720
721 #[test]
722 fn parse_empty_input() {
723 let result = parse("");
724 assert!(result.doc.front_matter.is_none());
725 assert!(result.doc.blocks.is_empty());
726 assert!(result.diagnostics.is_empty());
727 }
728
729 #[test]
730 fn find_attr_close_is_quote_and_depth_aware() {
731 assert_eq!(find_attr_close("[a=b]"), Some(4));
733 let s = r#"[src="u" caption="x ] y"]"#;
735 assert_eq!(find_attr_close(s), Some(s.len() - 1));
736 let s2 = r#"[src="u" caption="HQ [[Mountain View]] 2009"]"#;
738 assert_eq!(find_attr_close(s2), Some(s2.len() - 1));
739 let s3 = r#"[k="say \"hi\" ]now"]"#;
741 assert_eq!(find_attr_close(s3), Some(s3.len() - 1));
742 assert_eq!(find_attr_close("[a=b"), None);
744 }
745
746 #[test]
747 fn opening_directive_keeps_attrs_with_bracket_in_caption() {
748 let line = r#"::figure[src="https://x/y.jpg" caption="HQ in [[Mountain View, California]], 2009"]"#;
751 let (_, name, attrs_str) = opening_directive(line).expect("directive parses");
752 assert_eq!(name, "figure");
753 let attrs = parse_attrs(&attrs_str).expect("attrs parse");
754 let str_attr = |k: &str| match attrs.get(k) {
755 Some(crate::types::AttrValue::String(s)) => Some(s.clone()),
756 _ => None,
757 };
758 assert_eq!(str_attr("src").as_deref(), Some("https://x/y.jpg"));
759 assert!(
760 str_attr("caption").unwrap_or_default().contains("Mountain View"),
761 "caption preserved"
762 );
763 }
764
765 #[test]
766 fn figure_with_wikilink_caption_renders_img_src() {
767 let src = "::figure[src=\"https://upload.wikimedia.org/x.jpg\" caption=\"HQ in [[Mountain View, California]], 2009\"]\n";
769 let frag = parse(src).doc.to_html_fragment();
770 assert!(
771 frag.contains(r#"src="https://upload.wikimedia.org/x.jpg""#),
772 "img src present in fragment: {frag}"
773 );
774 }
775
776 #[test]
777 fn parse_plain_markdown() {
778 let input = "# Hello\n\nSome text here.\n";
779 let result = parse(input);
780 assert!(result.doc.front_matter.is_none());
781 assert_eq!(result.doc.blocks.len(), 1);
782 match &result.doc.blocks[0] {
783 Block::Markdown { content, .. } => {
784 assert!(content.contains("# Hello"));
785 assert!(content.contains("Some text here."));
786 }
787 _ => panic!("Expected Markdown block"),
788 }
789 }
790
791 #[test]
792 fn parse_front_matter() {
793 let input = "---\ntitle: Test\n---\n# Hello\n";
794 let result = parse(input);
795 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
796 let fm = result.doc.front_matter.as_ref().unwrap();
797 assert_eq!(fm.title.as_deref(), Some("Test"));
798 assert_eq!(result.doc.blocks.len(), 1);
799 match &result.doc.blocks[0] {
800 Block::Markdown { content, .. } => {
801 assert!(content.contains("# Hello"));
802 }
803 _ => panic!("Expected Markdown block"),
804 }
805 }
806
807 #[test]
808 fn parse_single_block() {
809 let input = "::callout[type=warning]\nDanger!\n::\n";
810 let result = parse(input);
811 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
812 assert_eq!(result.doc.blocks.len(), 1);
813 match &result.doc.blocks[0] {
814 Block::Callout {
815 callout_type,
816 content,
817 span,
818 ..
819 } => {
820 assert_eq!(*callout_type, crate::types::CalloutType::Warning);
821 assert_eq!(content, "Danger!");
822 assert_eq!(span.start_line, 1);
823 assert_eq!(span.end_line, 3);
824 }
825 other => panic!("Expected Callout block, got {other:?}"),
826 }
827 }
828
829 #[test]
830 fn parse_two_blocks() {
831 let input = "::callout[type=info]\nFirst\n::\n\nSome markdown.\n\n::data[format=json]\n{}\n::\n";
832 let result = parse(input);
833 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
834 assert_eq!(result.doc.blocks.len(), 3);
835
836 assert!(matches!(&result.doc.blocks[0], Block::Callout { .. }));
837 assert!(matches!(&result.doc.blocks[1], Block::Markdown { .. }));
838 assert!(matches!(&result.doc.blocks[2], Block::Data { .. }));
839 }
840
841 #[test]
842 fn parse_nested_blocks() {
843 let input = "::columns\n:::column\nLeft text.\n:::\n:::column\nRight text.\n:::\n::\n";
844 let result = parse(input);
845 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
846 assert_eq!(result.doc.blocks.len(), 1);
847 match &result.doc.blocks[0] {
848 Block::Columns { columns, .. } => {
849 assert_eq!(columns.len(), 2);
850 assert!(columns[0].content.contains("Left text."));
851 assert!(columns[1].content.contains("Right text."));
852 }
853 other => panic!("Expected Columns block, got {other:?}"),
854 }
855 }
856
857 #[test]
858 fn parse_unclosed_block() {
859 let input = "::callout[type=warning]\nNo closing marker";
860 let result = parse(input);
861 assert!(!result.diagnostics.is_empty(), "Expected a diagnostic for unclosed block");
862 assert_eq!(result.doc.blocks.len(), 1);
863 match &result.doc.blocks[0] {
864 Block::Callout { content, .. } => {
865 assert!(content.contains("No closing marker"));
866 }
867 other => panic!("Expected Callout block, got {other:?}"),
868 }
869 }
870
871 #[test]
872 fn parse_leaf_directive() {
873 let input = "# Title\n\n::metric[label=\"MRR\" value=\"$2K\"]\n\n## More\n";
874 let result = parse(input);
875 assert_eq!(
884 result.doc.blocks.len(),
885 2, "blocks: {:#?}", result.doc.blocks
887 );
888 let has_metric = result.doc.blocks.iter().any(|b| matches!(b, Block::Metric { .. }));
889 assert!(has_metric, "Should contain a metric block");
890 }
891
892 #[test]
893 fn parse_block_spans() {
894 let input = "# Title\n::callout\nInside\n::\n# After\n";
895 let result = parse(input);
896 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
897
898 match &result.doc.blocks[0] {
900 Block::Markdown { span, .. } => {
901 assert_eq!(span.start_line, 1);
902 assert_eq!(span.end_line, 1);
903 }
904 _ => panic!("Expected Markdown"),
905 }
906
907 match &result.doc.blocks[1] {
909 Block::Callout { span, .. } => {
910 assert_eq!(span.start_line, 2);
911 assert_eq!(span.end_line, 4);
912 }
913 other => panic!("Expected Callout, got {other:?}"),
914 }
915
916 match &result.doc.blocks[2] {
918 Block::Markdown { span, .. } => {
919 assert_eq!(span.start_line, 5);
920 assert_eq!(span.end_line, 5);
921 }
922 _ => panic!("Expected Markdown"),
923 }
924 }
925
926 #[test]
927 fn parse_front_matter_all_fields() {
928 let input = r#"---
929title: "Full Document"
930type: plan
931status: active
932scope: workspace
933tags: [rust, parser]
934created: "2026-02-10"
935updated: "2026-02-10"
936author: "Brady Davis"
937confidence: high
938version: 2
939workspace: cloudsurf
940decision: "Use Rust"
941related:
942 - path: plans/example.md
943 relationship: references
944---
945Body.
946"#;
947 let result = parse(input);
948 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
949 let fm = result.doc.front_matter.as_ref().unwrap();
950 assert_eq!(fm.title.as_deref(), Some("Full Document"));
951 assert_eq!(fm.doc_type, Some(crate::types::DocType::Plan));
952 assert_eq!(fm.status, Some(crate::types::DocStatus::Active));
953 assert_eq!(fm.scope, Some(crate::types::Scope::Workspace));
954 assert_eq!(fm.tags.as_deref(), Some(&["rust".to_string(), "parser".to_string()][..]));
955 assert_eq!(fm.created.as_deref(), Some("2026-02-10"));
956 assert_eq!(fm.updated.as_deref(), Some("2026-02-10"));
957 assert_eq!(fm.author.as_deref(), Some("Brady Davis"));
958 assert_eq!(fm.confidence, Some(crate::types::Confidence::High));
959 assert_eq!(fm.version, Some(2));
960 assert_eq!(fm.workspace.as_deref(), Some("cloudsurf"));
961 assert_eq!(fm.decision.as_deref(), Some("Use Rust"));
962 let related = fm.related.as_ref().unwrap();
963 assert_eq!(related.len(), 1);
964 assert_eq!(related[0].path, "plans/example.md");
965 }
966
967 #[test]
968 fn p005_recovery_is_identical_to_p002_recovery() {
969 let p005_input = "---\ntitle: T\ntype: checkpoint\n---\n# Heading\n\nBody.\n";
973 let p002_input = "---\ntitle: [broken\n---\n# Heading\n\nBody.\n";
974 for (input, code) in [(p005_input, "P005"), (p002_input, "P002")] {
975 let result = parse(input);
976 assert!(result.doc.front_matter.is_none(), "{code}: fm stays None");
977 assert_eq!(result.diagnostics.len(), 1, "{code}: one diagnostic");
978 assert_eq!(result.diagnostics[0].code.as_deref(), Some(code));
979 assert_eq!(result.doc.blocks.len(), 1, "{code}: body parsed");
980 match &result.doc.blocks[0] {
981 Block::Markdown { content, .. } => {
982 assert!(content.contains("# Heading"), "{code}");
983 assert!(content.contains("Body."), "{code}");
984 }
985 other => panic!("{code}: expected Markdown block, got {other:?}"),
986 }
987 }
988 assert_eq!(parse(p005_input).diagnostics[0].severity, Severity::Warning);
990 assert_eq!(parse(p002_input).diagnostics[0].severity, Severity::Error);
991 }
992
993 #[test]
994 fn p005_names_the_offending_field_and_value() {
995 let result = parse("---\ntitle: T\nstatus: superseded\n---\nBody.\n");
996 assert_eq!(result.diagnostics.len(), 1);
997 assert_eq!(result.diagnostics[0].code.as_deref(), Some("P005"));
998 assert_eq!(
999 result.diagnostics[0].message,
1000 "Front matter field 'status' has unrecognized value 'superseded'"
1001 );
1002 }
1003
1004 #[test]
1005 fn parse_unknown_front_matter_fields() {
1006 let input = "---\ntitle: Test\ncustom_field: hello\nanother: 42\n---\n";
1007 let result = parse(input);
1008 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1009 let fm = result.doc.front_matter.as_ref().unwrap();
1010 assert_eq!(fm.title.as_deref(), Some("Test"));
1011 assert!(fm.extra.contains_key("custom_field"), "extra should contain custom_field");
1012 assert!(fm.extra.contains_key("another"), "extra should contain another");
1013 }
1014
1015 #[test]
1020 fn parse_callout_end_to_end() {
1021 let input = "::callout[type=warning]\nWatch out for sharp edges.\n::\n";
1022 let result = parse(input);
1023 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1024 assert_eq!(result.doc.blocks.len(), 1);
1025 match &result.doc.blocks[0] {
1026 Block::Callout {
1027 callout_type,
1028 content,
1029 span,
1030 ..
1031 } => {
1032 assert_eq!(*callout_type, crate::types::CalloutType::Warning);
1033 assert_eq!(content, "Watch out for sharp edges.");
1034 assert_eq!(span.start_line, 1);
1035 assert_eq!(span.end_line, 3);
1036 }
1037 other => panic!("Expected Callout block, got {other:?}"),
1038 }
1039 }
1040
1041 #[test]
1042 fn parse_metric_end_to_end() {
1043 let input = "::metric[label=\"MRR\" value=\"$2K\"]\n::\n";
1044 let result = parse(input);
1045 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1046 assert_eq!(result.doc.blocks.len(), 1);
1047 match &result.doc.blocks[0] {
1048 Block::Metric {
1049 label,
1050 value,
1051 trend,
1052 ..
1053 } => {
1054 assert_eq!(label, "MRR");
1055 assert_eq!(value, "$2K");
1056 assert!(trend.is_none());
1057 }
1058 other => panic!("Expected Metric block, got {other:?}"),
1059 }
1060 }
1061
1062 #[test]
1063 fn parse_mixed_typed_blocks() {
1064 let input = concat!(
1065 "::callout[type=info]\nFYI\n::\n",
1066 "\n# Some Markdown\n\n",
1067 "::data[format=csv]\nA, B\n1, 2\n::\n",
1068 );
1069 let result = parse(input);
1070 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1071 assert_eq!(result.doc.blocks.len(), 3, "blocks: {:#?}", result.doc.blocks);
1072
1073 assert!(matches!(&result.doc.blocks[0], Block::Callout { .. }));
1074 assert!(matches!(&result.doc.blocks[1], Block::Markdown { .. }));
1075 match &result.doc.blocks[2] {
1076 Block::Data {
1077 format,
1078 headers,
1079 rows,
1080 ..
1081 } => {
1082 assert_eq!(*format, crate::types::DataFormat::Csv);
1083 assert_eq!(headers, &["A", "B"]);
1084 assert_eq!(rows.len(), 1);
1085 }
1086 other => panic!("Expected Data block, got {other:?}"),
1087 }
1088 }
1089
1090 #[test]
1095 fn parse_multipage_with_leaf_directives() {
1096 let input = concat!(
1099 "::site\nname: Test\n::\n",
1100 "::page[route=\"/\" title=\"Home\"]\n",
1101 "# Welcome\n",
1102 "::hero-image[src=\"bg.jpg\"]\n",
1103 "::cta[label=\"Go\" href=\"/about\"]\n",
1104 "::\n",
1105 "::page[route=\"/about\" title=\"About\"]\n",
1106 "# About Us\n",
1107 "::\n",
1108 "::footer\n(c) 2026\n::\n",
1109 );
1110 let result = parse(input);
1111 let pages: Vec<_> = result
1112 .doc
1113 .blocks
1114 .iter()
1115 .filter(|b| matches!(b, Block::Page { .. }))
1116 .collect();
1117 assert_eq!(pages.len(), 2, "Expected 2 pages, got {} — blocks: {:#?}", pages.len(), result.doc.blocks);
1118 match &pages[0] {
1119 Block::Page { route, title, .. } => {
1120 assert_eq!(route, "/");
1121 assert_eq!(title.as_deref(), Some("Home"));
1122 }
1123 _ => unreachable!(),
1124 }
1125 match &pages[1] {
1126 Block::Page { route, title, .. } => {
1127 assert_eq!(route, "/about");
1128 assert_eq!(title.as_deref(), Some("About"));
1129 }
1130 _ => unreachable!(),
1131 }
1132 }
1133
1134 #[test]
1135 fn parse_multipage_children_resolved() {
1136 let input = concat!(
1139 "::page[route=\"/\"]\n",
1140 "::hero-image[src=\"bg.jpg\"]\n",
1141 "::columns\n:::column\nLeft\n:::\n:::column\nRight\n:::\n::\n",
1142 "::cta[label=\"Click\"]\n",
1143 "::\n",
1144 "::page[route=\"/next\"]\n",
1145 "# Next Page\n",
1146 "::\n",
1147 );
1148 let result = parse(input);
1149 let pages: Vec<_> = result
1150 .doc
1151 .blocks
1152 .iter()
1153 .filter(|b| matches!(b, Block::Page { .. }))
1154 .collect();
1155 assert_eq!(pages.len(), 2, "blocks: {:#?}", result.doc.blocks);
1156
1157 if let Block::Page { children, .. } = &pages[0] {
1159 let has_columns = children.iter().any(|b| matches!(b, Block::Columns { .. }));
1160 assert!(has_columns, "First page should contain a Columns block, got: {:#?}", children);
1161 }
1162 }
1163
1164 #[test]
1165 fn parse_multipage_footer_closes_last_page() {
1166 let input = concat!(
1168 "::page[route=\"/\"]\n",
1169 "# Home\n",
1170 "::hero-image[src=\"bg.jpg\"]\n",
1171 "::\n",
1172 "::footer\n(c) 2026\n::\n",
1173 );
1174 let result = parse(input);
1175 let page_count = result.doc.blocks.iter().filter(|b| matches!(b, Block::Page { .. })).count();
1176 let footer_count = result.doc.blocks.iter().filter(|b| matches!(b, Block::Footer { .. })).count();
1177 assert_eq!(page_count, 1, "blocks: {:#?}", result.doc.blocks);
1178 assert_eq!(footer_count, 1, "blocks: {:#?}", result.doc.blocks);
1179 }
1180
1181 #[test]
1182 fn parse_five_page_site() {
1183 let input = concat!(
1185 "::site\nname: Bowties\n::\n",
1186 "::nav[logo=\"Bowties\"]\n- [Home](/)\n- [Gallery](/gallery)\n::\n",
1187 "::page[route=\"/\" title=\"Home\"]\n",
1188 "::hero-image[src=\"hero.jpg\"]\n",
1189 "::cta[label=\"View\" href=\"/gallery\"]\n",
1190 "::columns\n:::column\nA\n:::\n:::column\nB\n:::\n::\n",
1191 "::testimonial[author=\"Review\"]\nGreat!\n::\n",
1192 "::cta[label=\"Go\" href=\"/contact\"]\n",
1193 "::\n",
1194 "::page[route=\"/gallery\" title=\"Gallery\"]\n",
1195 "# Gallery\n",
1196 "::gallery\n Caption A\n::\n",
1197 "::cta[label=\"Book\" href=\"/contact\"]\n",
1198 "::\n",
1199 "::page[route=\"/services\" title=\"Services\"]\n",
1200 "# Services\n",
1201 "::\n",
1202 "::page[route=\"/measurements\" title=\"Measurements\"]\n",
1203 "::form[submit=\"Submit\"]\n- Name (text) *\n::\n",
1204 "::\n",
1205 "::page[route=\"/contact\" title=\"Contact\"]\n",
1206 "# Contact\n",
1207 "::embed[src=\"https://maps.google.com\"]\n",
1208 "::form[submit=\"Send\"]\n- Name (text) *\n::\n",
1209 "::\n",
1210 "::footer\n## Links\n- [Home](/)\n::\n",
1211 );
1212 let result = parse(input);
1213 let pages: Vec<_> = result
1214 .doc
1215 .blocks
1216 .iter()
1217 .filter_map(|b| match b {
1218 Block::Page { route, .. } => Some(route.as_str()),
1219 _ => None,
1220 })
1221 .collect();
1222 assert_eq!(
1223 pages,
1224 vec!["/", "/gallery", "/services", "/measurements", "/contact"],
1225 "Expected 5 pages, got {:?} — full blocks: {:#?}",
1226 pages,
1227 result.doc.blocks
1228 );
1229 }
1230
1231 #[test]
1232 fn parse_utf8_content_no_panic() {
1233 let input = "# Café ☕\n\n::callout[type=info]\nBienvenue à notre café!\n::\n\n日本語テスト\n";
1235 let result = parse(input);
1236 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1237 let md = result.doc.blocks.iter().find(|b| matches!(b, Block::Markdown { .. }));
1239 assert!(md.is_some(), "Expected markdown block with UTF-8 heading");
1240 if let Some(Block::Markdown { content, .. }) = md {
1241 assert!(content.contains("Café ☕"), "UTF-8 heading not captured");
1242 }
1243 }
1244
1245 #[test]
1246 fn parse_utf8_in_block_directives() {
1247 let input = "::callout[type=info]\nDas ist ein Prüfung mit Ümlauten: äöüß\n::\n";
1249 let result = parse(input);
1250 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1251 assert_eq!(result.doc.blocks.len(), 1);
1252 }
1253
1254 #[test]
1255 fn parse_utf8_in_page_routes_and_titles() {
1256 let input = concat!(
1257 "::site\nname = \"カフェ\"\n::\n",
1258 "::page[route=\"/\" title=\"ホーム\"]\n# ホーム\n::\n",
1259 "::page[route=\"/über-uns\"]\n# Über Uns\n::\n",
1260 );
1261 let result = parse(input);
1262 let pages: Vec<_> = result
1263 .doc
1264 .blocks
1265 .iter()
1266 .filter_map(|b| match b {
1267 Block::Page { route, .. } => Some(route.as_str()),
1268 _ => None,
1269 })
1270 .collect();
1271 assert_eq!(pages, vec!["/", "/über-uns"]);
1272 }
1273
1274 #[test]
1275 fn parse_emoji_heavy_content() {
1276 let input = "# 🚀 Launch Day\n\n::callout[type=tip]\n💡 Remember: 大事なこと\n::\n\n🎉 We did it! 🎊\n";
1278 let result = parse(input);
1279 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1280 assert!(result.doc.blocks.len() >= 2, "Expected at least 2 blocks");
1281 }
1282
1283 #[test]
1284 fn parse_duplicate_page_routes() {
1285 let input = concat!(
1288 "::site\nname = \"Test\"\n::\n",
1289 "::page[route=\"/\"]\n# Home v1\n::\n",
1290 "::page[route=\"/\"]\n# Home v2\n::\n",
1291 );
1292 let result = parse(input);
1293 let pages: Vec<_> = result
1294 .doc
1295 .blocks
1296 .iter()
1297 .filter_map(|b| match b {
1298 Block::Page { route, .. } => Some(route.as_str()),
1299 _ => None,
1300 })
1301 .collect();
1302 assert_eq!(pages.len(), 2, "Parser should emit both duplicate pages");
1304 }
1305
1306 #[test]
1311 fn leaf_then_container_then_leaf_all_parse() {
1312 let input = concat!(
1316 "::metric[label=\"x\" value=\"42K\"]\n",
1317 "\n",
1318 "::decision[status=accepted]\n",
1319 "x\n",
1320 "::\n",
1321 "\n",
1322 "::tasks\n",
1323 );
1324 let result = parse(input);
1325 let names: Vec<&str> = result
1326 .doc
1327 .blocks
1328 .iter()
1329 .map(|b| match b {
1330 Block::Metric { .. } => "metric",
1331 Block::Decision { .. } => "decision",
1332 Block::Tasks { .. } => "tasks",
1333 _ => "other",
1334 })
1335 .collect();
1336 assert_eq!(
1337 names,
1338 vec!["metric", "decision", "tasks"],
1339 "blocks: {:?}",
1340 result.doc.blocks
1341 );
1342 }
1343
1344 #[test]
1345 fn consecutive_leaves_then_container_all_parse() {
1346 let input = concat!(
1349 "::badge[value=\"New\"]\n",
1350 "\n",
1351 "::divider[label=\"sec\"]\n",
1352 "\n",
1353 "::cta[label=\"y\"]\n",
1354 "\n",
1355 "::callout[type=info]\n",
1356 "body text\n",
1357 "::\n",
1358 );
1359 let result = parse(input);
1360 assert!(
1361 result.doc.blocks.iter().any(|b| matches!(b, Block::Badge { .. })),
1362 "missing Badge; blocks: {:?}",
1363 result.doc.blocks
1364 );
1365 assert!(
1366 result.doc.blocks.iter().any(|b| matches!(b, Block::Divider { .. })),
1367 "missing Divider; blocks: {:?}",
1368 result.doc.blocks
1369 );
1370 assert!(
1371 result.doc.blocks.iter().any(|b| matches!(b, Block::Cta { .. })),
1372 "missing Cta; blocks: {:?}",
1373 result.doc.blocks
1374 );
1375 match result.doc.blocks.iter().find(|b| matches!(b, Block::Callout { .. })) {
1376 Some(Block::Callout { content, .. }) => {
1377 assert!(content.contains("body text"), "callout body lost: {content:?}");
1378 }
1379 _ => panic!("missing Callout; blocks: {:?}", result.doc.blocks),
1380 }
1381 }
1382
1383 #[test]
1384 fn normal_container_keeps_body_intact() {
1385 let input = "::callout[type=warning]\nbody\n::\n";
1388 let result = parse(input);
1389 assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1390 assert_eq!(result.doc.blocks.len(), 1);
1391 match &result.doc.blocks[0] {
1392 Block::Callout { content, .. } => assert_eq!(content, "body"),
1393 other => panic!("Expected Callout, got {other:?}"),
1394 }
1395 }
1396
1397 #[test]
1398 #[cfg(feature = "native")]
1399 fn gfm_pipe_table_in_markdown_becomes_data_table() {
1400 let input = "Lead text.\n\n| Col A | Col B |\n|-------|-------|\n| 1 | 2 |\n";
1403 let doc = parse(input).doc;
1404 assert!(doc
1406 .blocks
1407 .iter()
1408 .any(|b| matches!(b, Block::Markdown { .. })));
1409 let native = crate::render_native::to_native_blocks(&doc);
1411 let table = native.iter().find_map(|b| match b {
1412 crate::render_native::NativeBlock::DataTable { headers, rows, .. } => {
1413 Some((headers.clone(), rows.clone()))
1414 }
1415 _ => None,
1416 });
1417 let (headers, rows) = table.expect("expected a DataTable from the pipe table");
1418 assert_eq!(headers, vec!["Col A".to_string(), "Col B".to_string()]);
1419 assert_eq!(rows, vec![vec!["1".to_string(), "2".to_string()]]);
1420 }
1421
1422 #[test]
1423 #[cfg(feature = "native")]
1424 fn pipes_without_delimiter_stay_paragraph() {
1425 let input = "Run `a | b | c` in the shell.\n";
1428 let doc = parse(input).doc;
1429 let native = crate::render_native::to_native_blocks(&doc);
1430 assert!(!native
1431 .iter()
1432 .any(|b| matches!(b, crate::render_native::NativeBlock::DataTable { .. })));
1433 assert!(native
1434 .iter()
1435 .any(|b| matches!(b, crate::render_native::NativeBlock::Markdown { .. })));
1436 }
1437
1438 #[test]
1439 fn leaf_directive_as_final_block_at_eof_emits() {
1440 let input = "# Heading\n\n::metric[label=\"x\" value=\"42K\"]\n";
1442 let result = parse(input);
1443 assert!(
1444 result.doc.blocks.iter().any(|b| matches!(b, Block::Metric { .. })),
1445 "missing Metric at EOF; blocks: {:?}",
1446 result.doc.blocks
1447 );
1448 }
1449
1450 #[test]
1455 fn opening_directive_tolerates_space_after_colons() {
1456 let input = ":: hero[title=\"About Us\" align=center]\n# Welcome\n::\n";
1459 let result = parse(input);
1460 assert_eq!(result.doc.blocks.len(), 1, "blocks: {:#?}", result.doc.blocks);
1461 match &result.doc.blocks[0] {
1462 Block::Hero { headline, .. } => {
1463 assert!(
1464 headline.as_deref().unwrap_or_default().contains("Welcome"),
1465 "headline: {headline:?}"
1466 );
1467 }
1468 other => panic!("Expected Hero block, got {other:?}"),
1469 }
1470 let (depth, name, attrs) = opening_directive(":: hero[title=\"X\"]").unwrap();
1472 assert_eq!((depth, name.as_str(), attrs.as_str()), (2, "hero", "[title=\"X\"]"));
1473 assert_eq!(directive_name_start(":: hero", 2), 3);
1474 assert_eq!(directive_name_start("::hero", 2), 2);
1475
1476 let fenced = "```\n:: hero\n```\n";
1478 let result = parse(fenced);
1479 assert!(
1480 !result.doc.blocks.iter().any(|b| matches!(b, Block::Hero { .. })),
1481 "fenced tolerant opener must not open a block: {:#?}",
1482 result.doc.blocks
1483 );
1484 match &result.doc.blocks[0] {
1485 Block::Markdown { content, .. } => assert!(content.contains(":: hero")),
1486 other => panic!("Expected Markdown block, got {other:?}"),
1487 }
1488 }
1489
1490 #[test]
1491 fn orphan_top_level_closer_consumed_p006() {
1492 let input = "::callout[type=info]\nHi\n::\n::\n\nAfter text.\n";
1495 let result = parse(input);
1496 let p006: Vec<_> = result
1497 .diagnostics
1498 .iter()
1499 .filter(|d| d.code.as_deref() == Some("P006"))
1500 .collect();
1501 assert_eq!(p006.len(), 1, "diagnostics: {:?}", result.diagnostics);
1502 assert_eq!(p006[0].severity, Severity::Warning);
1503 for block in &result.doc.blocks {
1504 if let Block::Markdown { content, .. } = block {
1505 assert!(
1506 !content.lines().any(|l| l.trim() == "::"),
1507 "orphan closer leaked into markdown: {content:?}"
1508 );
1509 assert!(content.contains("After text."), "copy survives");
1510 }
1511 }
1512 let fenced = "```\n::\n```\n";
1514 let result = parse(fenced);
1515 assert!(
1516 result.diagnostics.iter().all(|d| d.code.as_deref() != Some("P006")),
1517 "no P006 for a fenced closer: {:?}",
1518 result.diagnostics
1519 );
1520 match &result.doc.blocks[0] {
1521 Block::Markdown { content, .. } => assert!(content.contains("::")),
1522 other => panic!("Expected Markdown block, got {other:?}"),
1523 }
1524 }
1525}