Skip to main content

max / docengine

28.7 KB · 806 lines History Blame Raw
1 //! Markdown to text.
2 //!
3 //! The other direction from the presets. They all answer "how do I show this
4 //! markdown", and this answers "what does this markdown say", which is what a
5 //! caller needs whenever it has markdown and a place that cannot take markup:
6 //! a list row's preview line, a notification body, a search snippet, a window
7 //! title, an `alt` attribute.
8 //!
9 //! Without it every such caller does the same wrong thing, which is to print
10 //! the source and let the syntax through. `**bold**` in a preview is not
11 //! emphasis and is not the word the author wrote either.
12 //!
13 //! [`render_runs`] is the same walk, stopping one step earlier: it hands back
14 //! the text in runs that still say which inline marks were over them, for a
15 //! caller whose destination has no markup but does have bold. A terminal cell
16 //! is the case that asked for it. [`render_plain`] is the runs concatenated,
17 //! and the two are one function so they cannot drift on what a table row or a
18 //! nested list is worth in newlines.
19
20 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
21 use serde::{Deserialize, Serialize};
22
23 /// The inline marks in force over a run of text.
24 ///
25 /// Markdown's own names: `strong` is `**this**`, `italic` is `*this*`. All four
26 /// are independent and any combination can hold at once, which is why this is a
27 /// set of flags rather than one kind.
28 ///
29 /// What kind of block the run sits in is [`Block`], on the run beside this.
30 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
31 pub struct Emphasis {
32 /// `**strong**`.
33 pub strong: bool,
34 /// `*italic*`.
35 pub italic: bool,
36 /// `~~struck~~`.
37 pub struck: bool,
38 /// A code span or the body of a code block.
39 pub code: bool,
40 }
41
42 impl Emphasis {
43 /// Whether nothing is in force, so a caller can take its own default style
44 /// rather than building one that says "no marks".
45 #[must_use]
46 pub const fn is_plain(self) -> bool {
47 !self.strong && !self.italic && !self.struck && !self.code
48 }
49 }
50
51 /// The kind of block a run sits in.
52 ///
53 /// Emphasis is what a caller paints on the words. This is what the words *are*,
54 /// and a destination with no markup can still answer it: a heading takes weight,
55 /// an item takes a bullet, a quote takes a marker in the margin.
56 ///
57 /// The innermost block wins and nesting is not carried. An item inside a quote
58 /// reads as an item, because a caller that has one line to draw has to pick one
59 /// of the two anyway and the inner one is the one holding the words. A caller
60 /// that needs the tree wants a markup preset rather than this.
61 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
62 pub enum Block {
63 /// A paragraph, a table cell, or anything else with nothing to say about
64 /// itself.
65 #[default]
66 Prose,
67 /// A heading, at its markdown level: 1 for `#` through 6 for `######`.
68 Heading(u8),
69 /// A list item, ordered or not. The marker is not in the text, because what
70 /// a bullet looks like is the caller's answer and a number would be wrong
71 /// for half of them.
72 Item,
73 /// A line inside a block quote.
74 Quote,
75 }
76
77 /// A stretch of text under one set of marks, in one kind of block.
78 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
79 pub struct TextRun {
80 /// The words, with none of the syntax that shaped them.
81 pub text: String,
82 /// What was over them.
83 pub emphasis: Emphasis,
84 /// What they are part of.
85 pub block: Block,
86 }
87
88 /// How deep we are inside each inline mark.
89 ///
90 /// Counters and not booleans: `**a *b* a**` closes the inner mark without
91 /// ending the outer one, and markdown lets that nest as far as an author cares
92 /// to go.
93 #[derive(Clone, Copy, Default)]
94 struct Marks {
95 strong: u16,
96 italic: u16,
97 struck: u16,
98 /// A fenced or indented block. Its body arrives as `Event::Text` and not as
99 /// `Event::Code`, which pulldown reserves for a span, so the only way to
100 /// know the words are code is to have seen the fence.
101 code_block: u16,
102 }
103
104 /// Which block a run is in, innermost last.
105 ///
106 /// A stack and not a field, because the blocks that matter here nest: an item
107 /// inside a quote inside an item is ordinary markdown, and closing the inner one
108 /// has to put the outer one back rather than fall to prose.
109 #[derive(Default)]
110 struct Blocks(Vec<Block>);
111
112 impl Blocks {
113 fn current(&self) -> Block {
114 self.0.last().copied().unwrap_or_default()
115 }
116
117 fn open(&mut self, block: Block) {
118 self.0.push(block);
119 }
120
121 fn close(&mut self) {
122 self.0.pop();
123 }
124 }
125
126 /// A markdown heading level as the number the author typed.
127 fn level_of(level: pulldown_cmark::HeadingLevel) -> u8 {
128 use pulldown_cmark::HeadingLevel::{H1, H2, H3, H4, H5, H6};
129 match level {
130 H1 => 1,
131 H2 => 2,
132 H3 => 3,
133 H4 => 4,
134 H5 => 5,
135 H6 => 6,
136 }
137 }
138
139 impl Marks {
140 fn emphasis(self) -> Emphasis {
141 Emphasis {
142 strong: self.strong > 0,
143 italic: self.italic > 0,
144 struck: self.struck > 0,
145 code: self.code_block > 0,
146 }
147 }
148 }
149
150 /// Render markdown as plain text: the words, without the syntax that shapes
151 /// them.
152 ///
153 /// Block structure survives as newlines, because a preview that runs two
154 /// paragraphs together reads as one sentence that does not parse. Inline
155 /// structure does not survive at all: emphasis, code spans and link text all
156 /// come through as their text, and a link's URL is dropped, since a reader
157 /// looking at a one-line preview cannot follow it and it crowds out the words
158 /// that would have told them whether they want to.
159 ///
160 /// Raw HTML is dropped rather than unescaped. This is not a sanitizer and must
161 /// not be used as one: it produces text, and text put into a document still has
162 /// to be escaped by whoever puts it there.
163 ///
164 /// The GFM options match the render presets, so a table or a task list
165 /// contributes its cell and item text rather than its pipes and brackets.
166 ///
167 /// ```
168 /// let plain = docengine::render_plain("# Title\n\nSee **the [docs](https://example.com)**.");
169 /// assert_eq!(plain, "Title\n\nSee the docs.");
170 /// ```
171 pub fn render_plain(markdown: &str) -> String {
172 render_runs(markdown)
173 .into_iter()
174 .map(|run| run.text)
175 .collect()
176 }
177
178 /// Render markdown as text runs that keep the inline marks that were over them.
179 ///
180 /// [`render_plain`] with the emphasis left on. Everything that function says
181 /// about block structure, links, raw HTML and GFM holds here unchanged, and the
182 /// concatenation of these runs is exactly what it returns.
183 ///
184 /// For a caller whose destination has no markup but is not flat either: a
185 /// terminal cell can be bold, an `egui` galley can carry a text format. A caller
186 /// that only wants the words should use [`render_plain`].
187 ///
188 /// Adjacent text under the same marks arrives as one run, so a paragraph with
189 /// no emphasis in it is one run rather than one per parsed event.
190 ///
191 /// ```
192 /// let runs = docengine::render_runs("plain **bold**");
193 /// assert_eq!(runs.len(), 2);
194 /// assert_eq!(runs[0].text, "plain ");
195 /// assert!(runs[0].emphasis.is_plain());
196 /// assert_eq!(runs[1].text, "bold");
197 /// assert!(runs[1].emphasis.strong);
198 /// ```
199 pub fn render_runs(markdown: &str) -> Vec<TextRun> {
200 if markdown.is_empty() {
201 return Vec::new();
202 }
203
204 let mut options = Options::empty();
205 options.insert(Options::ENABLE_TABLES);
206 options.insert(Options::ENABLE_STRIKETHROUGH);
207 options.insert(Options::ENABLE_FOOTNOTES);
208 options.insert(Options::ENABLE_TASKLISTS);
209
210 let mut runs: Vec<TextRun> = Vec::new();
211 let mut marks = Marks::default();
212 let mut blocks = Blocks::default();
213
214 for event in Parser::new_ext(markdown, options) {
215 match event {
216 // A code span's text is the text. A code block's is too: it is
217 // usually the least readable thing in a preview, and dropping it
218 // would silently empty out a message that is nothing but a snippet.
219 Event::Code(text) => push(
220 &mut runs,
221 &text,
222 Emphasis {
223 code: true,
224 ..marks.emphasis()
225 },
226 blocks.current(),
227 ),
228 Event::Text(text) => push(&mut runs, &text, marks.emphasis(), blocks.current()),
229
230 // What the words are, as opposed to what is painted on them. A
231 // heading has an answer on any destination that can set one line
232 // heavier than the next, and a terminal is one of them.
233 Event::Start(Tag::Heading { level, .. }) => {
234 blocks.open(Block::Heading(level_of(level)));
235 }
236 Event::Start(Tag::Item) => blocks.open(Block::Item),
237 Event::Start(Tag::BlockQuote(_)) => blocks.open(Block::Quote),
238
239 // Inline marks, tracked rather than passed over. This is the whole
240 // difference between the two functions above; the rest of the walk
241 // is the same one it always was.
242 Event::Start(Tag::Strong) => marks.strong += 1,
243 Event::End(TagEnd::Strong) => marks.strong = marks.strong.saturating_sub(1),
244 Event::Start(Tag::Emphasis) => marks.italic += 1,
245 Event::End(TagEnd::Emphasis) => marks.italic = marks.italic.saturating_sub(1),
246 Event::Start(Tag::Strikethrough) => marks.struck += 1,
247 Event::End(TagEnd::Strikethrough) => marks.struck = marks.struck.saturating_sub(1),
248 Event::Start(Tag::CodeBlock(_)) => marks.code_block += 1,
249
250 // A line the author broke on purpose, and a line the source
251 // wrapped: the author wrote one line there, so that one gives them
252 // one line back rather than a break they never typed. Both carry
253 // the marks in force, so a break inside `**...**` does not cut the
254 // run in three over a character nobody can see the style of.
255 Event::HardBreak => push(&mut runs, "\n", marks.emphasis(), blocks.current()),
256 Event::SoftBreak => push(&mut runs, " ", marks.emphasis(), blocks.current()),
257
258 // A prose block ends with a blank line after it, because two
259 // paragraphs run together read as one sentence that does not parse.
260 //
261 // Every separator below is prose under no marks, and by
262 // construction rather than by choice: it stands between two blocks
263 // and belongs to neither, and the block it closes is closed before
264 // it is written. That is also what keeps two adjacent items from
265 // merging into one run.
266 Event::End(TagEnd::CodeBlock) => {
267 marks.code_block = marks.code_block.saturating_sub(1);
268 push(&mut runs, "\n\n", Emphasis::default(), Block::Prose);
269 }
270 Event::End(TagEnd::Heading(_) | TagEnd::BlockQuote(_)) => {
271 blocks.close();
272 push(&mut runs, "\n\n", Emphasis::default(), Block::Prose);
273 }
274 Event::End(TagEnd::Paragraph | TagEnd::List(_) | TagEnd::FootnoteDefinition) => {
275 push(&mut runs, "\n\n", Emphasis::default(), Block::Prose);
276 }
277
278 // A member of a block ends a line and no more: a list is one item
279 // per line, and a table is one row per line.
280 Event::End(TagEnd::Item) => {
281 blocks.close();
282 push(&mut runs, "\n", Emphasis::default(), Block::Prose);
283 }
284 Event::End(TagEnd::TableRow | TagEnd::TableHead) => {
285 push(&mut runs, "\n", Emphasis::default(), Block::Prose);
286 }
287
288 // Cells sit in a row, so they want a separator rather than a break.
289 Event::End(TagEnd::TableCell) => {
290 push(&mut runs, "\t", Emphasis::default(), Block::Prose);
291 }
292
293 // A rule is a boundary with nothing to say.
294 Event::Rule => push(&mut runs, "\n", Emphasis::default(), Block::Prose),
295
296 // An image's alt text is the only thing here a reader can use, and
297 // pulldown emits it as the tag's inner text, so the tag itself is
298 // simply passed over.
299 Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image) => {}
300
301 // Markup, markers and references: nothing a reader would have read
302 // aloud. Raw HTML included, and see the note above about why that
303 // does not make this a sanitizer.
304 _ => {}
305 }
306 }
307
308 tidy(runs)
309 }
310
311 /// Append `text` under `emphasis` and `block`, joining the run before it when
312 /// both match, so a paragraph with no emphasis in it stays one run.
313 fn push(runs: &mut Vec<TextRun>, text: &str, emphasis: Emphasis, block: Block) {
314 if text.is_empty() {
315 return;
316 }
317 match runs.last_mut() {
318 Some(last) if last.emphasis == emphasis && last.block == block => {
319 last.text.push_str(text);
320 }
321 _ => runs.push(TextRun {
322 text: text.to_string(),
323 emphasis,
324 block,
325 }),
326 }
327 }
328
329 /// Collapse what the walk above leaves behind: trailing spaces on a line, and
330 /// runs of blank lines from nested blocks that each ended one.
331 ///
332 /// Nesting is the reason this is a pass rather than care taken inline. A list
333 /// inside a blockquote ends three blocks at the same point and each one is
334 /// right to end a line; only together are they wrong.
335 ///
336 /// It works a line at a time and a run can span several lines, so the first
337 /// move is to cut the runs at every newline. What comes back out is re-joined
338 /// and re-merged, so cutting costs nothing a caller can see.
339 fn tidy(runs: Vec<TextRun>) -> Vec<TextRun> {
340 let mut lines = split_lines(runs);
341 for line in &mut lines {
342 trim_end(line);
343 }
344
345 let mut kept: Vec<Vec<TextRun>> = Vec::new();
346 let mut blank_run = 0;
347 for line in lines {
348 if line.is_empty() {
349 blank_run += 1;
350 // One blank line separates two blocks. A second says nothing the
351 // first did not, and neither does one before anything at all.
352 if blank_run > 1 || kept.is_empty() {
353 continue;
354 }
355 } else {
356 blank_run = 0;
357 }
358 kept.push(line);
359 }
360 // The last block ended a line too, and there is nothing after it to
361 // separate from.
362 while kept.last().is_some_and(Vec::is_empty) {
363 kept.pop();
364 }
365
366 let mut out: Vec<TextRun> = Vec::new();
367 for (index, line) in kept.into_iter().enumerate() {
368 if index > 0 {
369 push(&mut out, "\n", Emphasis::default(), Block::Prose);
370 }
371 for run in line {
372 push(&mut out, &run.text, run.emphasis, run.block);
373 }
374 }
375 out
376 }
377
378 /// Cut `runs` at every newline, into one group of runs per line.
379 fn split_lines(runs: Vec<TextRun>) -> Vec<Vec<TextRun>> {
380 let mut lines: Vec<Vec<TextRun>> = vec![Vec::new()];
381 for run in runs {
382 for (index, piece) in run.text.split('\n').enumerate() {
383 if index > 0 {
384 lines.push(Vec::new());
385 }
386 if !piece.is_empty() {
387 // A cut cannot make two adjacent runs mergeable that were not
388 // already, so this only ever appends.
389 let line = lines.last_mut().expect("a line was just pushed");
390 line.push(TextRun {
391 text: piece.to_string(),
392 emphasis: run.emphasis,
393 block: run.block,
394 });
395 }
396 }
397 }
398 lines
399 }
400
401 /// Drop trailing whitespace from a line, across as many runs as it spans.
402 fn trim_end(line: &mut Vec<TextRun>) {
403 while let Some(last) = line.last_mut() {
404 let trimmed = last.text.trim_end();
405 if trimmed.is_empty() {
406 line.pop();
407 } else {
408 last.text.truncate(trimmed.len());
409 break;
410 }
411 }
412 }
413
414 #[cfg(test)]
415 mod tests {
416 use super::{Block, Emphasis, render_plain, render_runs};
417
418 /// The marks over each run, for asserting shape without spelling out four
419 /// booleans a run.
420 fn marks(markdown: &str) -> Vec<(String, Emphasis)> {
421 render_runs(markdown)
422 .into_iter()
423 .map(|run| (run.text, run.emphasis))
424 .collect()
425 }
426
427 /// The block each run sits in.
428 fn blocks(markdown: &str) -> Vec<(String, Block)> {
429 render_runs(markdown)
430 .into_iter()
431 .map(|run| (run.text, run.block))
432 .collect()
433 }
434
435 #[test]
436 fn inline_syntax_becomes_the_words_it_wrapped() {
437 assert_eq!(
438 render_plain("**bold**, *italic*, `code` and ~~struck~~"),
439 "bold, italic, code and struck"
440 );
441 }
442
443 #[test]
444 fn a_link_keeps_its_text_and_drops_its_url() {
445 // The preview case that motivated this: pter turns every anchor in an
446 // HTML email into `[text](url)`, and a row showing the source shows
447 // more URL than words.
448 assert_eq!(
449 render_plain("Read [the announcement](https://example.com/a/very/long/path)."),
450 "Read the announcement."
451 );
452 }
453
454 #[test]
455 fn blocks_are_separated_and_soft_wraps_are_not() {
456 // A soft break is where the source wrapped, not where the author did.
457 assert_eq!(
458 render_plain("First paragraph\nwrapped in the source.\n\nSecond."),
459 "First paragraph wrapped in the source.\n\nSecond."
460 );
461 }
462
463 #[test]
464 fn a_hard_break_is_kept() {
465 assert_eq!(render_plain("one \ntwo"), "one\ntwo");
466 }
467
468 #[test]
469 fn nested_blocks_do_not_pile_up_blank_lines() {
470 // A list inside a quote ends three blocks in one place. Each is right
471 // to end a line and together they would leave a hole.
472 let md = "> - one\n> - two\n\nAfter.";
473 assert_eq!(render_plain(md), "one\ntwo\n\nAfter.");
474 }
475
476 #[test]
477 fn headings_are_their_text() {
478 assert_eq!(render_plain("# Title\n\nBody."), "Title\n\nBody.");
479 }
480
481 #[test]
482 fn a_list_is_one_item_per_line() {
483 assert_eq!(render_plain("- one\n- two\n- three"), "one\ntwo\nthree");
484 }
485
486 #[test]
487 fn a_code_block_keeps_its_contents() {
488 // Least readable thing in a preview, and dropping it would empty out a
489 // message that is nothing but a snippet.
490 assert_eq!(render_plain("```rust\nlet x = 1;\n```"), "let x = 1;");
491 }
492
493 #[test]
494 fn a_table_reads_as_cells_rather_than_pipes() {
495 let md = "| a | b |\n|---|---|\n| 1 | 2 |";
496 assert_eq!(render_plain(md), "a\tb\n1\t2");
497 }
498
499 #[test]
500 fn raw_html_is_dropped_not_unescaped() {
501 // An inline tag goes and the prose around it stays, which is the case
502 // that matters: a sender's stray `<b>` should not cost the sentence.
503 assert_eq!(
504 render_plain("before <b>bold</b> after"),
505 "before bold after"
506 );
507 // A whole block of raw HTML goes with its contents, because pulldown
508 // hands it over as one opaque event and there is no text to pick out of
509 // it. Worth knowing rather than worth fixing here: markdown that is
510 // really an HTML document wants a converter, not this.
511 assert_eq!(render_plain("<div>inside</div>"), "");
512 // And this is not a sanitizer. What comes back is text, and text put
513 // into a document still has to be escaped by whoever puts it there.
514 assert_eq!(
515 render_plain("before <script>alert(1)</script> after"),
516 "before alert(1) after"
517 );
518 }
519
520 #[test]
521 fn an_image_contributes_its_alt_text() {
522 assert_eq!(
523 render_plain("![a diagram](/img/d.png) explains it"),
524 "a diagram explains it"
525 );
526 }
527
528 #[test]
529 fn empty_in_empty_out() {
530 assert_eq!(render_plain(""), "");
531 assert_eq!(render_plain("\n\n"), "");
532 assert_eq!(render_runs(""), Vec::new());
533 assert_eq!(render_runs("\n\n"), Vec::new());
534 }
535
536 #[test]
537 fn plain_text_survives_unchanged() {
538 // The other half of the format question in a caller that has both: text
539 // that is not markdown must come through as it was written.
540 assert_eq!(
541 render_plain("the *args and **kwargs conventions"),
542 "the *args and **kwargs conventions"
543 );
544 }
545
546 #[test]
547 fn runs_concatenate_to_the_plain_render() {
548 // The invariant the two functions exist as one walk to keep. If this
549 // ever fails, a caller that switched from one to the other has silently
550 // changed what its rows say.
551 let sources = [
552 "**bold**, *italic*, `code` and ~~struck~~",
553 "# Title\n\nBody with **weight** in it.",
554 "> - one\n> - **two**\n\nAfter.",
555 "| a | **b** |\n|---|---|\n| 1 | 2 |",
556 "```rust\nlet x = 1;\n```",
557 "Read [the **announcement**](https://example.com).",
558 "before <b>bold</b> after",
559 "the *args and **kwargs conventions",
560 "one \ntwo",
561 "",
562 ];
563 for source in sources {
564 let joined: String = render_runs(source)
565 .into_iter()
566 .map(|run| run.text)
567 .collect();
568 assert_eq!(joined, render_plain(source), "source: {source:?}");
569 }
570 }
571
572 #[test]
573 fn each_mark_is_carried() {
574 assert_eq!(
575 marks("**b** *i* `c` ~~s~~"),
576 vec![
577 (
578 "b".to_string(),
579 Emphasis {
580 strong: true,
581 ..Emphasis::default()
582 }
583 ),
584 (" ".to_string(), Emphasis::default()),
585 (
586 "i".to_string(),
587 Emphasis {
588 italic: true,
589 ..Emphasis::default()
590 }
591 ),
592 (" ".to_string(), Emphasis::default()),
593 (
594 "c".to_string(),
595 Emphasis {
596 code: true,
597 ..Emphasis::default()
598 }
599 ),
600 (" ".to_string(), Emphasis::default()),
601 (
602 "s".to_string(),
603 Emphasis {
604 struck: true,
605 ..Emphasis::default()
606 }
607 ),
608 ]
609 );
610 }
611
612 #[test]
613 fn marks_nest_and_combine() {
614 // The counters earn themselves here: the inner mark closes and the
615 // outer one is still in force over the text after it.
616 assert_eq!(
617 marks("**a *b* c**"),
618 vec![
619 (
620 "a ".to_string(),
621 Emphasis {
622 strong: true,
623 ..Emphasis::default()
624 }
625 ),
626 (
627 "b".to_string(),
628 Emphasis {
629 strong: true,
630 italic: true,
631 ..Emphasis::default()
632 }
633 ),
634 (
635 " c".to_string(),
636 Emphasis {
637 strong: true,
638 ..Emphasis::default()
639 }
640 ),
641 ]
642 );
643 }
644
645 #[test]
646 fn a_code_span_inside_emphasis_carries_both() {
647 assert_eq!(
648 marks("**`cargo build`**"),
649 vec![(
650 "cargo build".to_string(),
651 Emphasis {
652 strong: true,
653 code: true,
654 ..Emphasis::default()
655 }
656 )]
657 );
658 }
659
660 #[test]
661 fn a_code_block_is_marked_as_code() {
662 let runs = render_runs("```rust\nlet x = 1;\n```");
663 assert_eq!(runs.len(), 1);
664 assert!(runs[0].emphasis.code);
665 assert_eq!(runs[0].text, "let x = 1;");
666 }
667
668 #[test]
669 fn adjacent_text_under_the_same_marks_is_one_run() {
670 // A link's text and the prose around it are separate events and one
671 // run: a caller styling per run should not see a seam where the source
672 // had a bracket.
673 assert_eq!(
674 marks("Read [the docs](https://example.com) now."),
675 vec![("Read the docs now.".to_string(), Emphasis::default())]
676 );
677 }
678
679 #[test]
680 fn a_soft_break_inside_emphasis_does_not_cut_the_run() {
681 // The space stands where the source wrapped, and giving it the marks in
682 // force is what keeps `**a b**` one run rather than three.
683 assert_eq!(
684 marks("**one\ntwo**"),
685 vec![(
686 "one two".to_string(),
687 Emphasis {
688 strong: true,
689 ..Emphasis::default()
690 }
691 )]
692 );
693 }
694
695 #[test]
696 fn emphasis_survives_the_tidy_pass() {
697 // The pass cuts runs at newlines to work a line at a time. What it
698 // re-joins has to carry the same marks it took apart, and a list inside
699 // a quote is where it does the most cutting.
700 assert_eq!(
701 marks("> - **one**\n> - two\n\nAfter."),
702 vec![
703 (
704 "one".to_string(),
705 Emphasis {
706 strong: true,
707 ..Emphasis::default()
708 }
709 ),
710 ("\n".to_string(), Emphasis::default()),
711 ("two".to_string(), Emphasis::default()),
712 ("\n\nAfter.".to_string(), Emphasis::default()),
713 ]
714 );
715 }
716
717 #[test]
718 fn a_heading_says_which_level_it_was() {
719 // The one this was written for: a destination with no markup can still
720 // set one line heavier than the next, and until now it was not told
721 // which line.
722 assert_eq!(
723 blocks("# Title\n\nBody.\n\n### Deeper"),
724 vec![
725 ("Title".to_string(), Block::Heading(1)),
726 ("\n\nBody.\n\n".to_string(), Block::Prose),
727 ("Deeper".to_string(), Block::Heading(3)),
728 ]
729 );
730 }
731
732 #[test]
733 fn an_item_carries_no_marker_of_its_own() {
734 // What a bullet looks like is the caller's answer, and a number would be
735 // wrong for half of them. The separator between two items is prose,
736 // which is also what keeps them from merging into one run.
737 assert_eq!(
738 blocks("- one\n- two"),
739 vec![
740 ("one".to_string(), Block::Item),
741 ("\n".to_string(), Block::Prose),
742 ("two".to_string(), Block::Item),
743 ]
744 );
745 assert_eq!(render_plain("1. one\n2. two"), "one\ntwo");
746 }
747
748 #[test]
749 fn the_innermost_block_wins_and_the_outer_one_comes_back() {
750 // An item inside a quote reads as an item: a caller with one line to
751 // draw has to pick one of the two, and the inner one holds the words.
752 // What the stack is for is the line after it, where the quote is still
753 // open and prose would be wrong.
754 assert_eq!(
755 blocks("> - one\n>\n> after"),
756 vec![
757 ("one".to_string(), Block::Item),
758 ("\n\n".to_string(), Block::Prose),
759 ("after".to_string(), Block::Quote),
760 ]
761 );
762 }
763
764 #[test]
765 fn a_block_role_does_not_disturb_the_words() {
766 // The invariant again, over the sources that carry blocks. Adding a
767 // reason to split a run must not add or drop a character.
768 for source in [
769 "# Title\n\nBody.",
770 "- one\n- two\n\nAfter.",
771 "> quoted\n\nafter",
772 "> - **one**\n> - two\n\nAfter.",
773 "1. one\n2. two",
774 ] {
775 let joined: String = render_runs(source)
776 .into_iter()
777 .map(|run| run.text)
778 .collect();
779 assert_eq!(joined, render_plain(source), "source: {source:?}");
780 }
781 }
782
783 #[test]
784 fn a_line_ending_in_its_own_run_of_whitespace_loses_it() {
785 // A row ends every cell with a tab, including the last one, so each
786 // line ends in a run that is nothing but whitespace and `tidy` has to
787 // drop the run rather than trim inside it. The marked cell next to it
788 // is what makes the case worth a test: popping the wrong run would take
789 // the emphasis with it.
790 assert_eq!(
791 marks("| a | **b** |\n|---|---|\n| 1 | 2 |"),
792 vec![
793 ("a\t".to_string(), Emphasis::default()),
794 (
795 "b".to_string(),
796 Emphasis {
797 strong: true,
798 ..Emphasis::default()
799 }
800 ),
801 ("\n1\t2".to_string(), Emphasis::default()),
802 ]
803 );
804 }
805 }
806