| 26 |
26 |
|
/// are independent and any combination can hold at once, which is why this is a
|
| 27 |
27 |
|
/// set of flags rather than one kind.
|
| 28 |
28 |
|
///
|
| 29 |
|
- |
/// Block structure is deliberately not here. A heading's level, a list's
|
| 30 |
|
- |
/// nesting and a quote's depth are gone by this point for the same reason they
|
| 31 |
|
- |
/// are gone from [`render_plain`]: the destination is a place with no room to
|
| 32 |
|
- |
/// spend on them. What survives is exactly what a caller can paint on a run of
|
| 33 |
|
- |
/// text without moving it.
|
|
29 |
+ |
/// What kind of block the run sits in is [`Block`], on the run beside this.
|
| 34 |
30 |
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
| 35 |
31 |
|
pub struct Emphasis {
|
| 36 |
32 |
|
/// `**strong**`.
|
| 52 |
48 |
|
}
|
| 53 |
49 |
|
}
|
| 54 |
50 |
|
|
| 55 |
|
- |
/// A stretch of text under one set of marks.
|
|
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.
|
| 56 |
78 |
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
| 57 |
79 |
|
pub struct TextRun {
|
| 58 |
80 |
|
/// The words, with none of the syntax that shaped them.
|
| 59 |
81 |
|
pub text: String,
|
| 60 |
82 |
|
/// What was over them.
|
| 61 |
83 |
|
pub emphasis: Emphasis,
|
|
84 |
+ |
/// What they are part of.
|
|
85 |
+ |
pub block: Block,
|
| 62 |
86 |
|
}
|
| 63 |
87 |
|
|
| 64 |
88 |
|
/// How deep we are inside each inline mark.
|
| 77 |
101 |
|
code_block: u16,
|
| 78 |
102 |
|
}
|
| 79 |
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 |
+ |
|
| 80 |
139 |
|
impl Marks {
|
| 81 |
140 |
|
fn emphasis(self) -> Emphasis {
|
| 82 |
141 |
|
Emphasis {
|
| 151 |
210 |
|
|
| 152 |
211 |
|
let mut runs: Vec<TextRun> = Vec::new();
|
| 153 |
212 |
|
let mut marks = Marks::default();
|
|
213 |
+ |
let mut blocks = Blocks::default();
|
| 154 |
214 |
|
|
| 155 |
215 |
|
for event in Parser::new_ext(markdown, options) {
|
| 156 |
216 |
|
match event {
|
| 164 |
224 |
|
code: true,
|
| 165 |
225 |
|
..marks.emphasis()
|
| 166 |
226 |
|
},
|
|
227 |
+ |
blocks.current(),
|
| 167 |
228 |
|
),
|
| 168 |
|
- |
Event::Text(text) => push(&mut runs, &text, marks.emphasis()),
|
|
229 |
+ |
Event::Text(text) => push(&mut runs, &text, marks.emphasis(), blocks.current()),
|
|
230 |
+ |
|
|
231 |
+ |
// What the words are, as opposed to what is painted on them. A
|
|
232 |
+ |
// heading has an answer on any destination that can set one line
|
|
233 |
+ |
// heavier than the next, and a terminal is one of them.
|
|
234 |
+ |
Event::Start(Tag::Heading { level, .. }) => {
|
|
235 |
+ |
blocks.open(Block::Heading(level_of(level)));
|
|
236 |
+ |
}
|
|
237 |
+ |
Event::Start(Tag::Item) => blocks.open(Block::Item),
|
|
238 |
+ |
Event::Start(Tag::BlockQuote(_)) => blocks.open(Block::Quote),
|
| 169 |
239 |
|
|
| 170 |
240 |
|
// Inline marks, tracked rather than passed over. This is the whole
|
| 171 |
241 |
|
// difference between the two functions above; the rest of the walk
|
| 183 |
253 |
|
// one line back rather than a break they never typed. Both carry
|
| 184 |
254 |
|
// the marks in force, so a break inside `**...**` does not cut the
|
| 185 |
255 |
|
// run in three over a character nobody can see the style of.
|
| 186 |
|
- |
Event::HardBreak => push(&mut runs, "\n", marks.emphasis()),
|
| 187 |
|
- |
Event::SoftBreak => push(&mut runs, " ", marks.emphasis()),
|
|
256 |
+ |
Event::HardBreak => push(&mut runs, "\n", marks.emphasis(), blocks.current()),
|
|
257 |
+ |
Event::SoftBreak => push(&mut runs, " ", marks.emphasis(), blocks.current()),
|
| 188 |
258 |
|
|
| 189 |
259 |
|
// A prose block ends with a blank line after it, because two
|
| 190 |
260 |
|
// paragraphs run together read as one sentence that does not parse.
|
| 191 |
|
- |
// Inline marks are closed by now, so these are unmarked by
|
| 192 |
|
- |
// construction rather than by choice.
|
|
261 |
+ |
//
|
|
262 |
+ |
// Every separator below is prose under no marks, and by
|
|
263 |
+ |
// construction rather than by choice: it stands between two blocks
|
|
264 |
+ |
// and belongs to neither, and the block it closes is closed before
|
|
265 |
+ |
// it is written. That is also what keeps two adjacent items from
|
|
266 |
+ |
// merging into one run.
|
| 193 |
267 |
|
Event::End(TagEnd::CodeBlock) => {
|
| 194 |
268 |
|
marks.code_block = marks.code_block.saturating_sub(1);
|
| 195 |
|
- |
push(&mut runs, "\n\n", Emphasis::default());
|
|
269 |
+ |
push(&mut runs, "\n\n", Emphasis::default(), Block::Prose);
|
|
270 |
+ |
}
|
|
271 |
+ |
Event::End(TagEnd::Heading(_) | TagEnd::BlockQuote(_)) => {
|
|
272 |
+ |
blocks.close();
|
|
273 |
+ |
push(&mut runs, "\n\n", Emphasis::default(), Block::Prose);
|
|
274 |
+ |
}
|
|
275 |
+ |
Event::End(TagEnd::Paragraph | TagEnd::List(_) | TagEnd::FootnoteDefinition) => {
|
|
276 |
+ |
push(&mut runs, "\n\n", Emphasis::default(), Block::Prose);
|
| 196 |
277 |
|
}
|
| 197 |
|
- |
Event::End(
|
| 198 |
|
- |
TagEnd::Paragraph
|
| 199 |
|
- |
| TagEnd::Heading(_)
|
| 200 |
|
- |
| TagEnd::BlockQuote(_)
|
| 201 |
|
- |
| TagEnd::List(_)
|
| 202 |
|
- |
| TagEnd::FootnoteDefinition,
|
| 203 |
|
- |
) => push(&mut runs, "\n\n", Emphasis::default()),
|
| 204 |
278 |
|
|
| 205 |
279 |
|
// A member of a block ends a line and no more: a list is one item
|
| 206 |
280 |
|
// per line, and a table is one row per line.
|
| 207 |
|
- |
Event::End(TagEnd::Item | TagEnd::TableRow | TagEnd::TableHead) => {
|
| 208 |
|
- |
push(&mut runs, "\n", Emphasis::default());
|
|
281 |
+ |
Event::End(TagEnd::Item) => {
|
|
282 |
+ |
blocks.close();
|
|
283 |
+ |
push(&mut runs, "\n", Emphasis::default(), Block::Prose);
|
|
284 |
+ |
}
|
|
285 |
+ |
Event::End(TagEnd::TableRow | TagEnd::TableHead) => {
|
|
286 |
+ |
push(&mut runs, "\n", Emphasis::default(), Block::Prose);
|
| 209 |
287 |
|
}
|
| 210 |
288 |
|
|
| 211 |
289 |
|
// Cells sit in a row, so they want a separator rather than a break.
|
| 212 |
|
- |
Event::End(TagEnd::TableCell) => push(&mut runs, "\t", Emphasis::default()),
|
|
290 |
+ |
Event::End(TagEnd::TableCell) => {
|
|
291 |
+ |
push(&mut runs, "\t", Emphasis::default(), Block::Prose);
|
|
292 |
+ |
}
|
| 213 |
293 |
|
|
| 214 |
294 |
|
// A rule is a boundary with nothing to say.
|
| 215 |
|
- |
Event::Rule => push(&mut runs, "\n", Emphasis::default()),
|
|
295 |
+ |
Event::Rule => push(&mut runs, "\n", Emphasis::default(), Block::Prose),
|
| 216 |
296 |
|
|
| 217 |
297 |
|
// An image's alt text is the only thing here a reader can use, and
|
| 218 |
298 |
|
// pulldown emits it as the tag's inner text, so the tag itself is
|
| 229 |
309 |
|
tidy(runs)
|
| 230 |
310 |
|
}
|
| 231 |
311 |
|
|
| 232 |
|
- |
/// Append `text` under `emphasis`, joining the run before it when the marks
|
| 233 |
|
- |
/// match, so a paragraph with no emphasis in it stays one run.
|
| 234 |
|
- |
fn push(runs: &mut Vec<TextRun>, text: &str, emphasis: Emphasis) {
|
|
312 |
+ |
/// Append `text` under `emphasis` and `block`, joining the run before it when
|
|
313 |
+ |
/// both match, so a paragraph with no emphasis in it stays one run.
|
|
314 |
+ |
fn push(runs: &mut Vec<TextRun>, text: &str, emphasis: Emphasis, block: Block) {
|
| 235 |
315 |
|
if text.is_empty() {
|
| 236 |
316 |
|
return;
|
| 237 |
317 |
|
}
|
| 238 |
318 |
|
match runs.last_mut() {
|
| 239 |
|
- |
Some(last) if last.emphasis == emphasis => last.text.push_str(text),
|
|
319 |
+ |
Some(last) if last.emphasis == emphasis && last.block == block => {
|
|
320 |
+ |
last.text.push_str(text);
|
|
321 |
+ |
}
|
| 240 |
322 |
|
_ => runs.push(TextRun {
|
| 241 |
323 |
|
text: text.to_string(),
|
| 242 |
324 |
|
emphasis,
|
|
325 |
+ |
block,
|
| 243 |
326 |
|
}),
|
| 244 |
327 |
|
}
|
| 245 |
328 |
|
}
|
| 284 |
367 |
|
let mut out: Vec<TextRun> = Vec::new();
|
| 285 |
368 |
|
for (index, line) in kept.into_iter().enumerate() {
|
| 286 |
369 |
|
if index > 0 {
|
| 287 |
|
- |
push(&mut out, "\n", Emphasis::default());
|
|
370 |
+ |
push(&mut out, "\n", Emphasis::default(), Block::Prose);
|
| 288 |
371 |
|
}
|
| 289 |
372 |
|
for run in line {
|
| 290 |
|
- |
push(&mut out, &run.text, run.emphasis);
|
|
373 |
+ |
push(&mut out, &run.text, run.emphasis, run.block);
|
| 291 |
374 |
|
}
|
| 292 |
375 |
|
}
|
| 293 |
376 |
|
out
|
| 308 |
391 |
|
line.push(TextRun {
|
| 309 |
392 |
|
text: piece.to_string(),
|
| 310 |
393 |
|
emphasis: run.emphasis,
|
|
394 |
+ |
block: run.block,
|
| 311 |
395 |
|
});
|
| 312 |
396 |
|
}
|
| 313 |
397 |
|
}
|
| 330 |
414 |
|
|
| 331 |
415 |
|
#[cfg(test)]
|
| 332 |
416 |
|
mod tests {
|
| 333 |
|
- |
use super::{Emphasis, render_plain, render_runs};
|
|
417 |
+ |
use super::{Block, Emphasis, render_plain, render_runs};
|
| 334 |
418 |
|
|
| 335 |
419 |
|
/// The marks over each run, for asserting shape without spelling out four
|
| 336 |
420 |
|
/// booleans a run.
|
| 341 |
425 |
|
.collect()
|
| 342 |
426 |
|
}
|
| 343 |
427 |
|
|
|
428 |
+ |
/// The block each run sits in.
|
|
429 |
+ |
fn blocks(markdown: &str) -> Vec<(String, Block)> {
|
|
430 |
+ |
render_runs(markdown)
|
|
431 |
+ |
.into_iter()
|
|
432 |
+ |
.map(|run| (run.text, run.block))
|
|
433 |
+ |
.collect()
|
|
434 |
+ |
}
|
|
435 |
+ |
|
| 344 |
436 |
|
#[test]
|
| 345 |
437 |
|
fn inline_syntax_becomes_the_words_it_wrapped() {
|
| 346 |
438 |
|
assert_eq!(
|
| 616 |
708 |
|
..Emphasis::default()
|
| 617 |
709 |
|
}
|
| 618 |
710 |
|
),
|
| 619 |
|
- |
("\ntwo\n\nAfter.".to_string(), Emphasis::default()),
|
|
711 |
+ |
("\n".to_string(), Emphasis::default()),
|
|
712 |
+ |
("two".to_string(), Emphasis::default()),
|
|
713 |
+ |
("\n\nAfter.".to_string(), Emphasis::default()),
|
| 620 |
714 |
|
]
|
| 621 |
715 |
|
);
|
| 622 |
716 |
|
}
|
| 623 |
717 |
|
|
|
718 |
+ |
#[test]
|
|
719 |
+ |
fn a_heading_says_which_level_it_was() {
|
|
720 |
+ |
// The one this was written for: a destination with no markup can still
|
|
721 |
+ |
// set one line heavier than the next, and until now it was not told
|
|
722 |
+ |
// which line.
|
|
723 |
+ |
assert_eq!(
|
|
724 |
+ |
blocks("# Title\n\nBody.\n\n### Deeper"),
|
|
725 |
+ |
vec![
|
|
726 |
+ |
("Title".to_string(), Block::Heading(1)),
|
|
727 |
+ |
("\n\nBody.\n\n".to_string(), Block::Prose),
|
|
728 |
+ |
("Deeper".to_string(), Block::Heading(3)),
|
|
729 |
+ |
]
|
|
730 |
+ |
);
|
|
731 |
+ |
}
|
|
732 |
+ |
|
|
733 |
+ |
#[test]
|
|
734 |
+ |
fn an_item_carries_no_marker_of_its_own() {
|
|
735 |
+ |
// What a bullet looks like is the caller's answer, and a number would be
|
|
736 |
+ |
// wrong for half of them. The separator between two items is prose,
|
|
737 |
+ |
// which is also what keeps them from merging into one run.
|
|
738 |
+ |
assert_eq!(
|
|
739 |
+ |
blocks("- one\n- two"),
|
|
740 |
+ |
vec![
|
|
741 |
+ |
("one".to_string(), Block::Item),
|
|
742 |
+ |
("\n".to_string(), Block::Prose),
|
|
743 |
+ |
("two".to_string(), Block::Item),
|
|
744 |
+ |
]
|
|
745 |
+ |
);
|
|
746 |
+ |
assert_eq!(render_plain("1. one\n2. two"), "one\ntwo");
|
|
747 |
+ |
}
|
|
748 |
+ |
|
|
749 |
+ |
#[test]
|
|
750 |
+ |
fn the_innermost_block_wins_and_the_outer_one_comes_back() {
|
|
751 |
+ |
// An item inside a quote reads as an item: a caller with one line to
|
|
752 |
+ |
// draw has to pick one of the two, and the inner one holds the words.
|
|
753 |
+ |
// What the stack is for is the line after it, where the quote is still
|
|
754 |
+ |
// open and prose would be wrong.
|
|
755 |
+ |
assert_eq!(
|
|
756 |
+ |
blocks("> - one\n>\n> after"),
|
|
757 |
+ |
vec![
|
|
758 |
+ |
("one".to_string(), Block::Item),
|
|
759 |
+ |
("\n\n".to_string(), Block::Prose),
|
|
760 |
+ |
("after".to_string(), Block::Quote),
|
|
761 |
+ |
]
|
|
762 |
+ |
);
|
|
763 |
+ |
}
|
|
764 |
+ |
|
|
765 |
+ |
#[test]
|
|
766 |
+ |
fn a_block_role_does_not_disturb_the_words() {
|
|
767 |
+ |
// The invariant again, over the sources that carry blocks. Adding a
|
|
768 |
+ |
// reason to split a run must not add or drop a character.
|
|
769 |
+ |
for source in [
|
|
770 |
+ |
"# Title\n\nBody.",
|
|
771 |
+ |
"- one\n- two\n\nAfter.",
|
|
772 |
+ |
"> quoted\n\nafter",
|
|
773 |
+ |
"> - **one**\n> - two\n\nAfter.",
|
|
774 |
+ |
"1. one\n2. two",
|
|
775 |
+ |
] {
|
|
776 |
+ |
let joined: String = render_runs(source)
|
|
777 |
+ |
.into_iter()
|
|
778 |
+ |
.map(|run| run.text)
|
|
779 |
+ |
.collect();
|
|
780 |
+ |
assert_eq!(joined, render_plain(source), "source: {source:?}");
|
|
781 |
+ |
}
|
|
782 |
+ |
}
|
|
783 |
+ |
|
| 624 |
784 |
|
#[test]
|
| 625 |
785 |
|
fn a_line_ending_in_its_own_run_of_whitespace_loses_it() {
|
| 626 |
786 |
|
// A row ends every cell with a tab, including the last one, so each
|