1use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
5
6#[derive(Debug, Clone, Default, PartialEq)]
8pub struct Run {
9 pub text: String,
10 pub bold: bool,
11 pub italic: bool,
12 pub code: bool,
13 pub strike: bool,
14 pub link: Option<String>,
15}
16
17#[derive(Debug, Clone, PartialEq)]
19pub enum Block {
20 Heading {
21 level: u8,
22 runs: Vec<Run>,
23 },
24 Paragraph {
25 runs: Vec<Run>,
26 },
27 CodeBlock {
28 text: String,
29 },
30 BlockQuote {
31 blocks: Vec<Block>,
32 },
33 List {
34 start: Option<u64>,
35 items: Vec<ListItem>,
36 },
37 Table {
38 aligns: Vec<CellAlign>,
39 head: Vec<Vec<Run>>,
40 rows: Vec<Vec<Vec<Run>>>,
41 },
42 Rule,
43 Image {
44 path: String,
45 },
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub struct ListItem {
51 pub task: Option<bool>,
52 pub blocks: Vec<Block>,
53}
54
55#[derive(Clone, Copy, Debug, PartialEq)]
57pub enum CellAlign {
58 Default,
59 Left,
60 Center,
61 Right,
62}
63
64pub(crate) fn parse_blocks(md: &str) -> (Vec<Block>, u32) {
67 let options =
68 Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS;
69 let mut builder = Builder::new();
70 for event in Parser::new_ext(md, options) {
71 builder.event(event);
72 }
73 builder.finish()
74}
75
76#[derive(Default)]
77struct Builder {
78 stack: Vec<Vec<Block>>,
79 runs: Vec<Run>,
80 leaf: Leaf,
81 bold: u32,
82 italic: u32,
83 strike: u32,
84 links: Vec<String>,
85 image_depth: u32,
86 pending_images: Vec<String>,
87 lists: Vec<ListContext>,
88 tables: Vec<TableContext>,
89 skipped_html: u32,
90}
91
92#[derive(Default, PartialEq)]
93enum Leaf {
94 #[default]
95 None,
96 Paragraph,
97 Heading(u8),
98 Code(String),
99 Cell,
100}
101
102#[derive(Default)]
103struct ListContext {
104 start: Option<u64>,
105 items: Vec<ListItem>,
106 pending_task: Option<bool>,
107}
108
109#[derive(Default)]
110struct TableContext {
111 aligns: Vec<CellAlign>,
112 head: Vec<Vec<Run>>,
113 rows: Vec<Vec<Vec<Run>>>,
114 row: Vec<Vec<Run>>,
115 in_head: bool,
116}
117
118impl Builder {
119 fn new() -> Builder {
120 Builder {
121 stack: vec![Vec::new()],
122 ..Builder::default()
123 }
124 }
125
126 fn event(&mut self, event: Event<'_>) {
127 match event {
128 Event::Start(Tag::Paragraph) => self.leaf = Leaf::Paragraph,
129 Event::End(TagEnd::Paragraph) => self.flush_paragraph(),
130 Event::Start(Tag::Heading { level, .. }) => {
131 self.flush_loose_runs();
132 self.leaf = Leaf::Heading(heading_level(level));
133 }
134 Event::End(TagEnd::Heading(_)) => {
135 let Leaf::Heading(level) = std::mem::take(&mut self.leaf) else {
136 return;
137 };
138 let runs = std::mem::take(&mut self.runs);
139 self.push_block(Block::Heading { level, runs });
140 }
141 Event::Start(Tag::CodeBlock(_)) => {
142 self.flush_loose_runs();
143 self.leaf = Leaf::Code(String::new());
144 }
145 Event::End(TagEnd::CodeBlock) => {
146 let Leaf::Code(text) = std::mem::take(&mut self.leaf) else {
147 return;
148 };
149 self.push_block(Block::CodeBlock {
150 text: text.trim_end_matches('\n').to_string(),
151 });
152 }
153 Event::Start(Tag::BlockQuote(_)) => {
154 self.flush_loose_runs();
155 self.stack.push(Vec::new());
156 }
157 Event::End(TagEnd::BlockQuote(_)) => {
158 let blocks = self.stack.pop().unwrap_or_default();
159 self.push_block(Block::BlockQuote { blocks });
160 }
161 Event::Start(Tag::List(start)) => {
162 self.flush_loose_runs();
163 self.lists.push(ListContext {
164 start,
165 ..ListContext::default()
166 });
167 }
168 Event::End(TagEnd::List(_)) => {
169 let Some(list) = self.lists.pop() else { return };
170 self.push_block(Block::List {
171 start: list.start,
172 items: list.items,
173 });
174 }
175 Event::Start(Tag::Item) => {
176 self.flush_loose_runs();
177 self.stack.push(Vec::new());
178 }
179 Event::End(TagEnd::Item) => {
180 self.flush_loose_runs();
181 let blocks = self.stack.pop().unwrap_or_default();
182 let Some(list) = self.lists.last_mut() else {
183 return;
184 };
185 list.items.push(ListItem {
186 task: list.pending_task.take(),
187 blocks,
188 });
189 }
190 Event::TaskListMarker(checked) => {
191 let Some(list) = self.lists.last_mut() else {
192 return;
193 };
194 list.pending_task = Some(checked);
195 }
196 Event::Start(Tag::Table(aligns)) => {
197 self.flush_loose_runs();
198 self.tables.push(TableContext {
199 aligns: aligns.iter().map(cell_align).collect(),
200 ..TableContext::default()
201 });
202 }
203 Event::End(TagEnd::Table) => {
204 let Some(table) = self.tables.pop() else {
205 return;
206 };
207 self.push_block(Block::Table {
208 aligns: table.aligns,
209 head: table.head,
210 rows: table.rows,
211 });
212 }
213 Event::Start(Tag::TableHead) => self.set_in_head(true),
214 Event::End(TagEnd::TableHead) => {
215 let Some(table) = self.tables.last_mut() else {
216 return;
217 };
218 table.head = std::mem::take(&mut table.row);
219 table.in_head = false;
220 }
221 Event::Start(Tag::TableRow) => {}
222 Event::End(TagEnd::TableRow) => self.close_row(),
223 Event::Start(Tag::TableCell) => self.leaf = Leaf::Cell,
224 Event::End(TagEnd::TableCell) => {
225 self.leaf = Leaf::None;
226 let runs = std::mem::take(&mut self.runs);
227 let Some(table) = self.tables.last_mut() else {
228 return;
229 };
230 table.row.push(runs);
231 }
232 Event::Start(Tag::Emphasis) => self.italic += 1,
233 Event::End(TagEnd::Emphasis) => self.italic = self.italic.saturating_sub(1),
234 Event::Start(Tag::Strong) => self.bold += 1,
235 Event::End(TagEnd::Strong) => self.bold = self.bold.saturating_sub(1),
236 Event::Start(Tag::Strikethrough) => self.strike += 1,
237 Event::End(TagEnd::Strikethrough) => self.strike = self.strike.saturating_sub(1),
238 Event::Start(Tag::Link { dest_url, .. }) => self.links.push(dest_url.to_string()),
239 Event::End(TagEnd::Link) => {
240 self.links.pop();
241 }
242 Event::Start(Tag::Image { dest_url, .. }) => {
243 self.image_depth += 1;
244 self.pending_images.push(dest_url.to_string());
245 }
246 Event::End(TagEnd::Image) => self.image_depth = self.image_depth.saturating_sub(1),
247 Event::Text(text) => self.text(&text),
248 Event::Code(text) => self.code(&text),
249 Event::SoftBreak => self.text(" "),
250 Event::HardBreak => self.text("\n"),
251 Event::Rule => {
252 self.flush_loose_runs();
253 self.push_block(Block::Rule);
254 }
255 Event::Html(_) | Event::InlineHtml(_) => self.skipped_html += 1,
256 _ => {}
257 }
258 }
259
260 fn text(&mut self, text: &str) {
261 if self.image_depth > 0 {
262 return;
263 }
264 if let Leaf::Code(buffer) = &mut self.leaf {
265 buffer.push_str(text);
266 return;
267 }
268 self.run(text, false);
269 }
270
271 fn code(&mut self, text: &str) {
272 if self.image_depth > 0 {
273 return;
274 }
275 self.run(text, true);
276 }
277
278 fn run(&mut self, text: &str, code: bool) {
279 self.runs.push(Run {
280 text: text.to_string(),
281 bold: self.bold > 0,
282 italic: self.italic > 0,
283 code,
284 strike: self.strike > 0,
285 link: self.links.last().cloned(),
286 });
287 }
288
289 fn push_block(&mut self, block: Block) {
290 let Some(frame) = self.stack.last_mut() else {
291 return;
292 };
293 frame.push(block);
294 }
295
296 fn flush_paragraph(&mut self) {
297 self.leaf = Leaf::None;
298 let runs = std::mem::take(&mut self.runs);
299 let blank = runs.iter().all(|run| run.text.trim().is_empty());
300 if blank && self.pending_images.len() == 1 {
301 let Some(path) = self.pending_images.pop() else {
302 return;
303 };
304 self.push_block(Block::Image { path });
305 return;
306 }
307 if !runs.is_empty() {
308 self.push_block(Block::Paragraph { runs });
309 }
310 let images = std::mem::take(&mut self.pending_images);
311 for path in images {
312 self.push_block(Block::Image { path });
313 }
314 }
315
316 fn flush_loose_runs(&mut self) {
317 if !self.runs.is_empty() {
318 let runs = std::mem::take(&mut self.runs);
319 self.push_block(Block::Paragraph { runs });
320 }
321 let images = std::mem::take(&mut self.pending_images);
322 for path in images {
323 self.push_block(Block::Image { path });
324 }
325 }
326
327 fn close_row(&mut self) {
328 let Some(table) = self.tables.last_mut() else {
329 return;
330 };
331 if table.in_head {
332 return;
333 }
334 let row = std::mem::take(&mut table.row);
335 table.rows.push(row);
336 }
337
338 fn set_in_head(&mut self, in_head: bool) {
339 let Some(table) = self.tables.last_mut() else {
340 return;
341 };
342 table.in_head = in_head;
343 }
344
345 fn finish(mut self) -> (Vec<Block>, u32) {
346 self.flush_loose_runs();
347 let blocks = self.stack.pop().unwrap_or_default();
348 (blocks, self.skipped_html)
349 }
350}
351
352fn heading_level(level: HeadingLevel) -> u8 {
353 match level {
354 HeadingLevel::H1 => 1,
355 HeadingLevel::H2 => 2,
356 HeadingLevel::H3 => 3,
357 HeadingLevel::H4 => 4,
358 HeadingLevel::H5 => 5,
359 HeadingLevel::H6 => 6,
360 }
361}
362
363fn cell_align(alignment: &Alignment) -> CellAlign {
364 match alignment {
365 Alignment::None => CellAlign::Default,
366 Alignment::Left => CellAlign::Left,
367 Alignment::Center => CellAlign::Center,
368 Alignment::Right => CellAlign::Right,
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 fn plain(text: &str) -> Run {
377 Run {
378 text: text.to_string(),
379 ..Run::default()
380 }
381 }
382
383 #[test]
384 fn heading_paragraph_and_emphasis() {
385 let (blocks, skipped) = parse_blocks("# Title\n\nplain **bold** *italic* `code`\n");
386 assert_eq!(skipped, 0);
387 assert_eq!(
388 blocks[0],
389 Block::Heading {
390 level: 1,
391 runs: vec![plain("Title")]
392 }
393 );
394 let Block::Paragraph { runs } = &blocks[1] else {
395 panic!("expected paragraph")
396 };
397 assert_eq!(runs[0], plain("plain "));
398 assert_eq!(
399 runs[1],
400 Run {
401 text: "bold".into(),
402 bold: true,
403 ..Run::default()
404 }
405 );
406 assert_eq!(
407 runs[3],
408 Run {
409 text: "italic".into(),
410 italic: true,
411 ..Run::default()
412 }
413 );
414 assert_eq!(
415 runs[5],
416 Run {
417 text: "code".into(),
418 code: true,
419 ..Run::default()
420 }
421 );
422 }
423
424 #[test]
425 fn links_strikethrough_and_hard_breaks() {
426 let (blocks, _) = parse_blocks("[docs](https://x.y) and ~~gone~~ end \nnext line\n");
427 let Block::Paragraph { runs } = &blocks[0] else {
428 panic!()
429 };
430 assert_eq!(runs[0].link.as_deref(), Some("https://x.y"));
431 assert!(runs.iter().any(|r| r.strike && r.text == "gone"));
432 assert!(
433 runs.iter().any(|r| r.text == "\n"),
434 "hard break becomes a newline run"
435 );
436 }
437
438 #[test]
439 fn nested_list_with_tasks() {
440 let md = "1. first\n2. [x] done\n - inner\n";
441 let (blocks, _) = parse_blocks(md);
442 let Block::List { start, items } = &blocks[0] else {
443 panic!()
444 };
445 assert_eq!(*start, Some(1));
446 assert_eq!(items[1].task, Some(true));
447 assert!(matches!(
448 items[1].blocks.last(),
449 Some(Block::List { start: None, .. })
450 ));
451 }
452
453 #[test]
454 fn tight_list_text_stays_in_its_item() {
455 let (blocks, _) = parse_blocks("1. first\n2. [x] done\n - inner\n");
456 let Block::List { items, .. } = &blocks[0] else {
457 panic!()
458 };
459 assert_eq!(
460 items[1].blocks[0],
461 Block::Paragraph {
462 runs: vec![plain("done")]
463 }
464 );
465 let Block::List { items: inner, .. } = items[1].blocks.last().unwrap() else {
466 panic!()
467 };
468 assert_eq!(
469 inner[0].blocks[0],
470 Block::Paragraph {
471 runs: vec![plain("inner")]
472 }
473 );
474 }
475
476 #[test]
477 fn tight_item_image_becomes_the_items_own_block() {
478 let (blocks, _) = parse_blocks("- \n");
479 let Block::List { items, .. } = &blocks[0] else {
480 panic!()
481 };
482 assert_eq!(
483 items[0].blocks,
484 vec![Block::Image {
485 path: "x.png".into()
486 }]
487 );
488 }
489
490 #[test]
491 fn code_inside_alt_text_is_suppressed() {
492 let (blocks, _) = parse_blocks("\n");
493 assert_eq!(
494 blocks,
495 vec![Block::Image {
496 path: "x.png".into()
497 }]
498 );
499 }
500
501 #[test]
502 fn tight_item_text_precedes_a_following_heading() {
503 let (blocks, _) = parse_blocks("- text\n # h\n");
504 let Block::List { items, .. } = &blocks[0] else {
505 panic!()
506 };
507 assert_eq!(
508 items[0].blocks,
509 vec![
510 Block::Paragraph {
511 runs: vec![plain("text")]
512 },
513 Block::Heading {
514 level: 1,
515 runs: vec![plain("h")]
516 },
517 ]
518 );
519 }
520
521 #[test]
522 fn tight_item_text_precedes_a_following_code_block() {
523 let (blocks, _) = parse_blocks("- text\n ```\n code\n ```\n");
524 let Block::List { items, .. } = &blocks[0] else {
525 panic!()
526 };
527 assert_eq!(
528 items[0].blocks,
529 vec![
530 Block::Paragraph {
531 runs: vec![plain("text")]
532 },
533 Block::CodeBlock {
534 text: "code".into()
535 },
536 ]
537 );
538 }
539
540 #[test]
541 fn blockquote_code_rule_and_table() {
542 let md = "> quoted\n\n```\nlet x = 1;\n```\n\n---\n\n| a | b |\n|:--|--:|\n| 1 | 2 |\n";
543 let (blocks, _) = parse_blocks(md);
544 assert!(matches!(&blocks[0], Block::BlockQuote { blocks } if blocks.len() == 1));
545 assert_eq!(
546 blocks[1],
547 Block::CodeBlock {
548 text: "let x = 1;".into()
549 }
550 );
551 assert_eq!(blocks[2], Block::Rule);
552 let Block::Table { aligns, head, rows } = &blocks[3] else {
553 panic!()
554 };
555 assert_eq!(aligns, &vec![CellAlign::Left, CellAlign::Right]);
556 assert_eq!(head.len(), 2);
557 assert_eq!(rows[0][1], vec![plain("2")]);
558 }
559
560 #[test]
561 fn lone_image_becomes_a_block_and_html_is_counted() {
562 let (blocks, skipped) = parse_blocks("\n\n<div>x</div>\n");
563 assert_eq!(
564 blocks[0],
565 Block::Image {
566 path: "pic.png".into()
567 }
568 );
569 assert!(skipped >= 1);
570 }
571}