Skip to main content

scrybe_core/
ast.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Shawn Hartsock and contributors
3
4//! Markdown AST type definitions for Scrybe.
5//!
6//! The [`Ast`] type is built from pulldown-cmark events and represents
7//! the document structure as a tree of [`Node`]s. Rendering lives in
8//! `scrybe-render` (P1.3).
9
10use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
11
12/// A single node in the Markdown AST.
13#[derive(Debug, Clone, PartialEq)]
14pub enum Node {
15    /// A heading with a numeric level (1–6) and inline children.
16    Heading { level: u8, children: Vec<Self> },
17    /// A block of inline content.
18    Paragraph { children: Vec<Self> },
19    /// A fenced code block with an optional language tag.
20    FencedCode { lang: String, content: String },
21    /// Inline code span.
22    InlineCode { content: String },
23    /// A block quote containing block children.
24    BlockQuote { children: Vec<Self> },
25    /// An ordered or unordered list.
26    List { ordered: bool, items: Vec<Self> },
27    /// A list item containing block children.
28    ListItem { children: Vec<Self> },
29    /// Emphasised (italic) inline content.
30    Emphasis { children: Vec<Self> },
31    /// Strong (bold) inline content.
32    Strong { children: Vec<Self> },
33    /// A hyperlink.
34    Link {
35        href: String,
36        title: String,
37        children: Vec<Self>,
38    },
39    /// An image. `alt` is the flattened inline content of the bracket text
40    /// (`![alt](src)`); `title` is the optional Markdown title attribute
41    /// (`![alt](src "title")`), empty when absent.
42    Image {
43        src: String,
44        alt: String,
45        title: String,
46    },
47    /// A thematic break (`---` / `***`).
48    HorizontalRule,
49    /// A hard line break (`\\\n`).
50    HardBreak,
51    /// A soft line break (single newline in source).
52    SoftBreak,
53    /// Plain text.
54    Text(String),
55    /// Raw HTML.
56    Html(String),
57}
58
59// ---------------------------------------------------------------------------
60// Stack frame used while building the AST
61// ---------------------------------------------------------------------------
62
63#[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/// A parsed Markdown document as a tree of [`Node`]s.
79#[derive(Debug, Clone, PartialEq)]
80pub struct Ast {
81    /// Top-level nodes of the document.
82    pub nodes: Vec<Node>,
83}
84
85impl Ast {
86    /// Parses Markdown *source* into an AST.
87    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        // Each stack entry is (frame, children-accumulated-so-far).
93        let mut stack: Vec<(Frame, Vec<Node>)> = vec![(Frame::Root, Vec::new())];
94
95        // Accumulates text inside a FencedCode block before we close it.
96        let mut code_buf = String::new();
97
98        for event in parser {
99            match event {
100                // --- Opening tags ---
101                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                        // pulldown-cmark emits the alt text as inline child
148                        // events between Start(Image) and End(Image); `title`
149                        // here is the Markdown title attribute
150                        // (`![alt](src "title")`), carried separately.
151                        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                    // Ignore tags we don't model (tables, footnotes, etc.)
168                    _ => {}
169                },
170
171                // --- Closing tags ---
172                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                                // The image's alt text is its collected inline
237                                // children, flattened to plain text.
238                                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                                // Strip the trailing newline that pulldown-cmark always adds.
248                                let content = content.trim_end_matches('\n').to_string();
249                                Some(Node::FencedCode { lang, content })
250                            } else {
251                                None
252                            }
253                        }
254                        // Unmodelled end tags — discard
255                        _ => 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                // --- Leaf events ---
266                Event::Text(s) => {
267                    // If we're inside a code block, accumulate into code_buf.
268                    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                // Ignore footnote references, task list markers, etc.
302                _ => {}
303            }
304        }
305
306        // Drain the root frame.
307        let nodes = match stack.into_iter().next() {
308            Some((Frame::Root, children)) => children,
309            _ => Vec::new(),
310        };
311
312        Self { nodes }
313    }
314
315    /// Returns the title: text content of the first H1 node, if any.
316    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    /// Returns the source of every Mermaid fenced-code block, in document
329    /// order.
330    ///
331    /// A block counts as Mermaid when the first whitespace-delimited token of
332    /// its info string is `mermaid` — so ```` ```mermaid title="Flow" ```` is
333    /// matched but ```` ```mermaidjs ```` is not. The walk recurses into
334    /// headings, lists, list items, blockquotes, and paragraphs, mirroring the
335    /// linter's `visit_nodes`, so a diagram nested inside a blockquote or list
336    /// item is never missed.
337    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
344// ---------------------------------------------------------------------------
345// Helpers
346// ---------------------------------------------------------------------------
347
348fn 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
359/// Recursively collect Mermaid fenced-code sources in document order.
360fn 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
385/// Recursively collect plain text from a node list.
386fn 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// ---------------------------------------------------------------------------
404// Tests
405// ---------------------------------------------------------------------------
406
407#[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    // -----------------------------------------------------------------------
524    // Images: alt comes from child content, title is carried separately.
525    // Regression tests for the alt/title conflation bug (accessibility data
526    // loss): the parser used to store the Markdown title attribute as `alt`
527    // and discard the real bracket text.
528    // -----------------------------------------------------------------------
529
530    /// Collect every `Node::Image` in document order as (src, alt, title).
531    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("![diagram](image.png)\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("![diagram](image.png \"Architecture\")\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        // Empty alt is valid and meaningful (decorative image); the title
584        // must NOT leak into it.
585        let ast = Ast::parse("![](decorative.png \"Decoration\")\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("![**bold** label](image.png \"Title\")\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("![first](a.png \"A\") and ![second](b.png)\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("![](plain.png)\n");
624        assert_eq!(
625            images(&ast),
626            vec![("plain.png".to_string(), String::new(), String::new())]
627        );
628    }
629
630    // -----------------------------------------------------------------------
631    // mermaid_blocks
632    // -----------------------------------------------------------------------
633
634    #[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        // A fenced block indented under a list item is a child of the item;
662        // a top-level-only filter would miss it.
663        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        // Only the first whitespace token of the info string must match.
671        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        // `mermaidjs` is a different language; a bare fence is not mermaid.
679        let src = "```mermaidjs\nnope\n```\n\n```\nplain\n```\n";
680        assert!(Ast::parse(src).mermaid_blocks().is_empty());
681    }
682}