1use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
11
12#[derive(Debug, Clone, PartialEq)]
14pub enum Node {
15 Heading { level: u8, children: Vec<Self> },
17 Paragraph { children: Vec<Self> },
19 FencedCode { lang: String, content: String },
21 InlineCode { content: String },
23 BlockQuote { children: Vec<Self> },
25 List { ordered: bool, items: Vec<Self> },
27 ListItem { children: Vec<Self> },
29 Emphasis { children: Vec<Self> },
31 Strong { children: Vec<Self> },
33 Link {
35 href: String,
36 title: String,
37 children: Vec<Self>,
38 },
39 Image {
43 src: String,
44 alt: String,
45 title: String,
46 },
47 HorizontalRule,
49 HardBreak,
51 SoftBreak,
53 Text(String),
55 Html(String),
57}
58
59#[derive(Debug)]
64enum Frame {
65 Root,
66 Heading { level: u8 },
67 Paragraph,
68 BlockQuote,
69 List { ordered: bool },
70 ListItem,
71 Emphasis,
72 Strong,
73 Link { href: String, title: String },
74 Image { src: String, title: String },
75 FencedCode { lang: String },
76}
77
78#[derive(Debug, Clone, PartialEq)]
80pub struct Ast {
81 pub nodes: Vec<Node>,
83}
84
85impl Ast {
86 pub fn parse(source: &str) -> Self {
88 let opts =
89 Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES;
90 let parser = Parser::new_ext(source, opts);
91
92 let mut stack: Vec<(Frame, Vec<Node>)> = vec![(Frame::Root, Vec::new())];
94
95 let mut code_buf = String::new();
97
98 for event in parser {
99 match event {
100 Event::Start(tag) => match tag {
102 Tag::Heading { level, .. } => {
103 stack.push((
104 Frame::Heading {
105 level: heading_level(level),
106 },
107 Vec::new(),
108 ));
109 }
110 Tag::Paragraph => {
111 stack.push((Frame::Paragraph, Vec::new()));
112 }
113 Tag::BlockQuote(_) => {
114 stack.push((Frame::BlockQuote, Vec::new()));
115 }
116 Tag::List(start) => {
117 stack.push((
118 Frame::List {
119 ordered: start.is_some(),
120 },
121 Vec::new(),
122 ));
123 }
124 Tag::Item => {
125 stack.push((Frame::ListItem, Vec::new()));
126 }
127 Tag::Emphasis => {
128 stack.push((Frame::Emphasis, Vec::new()));
129 }
130 Tag::Strong => {
131 stack.push((Frame::Strong, Vec::new()));
132 }
133 Tag::Link {
134 dest_url, title, ..
135 } => {
136 stack.push((
137 Frame::Link {
138 href: dest_url.to_string(),
139 title: title.to_string(),
140 },
141 Vec::new(),
142 ));
143 }
144 Tag::Image {
145 dest_url, title, ..
146 } => {
147 stack.push((
152 Frame::Image {
153 src: dest_url.to_string(),
154 title: title.to_string(),
155 },
156 Vec::new(),
157 ));
158 }
159 Tag::CodeBlock(kind) => {
160 let lang = match kind {
161 pulldown_cmark::CodeBlockKind::Fenced(s) => s.to_string(),
162 pulldown_cmark::CodeBlockKind::Indented => String::new(),
163 };
164 code_buf.clear();
165 stack.push((Frame::FencedCode { lang }, Vec::new()));
166 }
167 _ => {}
169 },
170
171 Event::End(tag_end) => {
173 let node = match tag_end {
174 TagEnd::Heading(_) => {
175 if let Some((Frame::Heading { level }, children)) = stack.pop() {
176 Some(Node::Heading { level, children })
177 } else {
178 None
179 }
180 }
181 TagEnd::Paragraph => {
182 if let Some((Frame::Paragraph, children)) = stack.pop() {
183 Some(Node::Paragraph { children })
184 } else {
185 None
186 }
187 }
188 TagEnd::BlockQuote(_) => {
189 if let Some((Frame::BlockQuote, children)) = stack.pop() {
190 Some(Node::BlockQuote { children })
191 } else {
192 None
193 }
194 }
195 TagEnd::List(_) => {
196 if let Some((Frame::List { ordered }, items)) = stack.pop() {
197 Some(Node::List { ordered, items })
198 } else {
199 None
200 }
201 }
202 TagEnd::Item => {
203 if let Some((Frame::ListItem, children)) = stack.pop() {
204 Some(Node::ListItem { children })
205 } else {
206 None
207 }
208 }
209 TagEnd::Emphasis => {
210 if let Some((Frame::Emphasis, children)) = stack.pop() {
211 Some(Node::Emphasis { children })
212 } else {
213 None
214 }
215 }
216 TagEnd::Strong => {
217 if let Some((Frame::Strong, children)) = stack.pop() {
218 Some(Node::Strong { children })
219 } else {
220 None
221 }
222 }
223 TagEnd::Link => {
224 if let Some((Frame::Link { href, title }, children)) = stack.pop() {
225 Some(Node::Link {
226 href,
227 title,
228 children,
229 })
230 } else {
231 None
232 }
233 }
234 TagEnd::Image => {
235 if let Some((Frame::Image { src, title }, children)) = stack.pop() {
236 let alt = collect_text(&children);
239 Some(Node::Image { src, alt, title })
240 } else {
241 None
242 }
243 }
244 TagEnd::CodeBlock => {
245 if let Some((Frame::FencedCode { lang }, _)) = stack.pop() {
246 let content = std::mem::take(&mut code_buf);
247 let content = content.trim_end_matches('\n').to_string();
249 Some(Node::FencedCode { lang, content })
250 } else {
251 None
252 }
253 }
254 _ => None,
256 };
257
258 if let Some(n) = node {
259 if let Some((_, ref mut parent_children)) = stack.last_mut() {
260 parent_children.push(n);
261 }
262 }
263 }
264
265 Event::Text(s) => {
267 if matches!(stack.last(), Some((Frame::FencedCode { .. }, _))) {
269 code_buf.push_str(&s);
270 } else if let Some((_, ref mut children)) = stack.last_mut() {
271 children.push(Node::Text(s.to_string()));
272 }
273 }
274 Event::Code(s) => {
275 if let Some((_, ref mut children)) = stack.last_mut() {
276 children.push(Node::InlineCode {
277 content: s.to_string(),
278 });
279 }
280 }
281 Event::Html(s) | Event::InlineHtml(s) => {
282 if let Some((_, ref mut children)) = stack.last_mut() {
283 children.push(Node::Html(s.to_string()));
284 }
285 }
286 Event::SoftBreak => {
287 if let Some((_, ref mut children)) = stack.last_mut() {
288 children.push(Node::SoftBreak);
289 }
290 }
291 Event::HardBreak => {
292 if let Some((_, ref mut children)) = stack.last_mut() {
293 children.push(Node::HardBreak);
294 }
295 }
296 Event::Rule => {
297 if let Some((_, ref mut children)) = stack.last_mut() {
298 children.push(Node::HorizontalRule);
299 }
300 }
301 _ => {}
303 }
304 }
305
306 let nodes = match stack.into_iter().next() {
308 Some((Frame::Root, children)) => children,
309 _ => Vec::new(),
310 };
311
312 Self { nodes }
313 }
314
315 pub fn title(&self) -> Option<String> {
317 for node in &self.nodes {
318 if let Node::Heading { level: 1, children } = node {
319 let text = collect_text(children);
320 if !text.is_empty() {
321 return Some(text);
322 }
323 }
324 }
325 None
326 }
327
328 pub fn mermaid_blocks(&self) -> Vec<&str> {
338 let mut out = Vec::new();
339 collect_mermaid(&self.nodes, &mut out);
340 out
341 }
342}
343
344fn heading_level(level: HeadingLevel) -> u8 {
349 match level {
350 HeadingLevel::H1 => 1,
351 HeadingLevel::H2 => 2,
352 HeadingLevel::H3 => 3,
353 HeadingLevel::H4 => 4,
354 HeadingLevel::H5 => 5,
355 HeadingLevel::H6 => 6,
356 }
357}
358
359fn collect_mermaid<'a>(nodes: &'a [Node], out: &mut Vec<&'a str>) {
361 for node in nodes {
362 match node {
363 Node::FencedCode { lang, content } => {
364 if lang.split_whitespace().next() == Some("mermaid") {
365 out.push(content.as_str());
366 }
367 }
368 Node::Heading { children, .. }
369 | Node::Paragraph { children }
370 | Node::BlockQuote { children }
371 | Node::ListItem { children }
372 | Node::Emphasis { children }
373 | Node::Strong { children }
374 | Node::Link { children, .. } => {
375 collect_mermaid(children, out);
376 }
377 Node::List { items, .. } => {
378 collect_mermaid(items, out);
379 }
380 _ => {}
381 }
382 }
383}
384
385fn collect_text(nodes: &[Node]) -> String {
387 let mut out = String::new();
388 for node in nodes {
389 match node {
390 Node::Text(s) => out.push_str(s),
391 Node::InlineCode { content } => out.push_str(content),
392 Node::Emphasis { children }
393 | Node::Strong { children }
394 | Node::Link { children, .. } => {
395 out.push_str(&collect_text(children));
396 }
397 _ => {}
398 }
399 }
400 out
401}
402
403#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn test_heading_parsing() {
413 let ast = Ast::parse("# Hello\n\n## World\n");
414 assert_eq!(ast.nodes.len(), 2);
415 assert!(matches!(&ast.nodes[0], Node::Heading { level: 1, .. }));
416 assert!(matches!(&ast.nodes[1], Node::Heading { level: 2, .. }));
417 }
418
419 #[test]
420 fn test_heading_text_children() {
421 let ast = Ast::parse("# My Title\n");
422 if let Node::Heading { level, children } = &ast.nodes[0] {
423 assert_eq!(*level, 1);
424 assert!(matches!(&children[0], Node::Text(s) if s == "My Title"));
425 } else {
426 panic!("expected Heading");
427 }
428 }
429
430 #[test]
431 fn test_title_from_h1() {
432 let ast = Ast::parse("# The Title\n\nSome paragraph.\n");
433 assert_eq!(ast.title(), Some("The Title".to_string()));
434 }
435
436 #[test]
437 fn test_title_none_when_no_h1() {
438 let ast = Ast::parse("## Subheading only\n");
439 assert_eq!(ast.title(), None);
440 }
441
442 #[test]
443 fn test_fenced_code() {
444 let src = "```rust\nfn main() {}\n```\n";
445 let ast = Ast::parse(src);
446 assert_eq!(ast.nodes.len(), 1);
447 if let Node::FencedCode { lang, content } = &ast.nodes[0] {
448 assert_eq!(lang, "rust");
449 assert_eq!(content, "fn main() {}");
450 } else {
451 panic!("expected FencedCode, got {:?}", ast.nodes[0]);
452 }
453 }
454
455 #[test]
456 fn test_fenced_code_no_lang() {
457 let src = "```\nhello\n```\n";
458 let ast = Ast::parse(src);
459 if let Node::FencedCode { lang, content } = &ast.nodes[0] {
460 assert_eq!(lang, "");
461 assert_eq!(content, "hello");
462 } else {
463 panic!("expected FencedCode");
464 }
465 }
466
467 #[test]
468 fn test_paragraph_and_inline_code() {
469 let src = "Use `cargo test` now.\n";
470 let ast = Ast::parse(src);
471 assert_eq!(ast.nodes.len(), 1);
472 if let Node::Paragraph { children } = &ast.nodes[0] {
473 let has_inline = children
474 .iter()
475 .any(|n| matches!(n, Node::InlineCode { content } if content == "cargo test"));
476 assert!(has_inline);
477 } else {
478 panic!("expected Paragraph");
479 }
480 }
481
482 #[test]
483 fn test_nested_list() {
484 let src = "- alpha\n- beta\n";
485 let ast = Ast::parse(src);
486 assert_eq!(ast.nodes.len(), 1);
487 if let Node::List { ordered, items } = &ast.nodes[0] {
488 assert!(!ordered);
489 assert_eq!(items.len(), 2);
490 for item in items {
491 assert!(matches!(item, Node::ListItem { .. }));
492 }
493 } else {
494 panic!("expected List");
495 }
496 }
497
498 #[test]
499 fn test_ordered_list() {
500 let src = "1. one\n2. two\n";
501 let ast = Ast::parse(src);
502 if let Node::List { ordered, .. } = &ast.nodes[0] {
503 assert!(ordered);
504 } else {
505 panic!("expected List");
506 }
507 }
508
509 #[test]
510 fn test_blockquote() {
511 let src = "> a quote\n";
512 let ast = Ast::parse(src);
513 assert!(matches!(&ast.nodes[0], Node::BlockQuote { .. }));
514 }
515
516 #[test]
517 fn test_horizontal_rule() {
518 let src = "---\n";
519 let ast = Ast::parse(src);
520 assert!(matches!(&ast.nodes[0], Node::HorizontalRule));
521 }
522
523 fn images(ast: &Ast) -> Vec<(String, String, String)> {
532 fn walk(nodes: &[Node], out: &mut Vec<(String, String, String)>) {
533 for node in nodes {
534 match node {
535 Node::Image { src, alt, title } => {
536 out.push((src.clone(), alt.clone(), title.clone()));
537 }
538 Node::Heading { children, .. }
539 | Node::Paragraph { children }
540 | Node::BlockQuote { children }
541 | Node::ListItem { children }
542 | Node::Emphasis { children }
543 | Node::Strong { children }
544 | Node::Link { children, .. } => walk(children, out),
545 Node::List { items, .. } => walk(items, out),
546 _ => {}
547 }
548 }
549 }
550 let mut out = Vec::new();
551 walk(&ast.nodes, &mut out);
552 out
553 }
554
555 #[test]
556 fn test_image_alt_from_child_content_no_title() {
557 let ast = Ast::parse("\n");
558 assert_eq!(
559 images(&ast),
560 vec![(
561 "image.png".to_string(),
562 "diagram".to_string(),
563 String::new()
564 )]
565 );
566 }
567
568 #[test]
569 fn test_image_alt_and_title_carried_separately() {
570 let ast = Ast::parse("\n");
571 assert_eq!(
572 images(&ast),
573 vec![(
574 "image.png".to_string(),
575 "diagram".to_string(),
576 "Architecture".to_string()
577 )]
578 );
579 }
580
581 #[test]
582 fn test_image_empty_alt_with_title() {
583 let ast = Ast::parse("\n");
586 assert_eq!(
587 images(&ast),
588 vec![(
589 "decorative.png".to_string(),
590 String::new(),
591 "Decoration".to_string()
592 )]
593 );
594 }
595
596 #[test]
597 fn test_image_alt_flattens_nested_inline_formatting() {
598 let ast = Ast::parse("\n");
599 assert_eq!(
600 images(&ast),
601 vec![(
602 "image.png".to_string(),
603 "bold label".to_string(),
604 "Title".to_string()
605 )]
606 );
607 }
608
609 #[test]
610 fn test_two_images_in_one_paragraph() {
611 let ast = Ast::parse(" and \n");
612 assert_eq!(
613 images(&ast),
614 vec![
615 ("a.png".to_string(), "first".to_string(), "A".to_string()),
616 ("b.png".to_string(), "second".to_string(), String::new()),
617 ]
618 );
619 }
620
621 #[test]
622 fn test_image_empty_alt_no_title() {
623 let ast = Ast::parse("\n");
624 assert_eq!(
625 images(&ast),
626 vec![("plain.png".to_string(), String::new(), String::new())]
627 );
628 }
629
630 #[test]
635 fn test_mermaid_blocks_empty_when_none() {
636 let ast = Ast::parse("# Title\n\n```rust\nfn main() {}\n```\n");
637 assert!(ast.mermaid_blocks().is_empty());
638 }
639
640 #[test]
641 fn test_mermaid_blocks_ordered() {
642 let src = "```mermaid\ngraph TD; A-->B\n```\n\n\
643 Some prose.\n\n\
644 ```mermaid\nsequenceDiagram\n A->>B: hi\n```\n";
645 let ast = Ast::parse(src);
646 let blocks = ast.mermaid_blocks();
647 assert_eq!(blocks.len(), 2);
648 assert_eq!(blocks[0], "graph TD; A-->B");
649 assert!(blocks[1].starts_with("sequenceDiagram"));
650 }
651
652 #[test]
653 fn test_mermaid_blocks_nested_in_blockquote() {
654 let src = "> ```mermaid\n> graph LR; X-->Y\n> ```\n";
655 let ast = Ast::parse(src);
656 assert_eq!(ast.mermaid_blocks(), vec!["graph LR; X-->Y"]);
657 }
658
659 #[test]
660 fn test_mermaid_blocks_nested_in_list() {
661 let src = "- step one\n\n ```mermaid\n graph TD; P-->Q\n ```\n";
664 let ast = Ast::parse(src);
665 assert_eq!(ast.mermaid_blocks(), vec!["graph TD; P-->Q"]);
666 }
667
668 #[test]
669 fn test_mermaid_blocks_info_string_with_attrs() {
670 let src = "```mermaid title=\"Flow\"\ngraph TD; A-->B\n```\n";
672 let ast = Ast::parse(src);
673 assert_eq!(ast.mermaid_blocks(), vec!["graph TD; A-->B"]);
674 }
675
676 #[test]
677 fn test_mermaid_blocks_ignores_lookalike_langs() {
678 let src = "```mermaidjs\nnope\n```\n\n```\nplain\n```\n";
680 assert!(Ast::parse(src).mermaid_blocks().is_empty());
681 }
682}