Skip to main content

rsmarkdown_core/
parse.rs

1//! Markdown string -> AST conversion on top of pulldown-cmark.
2//! This is the Rust counterpart of `mdast-util-from-markdown` used by the original.
3
4use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
5
6use crate::ast::{Alignment, Ast, Block, Inline, ListItem};
7
8pub fn parse_block(markdown: &str) -> Ast {
9    let mut options = Options::empty();
10    options.insert(Options::ENABLE_TABLES);
11    options.insert(Options::ENABLE_STRIKETHROUGH);
12    options.insert(Options::ENABLE_TASKLISTS);
13    options.insert(Options::ENABLE_FOOTNOTES);
14    options.insert(Options::ENABLE_MATH);
15
16    let markdown = crate::scan::join_multiline_math(markdown);
17    let parser = Parser::new_ext(&markdown, options);
18    let mut builder = Builder::default();
19    for event in parser {
20        builder.push(event);
21    }
22    builder.finish()
23}
24
25struct Builder {
26    stack: Vec<Frame>,
27}
28
29impl Default for Builder {
30    fn default() -> Self {
31        Self {
32            stack: vec![Frame::Root(Vec::new())],
33        }
34    }
35}
36
37enum Frame {
38    Root(Vec<Block>),
39    Paragraph(Vec<Inline>),
40    Heading {
41        level: u8,
42        children: Vec<Inline>,
43    },
44    BlockQuote(Vec<Block>),
45    CodeBlock {
46        lang: String,
47        text: String,
48    },
49    List {
50        ordered: bool,
51        start: u64,
52        items: Vec<ListItem>,
53    },
54    Item {
55        checked: Option<bool>,
56        pending: Option<Vec<Inline>>,
57        children: Vec<Block>,
58    },
59    Table {
60        aligns: Vec<Alignment>,
61        head: Vec<Vec<Inline>>,
62        body: Vec<Vec<Vec<Inline>>>,
63        in_head: bool,
64    },
65    Footnote {
66        label: String,
67        children: Vec<Block>,
68    },
69    Strong(Vec<Inline>),
70    Emphasis(Vec<Inline>),
71    Strikethrough(Vec<Inline>),
72    Link {
73        text: Vec<Inline>,
74        url: String,
75    },
76    Image {
77        text: Vec<Inline>,
78        url: String,
79    },
80    Cell(Vec<Inline>),
81    Row(Vec<Vec<Inline>>),
82}
83
84fn is_block_frame(f: &Frame) -> bool {
85    matches!(
86        f,
87        Frame::Root(_) | Frame::BlockQuote(_) | Frame::Item { .. } | Frame::Footnote { .. }
88    )
89}
90
91fn is_inline_frame(f: &Frame) -> bool {
92    matches!(
93        f,
94        Frame::Paragraph(_)
95            | Frame::Heading { .. }
96            | Frame::Strong(_)
97            | Frame::Emphasis(_)
98            | Frame::Strikethrough(_)
99            | Frame::Link { .. }
100            | Frame::Cell(_)
101    )
102}
103
104impl Builder {
105    fn push(&mut self, event: Event) {
106        match event {
107            Event::Start(tag) => self.start(tag),
108            Event::End(tag_end) => self.end(tag_end),
109            Event::Text(text) => {
110                let text = text.into_string();
111                if let Some(Frame::CodeBlock { text: buf, .. }) = self.stack.last_mut() {
112                    buf.push_str(&text);
113                } else {
114                    self.inline(Inline::Text(text));
115                }
116            }
117            Event::Code(code) => self.inline(Inline::Code(code.into_string())),
118            Event::InlineMath(m) => self.inline(Inline::Math(m.into_string(), false)),
119            Event::DisplayMath(m) => self.inline(Inline::Math(m.into_string(), true)),
120            Event::Html(html) => self.html(html.into_string()),
121            Event::InlineHtml(html) => self.inline(Inline::Html(html.into_string())),
122            Event::FootnoteReference(label) => {
123                self.inline(Inline::FootnoteRef(label.into_string()))
124            }
125            Event::SoftBreak => self.inline(Inline::SoftBreak),
126            Event::HardBreak => self.inline(Inline::HardBreak),
127            Event::Rule => self.block(Block::ThematicBreak),
128            Event::TaskListMarker(checked) => {
129                // attach to the nearest Item frame
130                if let Some(Frame::Item { checked: slot, .. }) = self.stack.last_mut() {
131                    *slot = Some(checked);
132                }
133            }
134        }
135    }
136
137    fn start(&mut self, tag: Tag) {
138        match tag {
139            Tag::Paragraph => self.stack.push(Frame::Paragraph(Vec::new())),
140            Tag::Heading { level, .. } => self.stack.push(Frame::Heading {
141                level: match level {
142                    HeadingLevel::H1 => 1,
143                    HeadingLevel::H2 => 2,
144                    HeadingLevel::H3 => 3,
145                    HeadingLevel::H4 => 4,
146                    HeadingLevel::H5 => 5,
147                    HeadingLevel::H6 => 6,
148                },
149                children: Vec::new(),
150            }),
151            Tag::BlockQuote(_) => self.stack.push(Frame::BlockQuote(Vec::new())),
152            Tag::CodeBlock(kind) => {
153                let lang = match kind {
154                    CodeBlockKind::Fenced(lang) => lang.into_string(),
155                    CodeBlockKind::Indented => String::new(),
156                };
157                self.stack.push(Frame::CodeBlock {
158                    lang,
159                    text: String::new(),
160                });
161            }
162            Tag::List(start) => self.stack.push(Frame::List {
163                ordered: start.is_some(),
164                start: start.unwrap_or(1),
165                items: Vec::new(),
166            }),
167            Tag::Item => self.stack.push(Frame::Item {
168                checked: None,
169                pending: None,
170                children: Vec::new(),
171            }),
172            Tag::FootnoteDefinition(label) => {
173                self.stack.push(Frame::Footnote {
174                    label: label.into_string(),
175                    children: Vec::new(),
176                });
177            }
178            Tag::Table(aligns) => self.stack.push(Frame::Table {
179                aligns: aligns
180                    .iter()
181                    .map(|a| match a {
182                        pulldown_cmark::Alignment::None => Alignment::None,
183                        pulldown_cmark::Alignment::Left => Alignment::Left,
184                        pulldown_cmark::Alignment::Center => Alignment::Center,
185                        pulldown_cmark::Alignment::Right => Alignment::Right,
186                    })
187                    .collect(),
188                head: Vec::new(),
189                body: Vec::new(),
190                in_head: false,
191            }),
192            Tag::TableHead => {
193                if let Some(Frame::Table { in_head, .. }) = self.stack.last_mut() {
194                    *in_head = true;
195                }
196            }
197            Tag::TableRow => self.stack.push(Frame::Row(Vec::new())),
198            Tag::TableCell => self.stack.push(Frame::Cell(Vec::new())),
199            Tag::Strong => self.stack.push(Frame::Strong(Vec::new())),
200            Tag::Emphasis => self.stack.push(Frame::Emphasis(Vec::new())),
201            Tag::Strikethrough => self.stack.push(Frame::Strikethrough(Vec::new())),
202            Tag::Link { dest_url, .. } => {
203                self.stack.push(Frame::Link {
204                    text: Vec::new(),
205                    url: dest_url.into_string(),
206                });
207            }
208            Tag::Image { dest_url, .. } => {
209                self.stack.push(Frame::Image {
210                    text: Vec::new(),
211                    url: dest_url.into_string(),
212                });
213            }
214            _ => {}
215        }
216    }
217
218    fn end(&mut self, tag_end: TagEnd) {
219        // TagEnd::TableHead does not correspond to a pushed frame (header cells
220        // are collected straight into the Table frame).
221        if matches!(tag_end, TagEnd::TableHead) {
222            if let Some(Frame::Table { in_head, .. }) = self.stack.last_mut() {
223                *in_head = false;
224            }
225            return;
226        }
227        let finished = self.stack.pop();
228        match (tag_end, finished) {
229            (TagEnd::Paragraph, Some(Frame::Paragraph(children))) => {
230                self.block(Block::Paragraph(children))
231            }
232            (TagEnd::Heading(_), Some(Frame::Heading { level, children })) => {
233                self.block(Block::Heading { level, children });
234            }
235            (TagEnd::BlockQuote(_), Some(Frame::BlockQuote(children))) => {
236                self.block(Block::BlockQuote(children))
237            }
238            (TagEnd::CodeBlock, Some(Frame::CodeBlock { lang, text })) => {
239                self.block(Block::Code { lang, text })
240            }
241            (
242                TagEnd::List(_),
243                Some(Frame::List {
244                    ordered,
245                    start,
246                    items,
247                }),
248            ) => self.block(Block::List {
249                ordered,
250                start,
251                items,
252            }),
253            (
254                TagEnd::Item,
255                Some(Frame::Item {
256                    checked,
257                    pending,
258                    mut children,
259                }),
260            ) => {
261                if let Some(paragraph) = pending {
262                    children.push(Block::Paragraph(paragraph));
263                }
264                if let Some(Frame::List { items, .. }) = self.stack.last_mut() {
265                    items.push(ListItem { checked, children });
266                }
267            }
268            (TagEnd::FootnoteDefinition, Some(Frame::Footnote { label, children })) => {
269                self.block(Block::FootnoteDefinition { label, children });
270            }
271            (
272                TagEnd::Table,
273                Some(Frame::Table {
274                    aligns, head, body, ..
275                }),
276            ) => {
277                self.block(Block::Table {
278                    headers: head,
279                    rows: body,
280                    aligns,
281                });
282            }
283
284            (TagEnd::TableRow, Some(Frame::Row(row))) => {
285                if let Some(Frame::Table {
286                    head,
287                    body,
288                    in_head,
289                    ..
290                }) = self.stack.last_mut()
291                {
292                    if *in_head {
293                        for cell in row {
294                            head.push(cell);
295                        }
296                    } else {
297                        body.push(row);
298                    }
299                }
300            }
301            (TagEnd::TableCell, Some(Frame::Cell(cell))) => match self.stack.last_mut() {
302                // header cells arrive without a wrapping TableRow frame
303                Some(Frame::Row(row)) => row.push(cell),
304                Some(Frame::Table { head, in_head, .. }) if *in_head => head.push(cell),
305                _ => {}
306            },
307            (TagEnd::Strong, Some(Frame::Strong(children))) => {
308                self.inline(Inline::Strong(children))
309            }
310            (TagEnd::Emphasis, Some(Frame::Emphasis(children))) => {
311                self.inline(Inline::Emphasis(children))
312            }
313            (TagEnd::Strikethrough, Some(Frame::Strikethrough(children))) => {
314                self.inline(Inline::Strikethrough(children));
315            }
316            (TagEnd::Link, Some(Frame::Link { text, url })) => {
317                self.inline(Inline::Link { text, url })
318            }
319            (TagEnd::Image, Some(Frame::Image { text, url })) => {
320                self.inline(Inline::Image {
321                    alt: plain_text(&text),
322                    url,
323                });
324            }
325            _ => {}
326        }
327    }
328
329    fn inline(&mut self, inline: Inline) {
330        // find the nearest inline-capable frame
331        match self.stack.iter_mut().rev().find(|f| is_inline_frame(f)) {
332            Some(Frame::Paragraph(children)) => children.push(inline),
333            Some(Frame::Heading { children, .. }) => children.push(inline),
334            Some(Frame::Strong(children)) => children.push(inline),
335            Some(Frame::Emphasis(children)) => children.push(inline),
336            Some(Frame::Strikethrough(children)) => children.push(inline),
337            Some(Frame::Link { text, .. }) => text.push(inline),
338            Some(Frame::Image { text, .. }) => text.push(inline),
339            Some(Frame::Cell(cell)) => cell.push(inline),
340            _ => {
341                // no inline frame: attach to the enclosing list item (task lists
342                // emit bare text without a Paragraph tag), else start an implicit paragraph
343                match self.stack.last_mut() {
344                    Some(Frame::Item { pending, .. }) => {
345                        if let Some(buf) = pending {
346                            buf.push(inline);
347                        } else {
348                            *pending = Some(vec![inline]);
349                        }
350                    }
351                    _ => self.stack.push(Frame::Paragraph(vec![inline])),
352                }
353            }
354        }
355    }
356
357    fn html(&mut self, html: String) {
358        if let Some(top) = self.stack.last_mut() {
359            if is_inline_frame(top) {
360                self.inline(Inline::Html(html));
361                return;
362            }
363        }
364        self.block(Block::Html(html));
365    }
366
367    /// Append a block to the nearest block-capable frame.
368    fn block(&mut self, block: Block) {
369        match self.stack.iter_mut().rev().find(|f| is_block_frame(f)) {
370            Some(Frame::Root(children)) => children.push(block),
371            Some(Frame::BlockQuote(children)) => children.push(block),
372            Some(Frame::Item { children, .. }) => children.push(block),
373            Some(Frame::Footnote { children, .. }) => children.push(block),
374            _ => {
375                // no block frame (stray block) — ignore
376            }
377        }
378    }
379
380    fn finish(mut self) -> Ast {
381        while let Some(frame) = self.stack.pop() {
382            if let Frame::Root(children) = frame {
383                return Ast { children };
384            }
385        }
386        Ast::default()
387    }
388}
389
390fn plain_text(inlines: &[Inline]) -> String {
391    let mut out = String::new();
392    for i in inlines {
393        match i {
394            Inline::Text(t) => out.push_str(t),
395            Inline::Code(c) => out.push_str(c),
396            Inline::Strong(c) | Inline::Emphasis(c) | Inline::Strikethrough(c) => {
397                out.push_str(&plain_text(c))
398            }
399            Inline::Link { text, .. } => out.push_str(&plain_text(text)),
400            Inline::SoftBreak | Inline::HardBreak => out.push('\n'),
401            Inline::Math(m, _) => out.push_str(m),
402            Inline::Html(h) => out.push_str(h),
403            Inline::Image { alt, .. } => out.push_str(alt),
404            Inline::FootnoteRef(l) => out.push_str(l),
405        }
406    }
407    out
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn paragraph_and_inline() {
416        let ast = parse_block("hello **world** and `code`");
417        assert_eq!(ast.children.len(), 1);
418        if let Block::Paragraph(inlines) = &ast.children[0] {
419            assert_eq!(inlines.len(), 4);
420            assert!(matches!(inlines[1], Inline::Strong(_)));
421            assert!(matches!(inlines[3], Inline::Code(_)));
422        } else {
423            panic!("expected paragraph");
424        }
425    }
426
427    #[test]
428    fn heading_code_table() {
429        let ast =
430            parse_block("# Title\n\n```rust\nfn main() {}\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |");
431        assert_eq!(ast.children.len(), 3);
432        assert!(matches!(ast.children[0], Block::Heading { level: 1, .. }));
433        assert!(matches!(&ast.children[1], Block::Code { lang, .. } if lang == "rust"));
434        assert!(matches!(&ast.children[2], Block::Table { .. }));
435    }
436
437    #[test]
438    fn task_list() {
439        let ast = parse_block("- [x] done\n- [ ] todo");
440        if let Block::List { ordered, items, .. } = &ast.children[0] {
441            assert!(!ordered);
442            assert_eq!(items.len(), 2);
443            assert_eq!(items[0].checked, Some(true));
444            assert_eq!(items[1].checked, Some(false));
445        } else {
446            panic!("expected list");
447        }
448    }
449
450    fn only_math(ast: Ast) -> String {
451        assert_eq!(ast.children.len(), 1, "{:?}", ast.children);
452        let Block::Paragraph(inlines) = &ast.children[0] else {
453            panic!("expected a paragraph, got {:?}", ast.children[0]);
454        };
455        let [Inline::Math(math, true)] = inlines.as_slice() else {
456            panic!("expected one display math inline, got {inlines:?}");
457        };
458        math.clone()
459    }
460
461    /// A display formula laid out over several lines used to fall apart: a lone
462    /// `=` line made a setext heading of the line above, `+ c` made a list item,
463    /// and what was left parsed as text with its escapes eaten.
464    #[test]
465    fn display_math_survives_block_syntax_on_its_own_lines() {
466        let math = only_math(parse_block(
467            "$$ \\boxed{ Z[J]\n=\n\\int \\phi\\, \\left\\{ x \\right\\} } $$",
468        ));
469        assert_eq!(
470            math,
471            " \\boxed{ Z[J] = \\int \\phi\\, \\left\\{ x \\right\\} } "
472        );
473        let math = only_math(parse_block("$$ \\frac{a}{b}\n+ c $$"));
474        assert_eq!(math, " \\frac{a}{b} + c ");
475        let math = only_math(parse_block("$$\nE = mc^2\n$$"));
476        assert_eq!(math, " E = mc^2 ");
477    }
478
479    #[test]
480    fn display_math_keeps_its_neighbours() {
481        let ast = parse_block("before\n\n$$\nE = mc^2\n$$\n\nafter");
482        assert_eq!(ast.children.len(), 3, "{:?}", ast.children);
483        assert!(
484            matches!(&ast.children[0], Block::Paragraph(p) if matches!(p.as_slice(), [Inline::Text(t)] if t == "before"))
485        );
486        assert!(
487            matches!(&ast.children[1], Block::Paragraph(p) if matches!(p.as_slice(), [Inline::Math(_, true)]))
488        );
489        assert!(
490            matches!(&ast.children[2], Block::Paragraph(p) if matches!(p.as_slice(), [Inline::Text(t)] if t == "after"))
491        );
492    }
493
494    #[test]
495    fn code_blocks_keep_their_dollar_lines() {
496        let ast = parse_block("```\n$$\nx\n$$\n```");
497        assert!(
498            matches!(&ast.children[0], Block::Code { text, .. } if text == "$$\nx\n$$\n"),
499            "{:?}",
500            ast.children
501        );
502    }
503}