1use 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 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 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 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 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 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 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 }
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 #[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}