rich/markdown.rs
1//! Markdown rendering.
2//!
3//! Port of upstream `rich/markdown.py` (core block/inline elements). Parses
4//! CommonMark with `pulldown-cmark` and renders each block as justified,
5//! full-width lines separated by blank lines.
6//!
7//! Scope: paragraphs, ATX headings (h1โh6), bullet + ordered lists, block quotes,
8//! thematic breaks, fenced/indented **code blocks** (syntax-highlighted via
9//! [`Syntax`]), **links** (OSC 8 hyperlinks), inline strong/emphasis/code, and
10//! **GFM tables** (rendered via [`Table`]). Inline styling *within* a table cell
11//! is a documented follow-up (see the Markdown issue).
12
13use pulldown_cmark::{
14 Alignment, CodeBlockKind, CowStr, Event, HeadingLevel, LinkType, Options, Parser, Tag, TagEnd,
15};
16
17use crate::cells::cell_len;
18use crate::console::{Console, ConsoleOptions, Justify};
19use crate::protocol::Renderable;
20use crate::r#box::SIMPLE;
21use crate::segment::Segment;
22use crate::style::Style;
23use crate::syntax::Syntax;
24use crate::table::Table;
25use crate::text::Text;
26
27const CODE_STYLE: &str = "bold cyan on black"; // markdown.code
28/// The placeholder upstream's `ImageItem` puts in front of an image
29/// (`Text.assemble("๐ ", title, " ")`). U+1F306 measures two cells.
30const IMAGE_MARKER: &str = "\u{1f306} ";
31const BULLET: &str = " \u{2022} "; // " โข ", markdown.item.bullet = bold
32const QUOTE_PREFIX: &str = "\u{258c} "; // "โ ", markdown.block_quote = magenta
33const LINK_STYLE: &str = "bright_blue"; // markdown.link
34const LINK_URL_STYLE: &str = "underline blue"; // markdown.link_url
35const TABLE_BORDER_STYLE: &str = "cyan"; // markdown.table.border
36const TABLE_HEADER_STYLE: &str = "not bold cyan"; // markdown.table.header
37
38/// One item of a list. An item is a **container**: it holds whatever blocks it
39/// contains โ paragraphs, code, tables, quotes, further lists โ not a single
40/// line of text.
41///
42/// `number` is `Some` for an ordered list and carries the value to print.
43struct ListEntry {
44 number: Option<u64>,
45 blocks: Vec<Block>,
46}
47
48/// An open container while parsing.
49///
50/// Markdown nests, so parsing it needs a stack. Tracking the open list, quote
51/// and paragraph in flat `Option`s meant any nested block overwrote its
52/// parent's pending content: a heading inside a list item deleted the item's
53/// own text, a nested quote deleted the outer quote, and a code block inside an
54/// item was hoisted above the whole list.
55enum Frame {
56 List {
57 ordered: bool,
58 start: u64,
59 entries: Vec<ListEntry>,
60 },
61 Item {
62 blocks: Vec<Block>,
63 },
64 Quote {
65 blocks: Vec<Block>,
66 },
67}
68
69/// A parsed Markdown block.
70enum Block {
71 /// A paragraph or heading (its `Text` carries justify + any heading span).
72 Text(Text),
73 /// A bullet or ordered list. Each item holds its own blocks, so a nested
74 /// list, code block or quote inside an item is simply part of that item.
75 List { items: Vec<ListEntry> },
76 /// A block quote, holding whatever blocks it contains.
77 Quote {
78 blocks: Vec<Block>,
79 leading_break: bool,
80 },
81 /// An ignored HTML block still participates in upstream block spacing.
82 Html,
83 /// A fenced/indented code block, syntax-highlighted via [`Syntax`].
84 Code { language: String, code: String },
85 /// A thematic break (horizontal rule).
86 Rule,
87 /// An image placeholder. Upstream's `ImageItem` renders `๐ <title> ` and
88 /// says nothing about the picture itself; `text` is that whole assembly.
89 ///
90 /// `joins_next` reproduces `ImageItem.new_line = False` together with the
91 /// `end=""` on its text: nothing separates the marker from whatever renders
92 /// next, so the following block continues on the marker's own row. Only an
93 /// image lifted out of a *top-level* paragraph or heading behaves that way โ
94 /// see [`parse`] for why one inside a list or quote does not.
95 ///
96 /// `leading_break` is upstream's `new_line` flag frozen at the moment the
97 /// image was reached: a break precedes it only if some element had already
98 /// closed. It replaces the usual inter-block gap rather than adding to it.
99 Image {
100 text: Text,
101 joins_next: bool,
102 leading_break: bool,
103 },
104 /// A GFM table: per-column justify (from the alignment row), header cells,
105 /// and body rows. Rendered via [`Table`], matching upstream's construction.
106 Table {
107 alignments: Vec<Justify>,
108 headers: Vec<String>,
109 rows: Vec<Vec<String>>,
110 },
111}
112
113/// Accumulates a GFM table across `pulldown-cmark`'s table events.
114#[derive(Default)]
115struct TableAccum {
116 alignments: Vec<Justify>,
117 headers: Vec<String>,
118 rows: Vec<Vec<String>>,
119 in_head: bool,
120 in_cell: bool,
121 cur_row: Vec<String>,
122 cur_cell: String,
123}
124
125fn alignment_justify(alignment: Alignment) -> Justify {
126 match alignment {
127 Alignment::Right => Justify::Right,
128 Alignment::Center => Justify::Center,
129 // `None` has no explicit marker; upstream leaves it default (left).
130 Alignment::Left | Alignment::None => Justify::Left,
131 }
132}
133
134/// A rendered Markdown document. Mirrors `rich.markdown.Markdown`.
135pub struct Markdown {
136 source: String,
137 hyperlinks: bool,
138 blocks: Vec<Block>,
139}
140
141impl Markdown {
142 /// Parse CommonMark `source` into renderable blocks.
143 ///
144 /// Hyperlinks are on, matching `rich.markdown.Markdown(hyperlinks=True)`.
145 /// **The CLI wants them off** โ see [`hyperlinks`](Self::hyperlinks).
146 pub fn new(source: &str) -> Self {
147 Markdown {
148 source: source.to_string(),
149 hyperlinks: true,
150 blocks: parse(source, true),
151 }
152 }
153
154 /// Choose how a `[text](url)` is rendered. Port of
155 /// `rich.markdown.Markdown(hyperlinks=โฆ)`, default `true`.
156 ///
157 /// * `true` โ the text becomes an OSC 8 hyperlink pointing at the URL.
158 /// * `false` โ the URL is written out after the text, as
159 /// `text (https://example.com)`.
160 ///
161 /// The distinction is not cosmetic. An OSC 8 escape is only emitted when
162 /// the console has a colour system, so with hyperlinks on a piped or
163 /// `NO_COLOR` render drops every destination with nothing left to recover
164 /// it from. That is why upstream's **`rich-cli` passes `hyperlinks=False`
165 /// by default** and puts the OSC 8 form behind its opt-in `-y/--hyperlinks`
166 /// flag; a CLI built on this crate should do the same:
167 ///
168 /// ```
169 /// # use rich::markdown::Markdown;
170 /// let opt_in = false; // set by `-y/--hyperlinks`
171 /// let md = Markdown::new("A [link](https://example.com).").hyperlinks(opt_in);
172 /// ```
173 pub fn hyperlinks(mut self, hyperlinks: bool) -> Self {
174 // The flag changes what the *text* of a paragraph or table cell is, not
175 // just how it is painted, so the document has to be re-parsed.
176 if hyperlinks != self.hyperlinks {
177 self.blocks = parse(&self.source, hyperlinks);
178 self.hyperlinks = hyperlinks;
179 }
180 self
181 }
182}
183
184fn heading_level(level: HeadingLevel) -> usize {
185 match level {
186 HeadingLevel::H1 => 1,
187 HeadingLevel::H2 => 2,
188 HeadingLevel::H3 => 3,
189 HeadingLevel::H4 => 4,
190 HeadingLevel::H5 => 5,
191 HeadingLevel::H6 => 6,
192 }
193}
194
195/// `(base style, justify)` for a heading level (`default_styles.py` +
196/// `Heading.LEVEL_ALIGN`).
197fn heading_format(level: usize) -> (Style, Justify) {
198 let (spec, justify) = match level {
199 1 => ("bold underline", Justify::Center),
200 2 => ("underline magenta", Justify::Left),
201 3 => ("bold magenta", Justify::Left),
202 4 => ("italic magenta", Justify::Left),
203 5 => ("italic", Justify::Left),
204 _ => ("dim", Justify::Left),
205 };
206 (Style::parse(spec).unwrap_or_default(), justify)
207}
208
209fn inline_style(strong: usize, emphasis: usize, strike: usize) -> Option<Style> {
210 if strong == 0 && emphasis == 0 && strike == 0 {
211 return None;
212 }
213 let mut style = Style::new();
214 if strong > 0 {
215 style = style.combine(&Style::parse("bold").expect("valid style"));
216 }
217 if emphasis > 0 {
218 style = style.combine(&Style::parse("italic").expect("valid style"));
219 }
220 if strike > 0 {
221 // `markdown.s` in upstream's default theme.
222 style = style.combine(&Style::parse("strike").expect("valid style"));
223 }
224 Some(style)
225}
226
227/// `markdown.link_url` plus the OSC 8 target, which is what upstream pushes for
228/// a link when `hyperlinks=True`.
229fn link_style(url: &str) -> Style {
230 Style::parse(LINK_URL_STYLE)
231 .expect("valid style")
232 .with_link(url.to_string())
233}
234
235/// Upstream's `MarkdownContext.style_stack.current`: the product of every style
236/// open at this point, outermost first, each layer overriding the last.
237///
238/// The order is what makes an inline style compose rather than replace. A link
239/// inside `**bold**` is `bold underline blue`, not plain `underline blue`; a
240/// `` `code` `` inside a link keeps the link *and* takes cyan over the link's
241/// blue. Applying only the innermost layer dropped the outer attributes, and โ
242/// worse โ a link whose whole text was inline code lost its URL entirely.
243///
244/// `extra` is the run's own style (`markdown.code` for a code span), pushed last
245/// because upstream enters it after the link.
246fn stack_style(
247 heading: Option<&Style>,
248 inline: Option<Style>,
249 link: Option<&str>,
250 extra: Option<Style>,
251) -> Option<Style> {
252 let mut current: Option<Style> = None;
253 for layer in [heading.cloned(), inline, link.map(link_style), extra] {
254 let Some(next) = layer else { continue };
255 current = Some(match current {
256 Some(previous) => previous.combine(&next),
257 None => next,
258 });
259 }
260 current
261}
262
263/// The title upstream shows when an image has no alt text: the last path
264/// component of its destination, `destination.strip("/").rsplit("/", 1)[-1]`.
265///
266/// Without it `` rendered as a blank line โ a badge row in a README
267/// simply disappeared.
268fn image_fallback_title(destination: &str) -> &str {
269 let trimmed = destination.trim_matches('/');
270 match trimmed.rsplit_once('/') {
271 Some((_, last)) => last,
272 None => trimmed,
273 }
274}
275
276/// Assemble upstream's `Text.assemble("๐ ", title, " ")` for one image.
277///
278/// `link` is the URL of an enclosing `[โฆ](โฆ)`, which upstream prefers over the
279/// image's own destination (`self.link or self.destination`) so that a linked
280/// badge points at the link, not at the picture.
281///
282/// With `hyperlinks` off the target is dropped entirely:
283/// `ImageItem.__rich_console__` guards its `title.stylize(link_style)` behind
284/// `if self.hyperlinks`, so the marker carries no OSC 8 escape at all.
285fn image_text(
286 destination: &str,
287 alt: Text,
288 link: Option<&str>,
289 outer: Option<Style>,
290 hyperlinks: bool,
291) -> Text {
292 let mut title = if alt.plain().is_empty() {
293 Text::new(image_fallback_title(destination))
294 } else {
295 alt
296 };
297 let end = title.plain().len();
298 // `ImageItem.on_text` appends with `context.current_style`, so the title
299 // carries whatever was open around the image โ a heading's style, and the
300 // enclosing link's `markdown.link_url` for a badge wrapped in a link.
301 if let Some(style) = outer {
302 title.stylize(style, 0, end);
303 }
304 // `Style(link=self.link or self.destination or None)`: the enclosing link
305 // wins, the image's own destination is the fallback, and neither being set
306 // leaves the title unlinked.
307 if hyperlinks {
308 let target = link.unwrap_or(destination);
309 if !target.is_empty() {
310 title.stylize(Style::new().with_link(target.to_string()), 0, end);
311 }
312 }
313 let mut text = Text::new(IMAGE_MARKER).append_text(&title);
314 text.append(" ", None);
315 text
316}
317
318/// Where a finished block belongs: the innermost open item or quote, else the
319/// document. A `List` frame holds entries rather than blocks, so content passes
320/// straight through it to the item that owns it.
321fn sink<'a>(document: &'a mut Vec<Block>, stack: &'a mut [Frame]) -> &'a mut Vec<Block> {
322 match stack
323 .iter()
324 .rposition(|frame| matches!(frame, Frame::Item { .. } | Frame::Quote { .. }))
325 {
326 Some(index) => match &mut stack[index] {
327 Frame::Item { blocks } | Frame::Quote { blocks } => blocks,
328 Frame::List { .. } => unreachable!("rposition matched Item or Quote"),
329 },
330 None => document,
331 }
332}
333
334/// How deep containers may nest before further nesting is flattened.
335///
336/// Rendering recurses once per level, so an unbounded document overflows the
337/// stack and takes the process with it: 400 nested block quotes aborted with
338/// STATUS_STACK_OVERFLOW, no output, after burning four seconds of CPU.
339///
340/// Upstream caps this too โ markdown-it's `maxNesting` defaults to 20, which is
341/// why it renders such a document rather than dying. Content past the cap is
342/// kept; it simply stops indenting.
343const MAX_NESTING: usize = 20;
344
345/// Commit any pending inline text to the innermost open container.
346///
347/// A *tight* list item's text arrives as bare `Text` events with no enclosing
348/// paragraph, so it sits in `current` until something closes it. Every
349/// block-level start must call this first, or it overwrites that text โ which
350/// silently deleted the item's own content and reordered code blocks ahead of
351/// the paragraph introducing them.
352fn flush_pending(current: &mut Option<Text>, blocks: &mut Vec<Block>, stack: &mut [Frame]) {
353 let Some(mut text) = current.take() else {
354 return;
355 };
356 // A freshly opened item holds an empty buffer; committing it would emit a
357 // blank block.
358 if text.plain().is_empty() {
359 return;
360 }
361 text.set_justify(Justify::Left);
362 sink(blocks, stack).push(Block::Text(text));
363}
364
365/// Emit a literal `~` for a single-tilde span, into whichever buffer the
366/// surrounding characters are going to.
367///
368/// Inside a link label the label text is buffered separately, so appending
369/// straight to `current` put BOTH tildes in front of the label: `[~a~ label]`
370/// rendered as `~~a label`, characters reordered rather than restyled. Outside
371/// one the buffer may not be open yet, so it still has to be created โ routing
372/// through a plain `as_mut()` silently DROPPED the tilde instead.
373fn push_tilde(current: &mut Option<Text>, link_label: &mut Option<String>) {
374 if let Some(label) = link_label.as_mut() {
375 label.push('~');
376 } else {
377 current
378 .get_or_insert_with(|| Text::new(""))
379 .append("~", None);
380 }
381}
382
383/// Append a soft/hard break to the open link label if one is being buffered,
384/// else to the open text buffer if there is one.
385fn append_break(
386 current: Option<&mut Text>,
387 link_label: Option<&mut String>,
388 text: &str,
389 style: Option<Style>,
390) {
391 if let Some(label) = link_label {
392 label.push_str(text);
393 } else if let Some(block) = current {
394 block.append(text, style.map(Into::into));
395 }
396}
397
398/// One inline token while pairing tildes: an untouched event, literal source
399/// text, a `~~` delimiter, or a delimiter that has been paired.
400enum Piece<'a> {
401 Event(Event<'a>, std::ops::Range<usize>),
402 Literal(std::ops::Range<usize>),
403 Tilde(std::ops::Range<usize>),
404 Open(std::ops::Range<usize>),
405 Close(std::ops::Range<usize>),
406}
407
408/// A `~~` delimiter in markdown-it's `Delimiter` sense. `length` is always 0
409/// for strikethrough (upstream disables the "rule of 3"), so it is omitted.
410struct Delimiter {
411 piece: usize,
412 open: bool,
413 close: bool,
414 end: Option<usize>,
415 /// Innermost emphasis/strong span containing the delimiter. markdown-it
416 /// pairs `*`/`_` and `~` in one pass, and a matched pair's jump hides every
417 /// delimiter inside it from later closers; tildes are never paired across
418 /// an emphasis span here for the same reason (see DIVERGENCES ยง21).
419 emphasis: usize,
420}
421
422fn is_md_ascii_punct(c: char) -> bool {
423 c.is_ascii_punctuation()
424}
425
426/// markdown-it's `isPunctChar`: ASCII punctuation or a Unicode punctuation
427/// category. `char::is_ascii_punctuation` plus general punctuation/symbols is
428/// the closest std-only equivalent.
429fn is_punct_char(c: char) -> bool {
430 c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control())
431}
432
433/// Port of markdown-it's `StateInline.scanDelims` for a tilde run
434/// (`canSplitWord = True`), returning `(can_open, can_close)`.
435fn scan_delims(last: char, next: char) -> (bool, bool) {
436 let last_punct = is_md_ascii_punct(last) || is_punct_char(last);
437 let next_punct = is_md_ascii_punct(next) || is_punct_char(next);
438 let last_space = last.is_whitespace();
439 let next_space = next.is_whitespace();
440 let left_flanking = !(next_space || (next_punct && !(last_space || last_punct)));
441 let right_flanking = !(last_space || (last_punct && !(next_space || next_punct)));
442 (left_flanking, right_flanking)
443}
444
445/// Port of markdown-it's `balance_pairs.processDelimiters` for one delimiter
446/// list (a single marker, lengths 0).
447fn process_delimiters(delimiters: &mut [Delimiter]) {
448 if delimiters.is_empty() {
449 return;
450 }
451 // `openersBottom[marker]`, indexed by `closer.open ? 3 : 0` (length % 3 = 0).
452 let mut openers_bottom = [-1isize; 6];
453 let mut header = 0usize;
454 let mut last_piece: isize = -2;
455 let mut jumps: Vec<usize> = Vec::with_capacity(delimiters.len());
456 for closer_index in 0..delimiters.len() {
457 jumps.push(0);
458 if last_piece != delimiters[closer_index].piece as isize - 1 {
459 header = closer_index;
460 }
461 last_piece = delimiters[closer_index].piece as isize;
462 if !delimiters[closer_index].close {
463 continue;
464 }
465 let slot = if delimiters[closer_index].open { 3 } else { 0 };
466 let min_opener = openers_bottom[slot];
467 let mut opener_index = header as isize - jumps[header] as isize - 1;
468 let mut new_min = opener_index;
469 while opener_index > min_opener {
470 let i = opener_index as usize;
471 let usable = delimiters[i].open
472 && delimiters[i].end.is_none()
473 && delimiters[i].emphasis == delimiters[closer_index].emphasis;
474 if usable {
475 let last_jump = if i > 0 && !delimiters[i - 1].open {
476 jumps[i - 1] + 1
477 } else {
478 0
479 };
480 jumps[closer_index] = closer_index - i + last_jump;
481 jumps[i] = last_jump;
482 delimiters[closer_index].open = false;
483 delimiters[i].end = Some(closer_index);
484 delimiters[i].close = false;
485 new_min = -1;
486 last_piece = -2;
487 break;
488 }
489 opener_index -= jumps[i] as isize + 1;
490 }
491 if new_min != -1 {
492 openers_bottom[slot] = new_min;
493 }
494 }
495}
496
497/// Pair tilde runs the way upstream's markdown-it does (its `strikethrough`
498/// tokenize + `balance_pairs` + postProcess), over pulldown-cmark events parsed
499/// *without* strikethrough.
500///
501/// Per inline run (a paragraph, heading, table cell or tight list item, with a
502/// link label as its own nested scope, as markdown-it scopes delimiters per
503/// opening token): each run of two or more tildes in literal text becomes an
504/// optional leading `~` (odd runs) plus `~~` delimiters; paired delimiters turn
505/// into `Strikethrough` events, and a lone `~` left before a closer moves after
506/// it. `a ~~~x~~~ b` renders `a ~` + struck `x` + `~ b`, as upstream does.
507fn pair_strikethrough<'a>(
508 source: &'a str,
509 events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
510) -> Vec<(Event<'a>, std::ops::Range<usize>)> {
511 let mut pieces: Vec<Piece<'a>> = Vec::new();
512 // Delimiter lists: one per open scope; a link pushes a nested one.
513 let mut scopes: Vec<Vec<Delimiter>> = vec![Vec::new()];
514 let mut finished: Vec<Vec<Delimiter>> = Vec::new();
515 let mut emphasis_stack: Vec<usize> = Vec::new();
516 let mut next_emphasis = 1usize;
517 let mut in_code = false;
518 let mut in_cell = false;
519 let mut image_depth = 0usize;
520
521 let neighbour = |c: Option<char>, in_cell: bool| match c {
522 None => ' ',
523 // markdown-it parses a trimmed cell, so a pipe reads as the edge.
524 Some('|') if in_cell => ' ',
525 Some(c) => c,
526 };
527
528 for (event, range) in events {
529 if image_depth > 0 {
530 match &event {
531 Event::Start(Tag::Image { .. }) => image_depth += 1,
532 Event::End(TagEnd::Image) => image_depth -= 1,
533 _ => {}
534 }
535 pieces.push(Piece::Event(event, range));
536 continue;
537 }
538 match &event {
539 Event::Text(text) if !in_code && **text == source[range.clone()] => {
540 // Merge with a directly preceding literal so a run split across
541 // two text events is scanned as one.
542 let mut start = range.start;
543 if let Some(Piece::Literal(previous)) = pieces.last() {
544 if previous.end == range.start {
545 start = previous.start;
546 pieces.pop();
547 }
548 }
549 let end = range.end;
550 let bytes = source.as_bytes();
551 let mut at = start;
552 let mut literal_from = start;
553 while at < end {
554 if bytes[at] != b'~' {
555 at += 1;
556 continue;
557 }
558 let run_start = at;
559 while at < end && bytes[at] == b'~' {
560 at += 1;
561 }
562 let length = at - run_start;
563 if length < 2 {
564 continue;
565 }
566 if literal_from < run_start {
567 pieces.push(Piece::Literal(literal_from..run_start));
568 }
569 let last = neighbour(source[..run_start].chars().next_back(), in_cell);
570 let next = neighbour(source[at..].chars().next(), in_cell);
571 let (open, close) = scan_delims(last, next);
572 let mut from = run_start;
573 if length % 2 == 1 {
574 pieces.push(Piece::Literal(from..from + 1));
575 from += 1;
576 }
577 let emphasis = emphasis_stack.last().copied().unwrap_or(0);
578 while from < at {
579 pieces.push(Piece::Tilde(from..from + 2));
580 scopes.last_mut().expect("scope").push(Delimiter {
581 piece: pieces.len() - 1,
582 open,
583 close,
584 end: None,
585 emphasis,
586 });
587 from += 2;
588 }
589 literal_from = at;
590 }
591 if literal_from < end {
592 pieces.push(Piece::Literal(literal_from..end));
593 }
594 continue;
595 }
596 Event::Start(Tag::Emphasis | Tag::Strong) => {
597 emphasis_stack.push(next_emphasis);
598 next_emphasis += 1;
599 }
600 Event::End(TagEnd::Emphasis | TagEnd::Strong) => {
601 emphasis_stack.pop();
602 }
603 Event::Start(Tag::Link { .. }) => scopes.push(Vec::new()),
604 Event::End(TagEnd::Link) => {
605 if scopes.len() > 1 {
606 finished.push(scopes.pop().expect("link scope"));
607 }
608 }
609 Event::Start(Tag::Image { .. }) => image_depth = 1,
610 Event::Text(_)
611 | Event::Code(_)
612 | Event::InlineHtml(_)
613 | Event::SoftBreak
614 | Event::HardBreak
615 | Event::FootnoteReference(_)
616 | Event::InlineMath(_) => {}
617 // Anything else is block structure: the inline run ends here.
618 _ => {
619 match &event {
620 Event::Start(Tag::CodeBlock(_)) => in_code = true,
621 Event::End(TagEnd::CodeBlock) => in_code = false,
622 Event::Start(Tag::TableCell) => in_cell = true,
623 Event::End(TagEnd::TableCell) => in_cell = false,
624 _ => {}
625 }
626 finished.append(&mut scopes);
627 scopes.push(Vec::new());
628 emphasis_stack.clear();
629 }
630 }
631 pieces.push(Piece::Event(event, range));
632 }
633 finished.append(&mut scopes);
634
635 // Pair, then mark: markdown-it's strikethrough `_postProcess`.
636 let mut lone_markers: Vec<usize> = Vec::new();
637 for mut delimiters in finished {
638 process_delimiters(&mut delimiters);
639 for delimiter in &delimiters {
640 let Some(end) = delimiter.end else { continue };
641 let closer = delimiters[end].piece;
642 if let Piece::Tilde(range) = &pieces[delimiter.piece] {
643 pieces[delimiter.piece] = Piece::Open(range.clone());
644 }
645 if let Piece::Tilde(range) = &pieces[closer] {
646 pieces[closer] = Piece::Close(range.clone());
647 }
648 if let Some(Piece::Literal(range)) = closer.checked_sub(1).map(|i| &pieces[i]) {
649 if &source[range.clone()] == "~" {
650 lone_markers.push(closer - 1);
651 }
652 }
653 }
654 }
655 // An odd run is split as `~` + `~~`โฆ, so a closer can leave its lone `~`
656 // in front of it: move it after the closing tags.
657 while let Some(i) = lone_markers.pop() {
658 let mut j = i + 1;
659 while j < pieces.len() && matches!(pieces[j], Piece::Close(_)) {
660 j += 1;
661 }
662 j -= 1;
663 if i != j {
664 pieces.swap(i, j);
665 }
666 }
667
668 // markdown-it's `fragments_join`: adjacent text tokens become one, so a
669 // run like `a ~` renders as a single span rather than one per piece.
670 let mut out: Vec<(Event<'a>, std::ops::Range<usize>)> = Vec::with_capacity(pieces.len());
671 for piece in pieces {
672 let (event, range) = match piece {
673 Piece::Event(event, range) => (event, range),
674 Piece::Literal(range) | Piece::Tilde(range) => {
675 (Event::Text(CowStr::Borrowed(&source[range.clone()])), range)
676 }
677 Piece::Open(range) => (Event::Start(Tag::Strikethrough), range),
678 Piece::Close(range) => (Event::End(TagEnd::Strikethrough), range),
679 };
680 if let (Event::Text(text), Some((Event::Text(previous), previous_range))) =
681 (&event, out.last_mut())
682 {
683 let mut joined = previous.to_string();
684 joined.push_str(text);
685 *previous = CowStr::Boxed(joined.into_boxed_str());
686 *previous_range =
687 previous_range.start.min(range.start)..previous_range.end.max(range.end);
688 continue;
689 }
690 out.push((event, range));
691 }
692 out
693}
694
695fn parse(source: &str, hyperlinks: bool) -> Vec<Block> {
696 let mut blocks: Vec<Block> = Vec::new();
697 let mut current: Option<Text> = None;
698 let mut heading_style: Option<Style> = None;
699 let mut justify = Justify::Left;
700 let mut strong = 0usize;
701 let mut emphasis = 0usize;
702 let mut strike = 0usize;
703 // Depth of single-tilde spans currently open; their delimiters are re-emitted
704 // as literal text so the run is not styled.
705 let mut single_tilde = 0usize;
706 // Open containers, innermost last. Markdown nests, so this has to be a
707 // stack: with flat slots, any nested block overwrote its parent's pending
708 // content and the parent then emitted nothing.
709 let mut stack: Vec<Frame> = Vec::new();
710 // Containers past MAX_NESTING are not pushed; these count them so the
711 // matching End events unwind symmetrically and the stack stays balanced.
712 let mut suppressed = 0usize;
713 let mut item_suppressed = 0usize;
714 // (language, accumulated source) while inside a code block.
715 let mut code: Option<(String, String)> = None;
716 // The destination URL while inside a link.
717 let mut link: Option<String> = None;
718 // The label of the open link, when hyperlinks are off. Upstream pushes a
719 // `Link` **element** at `link_close`-time rather than a style, so every
720 // token in between is captured by it instead of by the paragraph, and only
721 // `element.text.plain` is re-emitted at the close. That is why the label's
722 // own emphasis is lost: `[**bold** label](u)` prints an unbolded
723 // `bold label`. `None` whenever hyperlinks are on, where the label is
724 // styled in place and this buffer must stay out of the way.
725 let mut link_label: Option<String> = None;
726 // Destination of the image being parsed, and the source span of its alt.
727 let mut image: Option<String> = None;
728 let mut image_span: Option<(usize, usize)> = None;
729 // Upstream's `new_line` flag: set by every element that closes, cleared by
730 // an image (`ImageItem.new_line = False`) and by a rule. Only images read
731 // it, and it is why one lifted out of the *second* list item gets a blank
732 // row above it while one lifted out of the first does not.
733 let mut new_line = false;
734 // The table being assembled while inside a GFM table.
735 let mut table: Option<TableAccum> = None;
736
737 // Strikethrough is *not* enabled in pulldown-cmark: it pairs tilde runs by
738 // GFM rules (equal-length runs, single tildes allowed), while upstream's
739 // markdown-it splits runs into `~~` delimiters and pairs those. The
740 // tildes arrive as literal text and `pair_strikethrough` reproduces
741 // markdown-it's pairing, emitting ordinary `Strikethrough` events whose
742 // range is the `~~` delimiter.
743 let options = Options::ENABLE_TABLES;
744 let events = Parser::new_ext(source, options).into_offset_iter();
745 for (event, range) in pair_strikethrough(source, events) {
746 // Everything between an image's brackets is its alt text, and upstream
747 // takes that from the *raw* markdown (`token.content`) rather than from
748 // parsed inline events: `` shows `alt *em*`, asterisks and
749 // all. Widening the source span is the only way back to the literal
750 // text once pulldown-cmark has turned the markers into events.
751 if image.is_some() && !matches!(event, Event::End(TagEnd::Image)) {
752 image_span = Some(match image_span {
753 Some((start, end)) => (start.min(range.start), end.max(range.end)),
754 None => (range.start, range.end),
755 });
756 continue;
757 }
758 // Upstream's `new_line = element.new_line` bookkeeping, which runs for
759 // every element that closes. Everything declares `new_line = True`
760 // except an image and a rule. Images and closing quotes read the
761 // preceding value before their own closing event changes it.
762 let preceding_new_line = new_line;
763 match &event {
764 Event::End(
765 TagEnd::Paragraph
766 | TagEnd::Heading(_)
767 | TagEnd::List(_)
768 | TagEnd::Item
769 | TagEnd::BlockQuote(_)
770 | TagEnd::CodeBlock
771 | TagEnd::Table
772 | TagEnd::TableHead
773 | TagEnd::TableRow
774 | TagEnd::TableCell
775 | TagEnd::HtmlBlock,
776 ) => new_line = true,
777 Event::Rule => new_line = false,
778 _ => {}
779 }
780 match event {
781 Event::End(TagEnd::HtmlBlock) => {
782 sink(&mut blocks, &mut stack).push(Block::Html);
783 }
784 Event::Rule => {
785 flush_pending(&mut current, &mut blocks, &mut stack);
786 sink(&mut blocks, &mut stack).push(Block::Rule);
787 }
788 Event::Start(Tag::Link {
789 link_type,
790 dest_url,
791 ..
792 }) => {
793 // An email autolink (`<user@example.org>`) carries a `mailto:`
794 // destination in CommonMark, but pulldown-cmark leaves the
795 // scheme to the renderer and hands us the bare address. Adding
796 // it is what makes the destination a usable URL โ upstream's
797 // markdown-it puts it in the `href` itself.
798 link = Some(match link_type {
799 LinkType::Email => format!("mailto:{dest_url}"),
800 _ => dest_url.to_string(),
801 });
802 if !hyperlinks {
803 link_label = Some(String::new());
804 }
805 }
806 Event::End(TagEnd::Link) => {
807 let url = link.take();
808 let label = link_label.take();
809 // `hyperlinks=False`: upstream flushes the buffered label under
810 // `markdown.link` and then writes the destination out after it โ
811 // `A link (https://example.com) here.`
812 //
813 // Emitting nothing here (our only behaviour before) loses the
814 // URL outright the moment the console has no colour system, and
815 // a pipe has no OSC 8 escape to recover it from. `rich -m`
816 // passes `hyperlinks=False`, so that was every URL in every
817 // redirected render.
818 if let Some(url) = url.filter(|_| !hyperlinks) {
819 let label = label.unwrap_or_default();
820 let inline = inline_style(strong, emphasis, strike);
821 if let Some(acc) = table.as_mut().filter(|a| a.in_cell) {
822 // The URL is part of the cell's *text*, so it counts
823 // towards the column width โ a table of links laid out
824 // against the bare label is far too narrow.
825 acc.cur_cell.push_str(&label);
826 acc.cur_cell.push_str(" (");
827 acc.cur_cell.push_str(&url);
828 acc.cur_cell.push(')');
829 } else {
830 let block = current.get_or_insert_with(|| Text::new(""));
831 let layer = |style: Option<Style>| {
832 stack_style(heading_style.as_ref(), inline.clone(), None, style)
833 };
834 // An empty label appends a zero-length span upstream,
835 // which renders as nothing at all.
836 if !label.is_empty() {
837 block.append(
838 &label,
839 layer(Style::parse(LINK_STYLE).ok()).map(Into::into),
840 );
841 }
842 block.append(" (", layer(None).map(Into::into));
843 block.append(
844 &url,
845 layer(Style::parse(LINK_URL_STYLE).ok()).map(Into::into),
846 );
847 block.append(")", layer(None).map(Into::into));
848 }
849 }
850 }
851 // Images are emitted immediately rather than appended to their
852 // parent element. `TableDataElement` uses that same base
853 // `on_child_close`, so an image in a cell is hoisted above the
854 // eventual table and contributes no text to the cell.
855 Event::Start(Tag::Image { dest_url, .. }) => {
856 image = Some(dest_url.to_string());
857 image_span = None;
858 }
859 Event::End(TagEnd::Image) => {
860 if let Some(destination) = image.take() {
861 let alt = image_span
862 .take()
863 .map(|(start, end)| Text::new(&source[start..end]))
864 .unwrap_or_default();
865 // Pushed to the *document*, not to `sink`: upstream renders
866 // the image element the moment its token is reached, while
867 // the list or quote containing it is still open and will not
868 // render until it closes. An image inside a list therefore
869 // appears above the whole list, not inside the item.
870 //
871 // `joins_next` is only true at the top level: upstream emits
872 // no line break after an image, but a container closing
873 // after it (its paragraph having been captured) emits one of
874 // its own, so only a top-level paragraph or heading really
875 // continues on the marker's row.
876 blocks.push(Block::Image {
877 text: image_text(
878 &destination,
879 alt,
880 link.as_deref(),
881 stack_style(
882 heading_style.as_ref(),
883 inline_style(strong, emphasis, strike),
884 link.as_deref().filter(|_| hyperlinks),
885 None,
886 ),
887 hyperlinks,
888 ),
889 // A table is a container too, even though it uses a
890 // dedicated accumulator rather than a `Frame`. Its own
891 // render begins after the hoisted image's open row.
892 joins_next: stack.is_empty() && table.is_none(),
893 leading_break: new_line,
894 });
895 new_line = false;
896 }
897 }
898 Event::Start(Tag::CodeBlock(kind)) => {
899 flush_pending(&mut current, &mut blocks, &mut stack);
900 let language = match kind {
901 CodeBlockKind::Fenced(info) => {
902 // The info string is `lang` (possibly with extra tokens).
903 info.split_whitespace().next().unwrap_or("").to_string()
904 }
905 CodeBlockKind::Indented => String::new(),
906 };
907 code = Some((language, String::new()));
908 }
909 Event::End(TagEnd::CodeBlock) => {
910 if let Some((language, mut source)) = code.take() {
911 // Drop the single trailing newline the parser appends.
912 if source.ends_with('\n') {
913 source.pop();
914 }
915 sink(&mut blocks, &mut stack).push(Block::Code {
916 language,
917 code: source,
918 });
919 }
920 }
921 Event::Start(Tag::Table(aligns)) => {
922 flush_pending(&mut current, &mut blocks, &mut stack);
923 table = Some(TableAccum {
924 alignments: aligns.into_iter().map(alignment_justify).collect(),
925 ..TableAccum::default()
926 });
927 }
928 Event::End(TagEnd::Table) => {
929 if let Some(acc) = table.take() {
930 sink(&mut blocks, &mut stack).push(Block::Table {
931 alignments: acc.alignments,
932 headers: acc.headers,
933 rows: acc.rows,
934 });
935 }
936 }
937 Event::Start(Tag::TableHead) => {
938 if let Some(acc) = table.as_mut() {
939 acc.in_head = true;
940 acc.cur_row = Vec::new();
941 }
942 }
943 Event::End(TagEnd::TableHead) => {
944 if let Some(acc) = table.as_mut() {
945 acc.headers = std::mem::take(&mut acc.cur_row);
946 acc.in_head = false;
947 }
948 }
949 Event::Start(Tag::TableRow) => {
950 if let Some(acc) = table.as_mut() {
951 acc.cur_row = Vec::new();
952 }
953 }
954 Event::End(TagEnd::TableRow) => {
955 if let Some(acc) = table.as_mut() {
956 let row = std::mem::take(&mut acc.cur_row);
957 acc.rows.push(row);
958 }
959 }
960 Event::Start(Tag::TableCell) => {
961 if let Some(acc) = table.as_mut() {
962 acc.in_cell = true;
963 acc.cur_cell = String::new();
964 }
965 }
966 Event::End(TagEnd::TableCell) => {
967 if let Some(acc) = table.as_mut() {
968 let cell = std::mem::take(&mut acc.cur_cell);
969 acc.cur_row.push(cell);
970 acc.in_cell = false;
971 }
972 }
973 Event::Start(Tag::BlockQuote(_)) => {
974 flush_pending(&mut current, &mut blocks, &mut stack);
975 if stack.len() >= MAX_NESTING {
976 suppressed += 1;
977 } else {
978 stack.push(Frame::Quote { blocks: Vec::new() });
979 }
980 }
981 Event::End(TagEnd::BlockQuote(_)) => {
982 if suppressed > 0 {
983 suppressed -= 1;
984 } else if let Some(Frame::Quote { blocks: quoted }) = stack.pop() {
985 sink(&mut blocks, &mut stack).push(Block::Quote {
986 blocks: quoted,
987 leading_break: preceding_new_line,
988 });
989 }
990 }
991 Event::Start(Tag::List(first)) => {
992 flush_pending(&mut current, &mut blocks, &mut stack);
993 if stack.len() >= MAX_NESTING {
994 suppressed += 1;
995 } else {
996 stack.push(Frame::List {
997 ordered: first.is_some(),
998 start: first.unwrap_or(1),
999 entries: Vec::new(),
1000 });
1001 }
1002 }
1003 Event::End(TagEnd::List(_)) => {
1004 if suppressed > 0 {
1005 suppressed -= 1;
1006 } else if let Some(Frame::List { entries, .. }) = stack.pop() {
1007 sink(&mut blocks, &mut stack).push(Block::List { items: entries });
1008 }
1009 }
1010 Event::Start(Tag::Item) => {
1011 if stack.len() >= MAX_NESTING {
1012 item_suppressed += 1;
1013 } else {
1014 stack.push(Frame::Item { blocks: Vec::new() });
1015 }
1016 // A *tight* list emits its item text as bare `Text` events with
1017 // no enclosing Paragraph, so open a buffer here for it to land
1018 // in. A loose item simply resets this at its Start(Paragraph).
1019 current = Some(Text::new(""));
1020 heading_style = None;
1021 justify = Justify::Left;
1022 }
1023 Event::End(TagEnd::Item) => {
1024 // A *tight* list emits its item text without a Paragraph, so
1025 // anything still pending belongs to this item.
1026 if let Some(mut text) = current.take() {
1027 text.set_justify(Justify::Left);
1028 sink(&mut blocks, &mut stack).push(Block::Text(text));
1029 }
1030 if item_suppressed > 0 {
1031 item_suppressed -= 1;
1032 } else if let Some(Frame::Item {
1033 blocks: item_blocks,
1034 }) = stack.pop()
1035 {
1036 if let Some(Frame::List {
1037 ordered,
1038 start,
1039 entries,
1040 }) = stack.last_mut()
1041 {
1042 let number = ordered.then(|| *start + entries.len() as u64);
1043 entries.push(ListEntry {
1044 number,
1045 blocks: item_blocks,
1046 });
1047 }
1048 }
1049 }
1050 Event::Start(Tag::Paragraph) => {
1051 flush_pending(&mut current, &mut blocks, &mut stack);
1052 current = Some(Text::new(""));
1053 heading_style = None;
1054 justify = Justify::Left;
1055 }
1056 Event::Start(Tag::Heading { level, .. }) => {
1057 flush_pending(&mut current, &mut blocks, &mut stack);
1058 let (style, heading_justify) = heading_format(heading_level(level));
1059 current = Some(Text::new(""));
1060 heading_style = Some(style);
1061 justify = heading_justify;
1062 }
1063 Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Heading(_)) => {
1064 if let Some(mut text) = current.take() {
1065 let in_quote = stack
1066 .iter()
1067 .rposition(|f| matches!(f, Frame::Item { .. } | Frame::Quote { .. }))
1068 .is_some_and(|i| matches!(stack[i], Frame::Quote { .. }));
1069 if in_quote {
1070 // Quote paragraph: magenta base so its padding is magenta too.
1071 text.set_base_style(Style::parse("magenta").expect("valid style"));
1072 }
1073 // A heading's style rides on each run (upstream pushes
1074 // `markdown.h<n>` onto the style stack at `heading_open`, so
1075 // every inline style composes *over* it), never as a base
1076 // style โ a base style would paint the centring padding too,
1077 // which upstream leaves unstyled. Only the alignment is left
1078 // to apply here; treating a quoted heading as body text
1079 // flattened h1 to plain magenta and left-aligned it.
1080 text.set_justify(justify);
1081 sink(&mut blocks, &mut stack).push(Block::Text(text));
1082 }
1083 heading_style = None;
1084 justify = Justify::Left;
1085 strong = 0;
1086 emphasis = 0;
1087 }
1088 Event::Start(Tag::Strong) => strong += 1,
1089 Event::End(TagEnd::Strong) => strong = strong.saturating_sub(1),
1090 Event::Start(Tag::Strikethrough) => {
1091 if source[range.clone()].starts_with("~~") {
1092 strike += 1;
1093 } else {
1094 // Single-tilde: not a delimiter upstream. Keep the literal
1095 // text, tildes and all.
1096 //
1097 // Route it the same way as any other text: inside a link
1098 // label the surrounding characters are buffered separately,
1099 // so appending straight to `current` put BOTH tildes in
1100 // front of the label โ `[~a~ label]` came out as
1101 // `~~a label`, characters reordered rather than restyled.
1102 single_tilde += 1;
1103 push_tilde(&mut current, &mut link_label);
1104 }
1105 }
1106 Event::End(TagEnd::Strikethrough) => {
1107 if single_tilde > 0 {
1108 single_tilde -= 1;
1109 push_tilde(&mut current, &mut link_label);
1110 } else {
1111 strike = strike.saturating_sub(1);
1112 }
1113 }
1114 Event::Start(Tag::Emphasis) => emphasis += 1,
1115 Event::End(TagEnd::Emphasis) => emphasis = emphasis.saturating_sub(1),
1116 Event::Text(text) => {
1117 if let Some(label) = link_label.as_mut() {
1118 label.push_str(&text);
1119 } else if let Some(acc) = table.as_mut().filter(|a| a.in_cell) {
1120 // Table cells collect plain text; inline styling within a cell
1121 // is a documented follow-up (see the Markdown issue).
1122 acc.cur_cell.push_str(&text);
1123 } else if let Some((_, source)) = code.as_mut() {
1124 source.push_str(&text);
1125 } else {
1126 // Open a buffer if none is active. In a tight list item the
1127 // text after a nested block arrives bare, with the previous
1128 // buffer already flushed by that block's start โ matching
1129 // on `as_mut()` here silently dropped it.
1130 let block = current.get_or_insert_with(|| Text::new(""));
1131 let style = stack_style(
1132 heading_style.as_ref(),
1133 inline_style(strong, emphasis, strike),
1134 link.as_deref().filter(|_| hyperlinks),
1135 None,
1136 );
1137 block.append(&text, style.map(Into::into));
1138 }
1139 }
1140 Event::Code(text) => {
1141 if let Some(label) = link_label.as_mut() {
1142 label.push_str(&text);
1143 } else if let Some(acc) = table.as_mut().filter(|a| a.in_cell) {
1144 acc.cur_cell.push_str(&text);
1145 } else {
1146 // Open a buffer if none is active. In a tight list item the
1147 // text after a nested block arrives bare, with the previous
1148 // buffer already flushed by that block's start โ matching
1149 // on `as_mut()` here silently dropped it.
1150 let block = current.get_or_insert_with(|| Text::new(""));
1151 // `markdown.code` is pushed on TOP of the link, so a link
1152 // whose whole label is inline code โ ``[`rich`](url)`` โ
1153 // keeps its destination. Applying the code style alone
1154 // discarded it.
1155 let style = stack_style(
1156 heading_style.as_ref(),
1157 inline_style(strong, emphasis, strike),
1158 link.as_deref().filter(|_| hyperlinks),
1159 Style::parse(CODE_STYLE).ok(),
1160 );
1161 block.append(&text, style.map(Into::into));
1162 }
1163 }
1164 // `softbreak`/`hardbreak` go through `context.on_text`, so they land
1165 // in the open link label if there is one, and otherwise carry
1166 // whatever styles are open just like any other run.
1167 Event::SoftBreak => append_break(
1168 current.as_mut(),
1169 link_label.as_mut(),
1170 " ",
1171 stack_style(
1172 heading_style.as_ref(),
1173 inline_style(strong, emphasis, strike),
1174 link.as_deref().filter(|_| hyperlinks),
1175 None,
1176 ),
1177 ),
1178 Event::HardBreak => append_break(
1179 current.as_mut(),
1180 link_label.as_mut(),
1181 "\n",
1182 stack_style(
1183 heading_style.as_ref(),
1184 inline_style(strong, emphasis, strike),
1185 link.as_deref().filter(|_| hyperlinks),
1186 None,
1187 ),
1188 ),
1189 _ => {}
1190 }
1191 }
1192 blocks
1193}
1194
1195impl Renderable for Markdown {
1196 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
1197 let mut lines = render_blocks(&self.blocks, console, options, options.max_width, true);
1198
1199 // Upstream's thematic-break element emits a trailing line break, which is
1200 // only observable when the rule is the document's last block: it adds one
1201 // extra blank line there (a mid-document rule merges with the normal block
1202 // separator). Match that.
1203 if matches!(self.blocks.last(), Some(Block::Rule)) {
1204 lines.push(Vec::new());
1205 }
1206
1207 let mut segments = Vec::new();
1208 let last = lines.len().saturating_sub(1);
1209 for (index, line) in lines.into_iter().enumerate() {
1210 segments.extend(line);
1211 if index != last {
1212 segments.push(Segment::line());
1213 }
1214 }
1215 segments
1216 }
1217}
1218
1219/// Pad every row out to `width`, as upstream's `console.render_lines` does โ
1220/// `pad=True` is its default, and both the list-item and block-quote handlers
1221/// rely on it.
1222///
1223/// Without this a child rendered in a narrower box hands back short rows and
1224/// every enclosing level inherits the shortfall, so nesting lost two cells per
1225/// level: quotes measured 68, 66, 64, 62 at depths 1โ4 where upstream holds a
1226/// flat 68.
1227fn pad_lines(lines: &mut [Vec<Segment>], width: usize) {
1228 for line in lines.iter_mut() {
1229 let len: usize = line.iter().map(Segment::cell_length).sum();
1230 if len < width {
1231 line.push(Segment::new(" ".repeat(width - len), None));
1232 }
1233 }
1234}
1235
1236/// Render a run of blocks into rows of segments at `width`.
1237///
1238/// Recursive, because a list item and a quote are containers: whatever they
1239/// hold is rendered by this same function at a reduced width and then prefixed.
1240fn render_blocks(
1241 blocks: &[Block],
1242 console: &Console,
1243 options: &ConsoleOptions,
1244 width: usize,
1245 top_level: bool,
1246) -> Vec<Vec<Segment>> {
1247 let base = console.base_style();
1248 let mut lines: Vec<Vec<Segment>> = Vec::new();
1249 // Set by an image whose marker must stay on the same row as the block that
1250 // follows it (see [`Block::Image`]).
1251 let mut join_previous = false;
1252
1253 for (index, block) in blocks.iter().enumerate() {
1254 let mut merge = std::mem::take(&mut join_previous);
1255 // Consecutive images share their open row even when hoisted from a
1256 // container. A closed cell/item sets leading_break and ends that row.
1257 if matches!(
1258 block,
1259 Block::Image {
1260 leading_break: false,
1261 ..
1262 }
1263 ) && index > 0
1264 && matches!(blocks[index - 1], Block::Image { .. })
1265 {
1266 merge = true;
1267 }
1268 // `new_line` before an image is a single line break, not the blank-row
1269 // separator used between ordinary blocks. In particular, images
1270 // hoisted from consecutive table rows must occupy consecutive output
1271 // rows. It also cancels the preceding image's open-row join.
1272 if matches!(
1273 block,
1274 Block::Image {
1275 leading_break: true,
1276 ..
1277 }
1278 ) {
1279 merge = false;
1280 }
1281 // A blank line precedes every non-first block, and every
1282 // list/quote/table (which upstream renders with a leading gap).
1283 // Blank lines between blocks are a *document* convention. Upstream puts
1284 // none inside a list item or a quote โ neither before a nested list nor
1285 // between two paragraphs of one item โ so applying the rule there added
1286 // a stray row per block, and one per level of nesting.
1287 // A rule brings its own trailing blank, so the usual gap after it would
1288 // double up (upstream sets `HorizontalRule.new_line = False` for exactly
1289 // this reason).
1290 let after_rule = index > 0 && matches!(blocks[index - 1], Block::Rule);
1291 // A list, quote or table carries its own leading gap, which survives even
1292 // after a rule; only the generic inter-block separator is suppressed.
1293 let own_gap = matches!(block, Block::List { .. } | Block::Table { .. });
1294 // An image emits no line break after itself, so the block that follows
1295 // one gets no separator at all โ not even the leading gap a list, quote
1296 // or table would otherwise bring.
1297 let after_image = index > 0 && matches!(blocks[index - 1], Block::Image { .. });
1298 let separator = match block {
1299 Block::Quote { leading_break, .. } => top_level && *leading_break && !after_image,
1300 // After an ordinary element this is the usual blank-row gap;
1301 // after an image (whose text has `end=""`) it is only a line break,
1302 // represented above by declining to merge the two image rows.
1303 Block::Image { leading_break, .. } => top_level && *leading_break && !after_image,
1304 _ if after_image => false,
1305 _ => top_level && (own_gap || (index > 0 && !after_rule)),
1306 };
1307 if separator {
1308 lines.push(Vec::new());
1309 }
1310 let start = lines.len();
1311 match block {
1312 Block::Text(text) => {
1313 lines.extend(text.render_lines(console.theme(), base, Some(width)))
1314 }
1315 Block::Image {
1316 text, joins_next, ..
1317 } => {
1318 // No justify of its own, so the marker is wrapped but never
1319 // padded โ upstream assembles a bare `Text` for it.
1320 lines.extend(text.render_lines(console.theme(), base, Some(width)));
1321 join_previous = *joins_next;
1322 }
1323 Block::List { items } => {
1324 for item in items {
1325 let (prefix, prefix_style) = match item.number {
1326 Some(number) => (
1327 format!(" {number} "),
1328 Style::parse("cyan").expect("valid style"),
1329 ),
1330 None => (
1331 BULLET.to_string(),
1332 Style::parse("bold").expect("valid style"),
1333 ),
1334 };
1335 let prefix_width = cell_len(&prefix);
1336 // The item's own blocks, rendered in the space left beside
1337 // its marker. A nested list is just one of those blocks, so
1338 // indentation compounds naturally.
1339 let item_lines = render_blocks(
1340 &item.blocks,
1341 console,
1342 options,
1343 width.saturating_sub(prefix_width),
1344 false,
1345 );
1346 // A leading blank row would push the marker off its content.
1347 let mut item_lines: Vec<Vec<Segment>> = item_lines
1348 .into_iter()
1349 .skip_while(|line| line.is_empty())
1350 .collect();
1351 pad_lines(&mut item_lines, width.saturating_sub(prefix_width));
1352 for (line_index, line) in item_lines.into_iter().enumerate() {
1353 let mut row = Vec::new();
1354 if line_index == 0 {
1355 row.push(Segment::new(prefix.clone(), Some(prefix_style.clone())));
1356 } else {
1357 row.push(Segment::new(" ".repeat(prefix_width), None));
1358 }
1359 row.extend(line);
1360 lines.push(row);
1361 }
1362 }
1363 }
1364 Block::Html => {}
1365 Block::Quote { blocks: quoted, .. } => {
1366 let prefix_style = Style::parse("magenta").expect("valid style");
1367 // Upstream renders quote content at `max_width - 4`.
1368 let content_width = width.saturating_sub(4);
1369 let quoted_lines = render_blocks(quoted, console, options, content_width, false);
1370 let mut quoted_lines: Vec<Vec<Segment>> = quoted_lines
1371 .into_iter()
1372 .skip_while(|line| line.is_empty())
1373 .collect();
1374 pad_lines(&mut quoted_lines, content_width);
1375 for line in quoted_lines {
1376 let mut row = vec![Segment::new(
1377 QUOTE_PREFIX.to_string(),
1378 Some(prefix_style.clone()),
1379 )];
1380 // Upstream passes `style=self.style` to `render_lines`, so
1381 // the quote colour reaches *every* child โ including a list
1382 // or table, which set their own styles and so previously
1383 // rendered inside a quote with no magenta at all.
1384 row.extend(Segment::apply_style(&line, &prefix_style));
1385 lines.push(row);
1386 }
1387 }
1388 Block::Code { language, code } => {
1389 // Render the code block via the Syntax renderable (functional,
1390 // not byte-parity โ see DIVERGENCES). Split its segment stream
1391 // back into per-line rows for the shared join below.
1392 // Upstream: `Syntax(code, lexer, theme=..., word_wrap=True, padding=1)`.
1393 // Upstream: `Syntax(code, lexer, theme=..., word_wrap=True, padding=1)`.
1394 // Without word_wrap a long line was cropped dead at the console
1395 // width and its tail discarded entirely โ a README's install
1396 // command lost half its flags, with no marker that anything went.
1397 let syntax = Syntax::new(code.as_str(), language.as_str())
1398 .word_wrap(true)
1399 .padding(1);
1400 let inner = options.update_width(width);
1401 let segments = syntax.rich_render(console, &inner);
1402 lines.extend(Segment::split_lines(&segments));
1403 }
1404 Block::Rule => {
1405 let style = Style::parse("dim").expect("valid style");
1406 lines.push(vec![Segment::new("-".repeat(width), Some(style))]);
1407 // Upstream's rule carries a trailing blank row of its own, in
1408 // place of the usual inter-block gap (`HorizontalRule.new_line
1409 // = False`). Inside a quote that row picks up the quote prefix,
1410 // which is why upstream shows a bare `โ` line under a quoted
1411 // rule and we showed none.
1412 //
1413 // At the very end of a document the trailing break already
1414 // arrives from the join below โ the `markdown_hr_end` golden
1415 // pins it โ so adding one here would double it.
1416 if index + 1 < blocks.len() || !top_level {
1417 lines.push(Vec::new());
1418 }
1419 }
1420 Block::Table {
1421 alignments,
1422 headers,
1423 rows,
1424 } => {
1425 // Build the Table exactly as upstream's TableElement does:
1426 // box=SIMPLE, pad_edge=False, collapse_padding=True, and the
1427 // markdown.table.border/header styles. Per-column justify comes
1428 // from the alignment row.
1429 let mut table = Table::new()
1430 .box_set(SIMPLE)
1431 .pad_edge(false)
1432 .collapse_padding(true)
1433 .style(Style::parse(TABLE_BORDER_STYLE).expect("valid style"));
1434 let header_style = Style::parse(TABLE_HEADER_STYLE).expect("valid style");
1435 for (col, header) in headers.iter().enumerate() {
1436 let justify = alignments.get(col).copied().unwrap_or(Justify::Left);
1437 table.add_column_justify(header.as_str(), justify);
1438 table.column_header_style(header_style.clone());
1439 }
1440 for row in rows {
1441 let refs: Vec<&str> = row.iter().map(String::as_str).collect();
1442 table.add_row(&refs);
1443 }
1444 let inner = options.update_width(width);
1445 lines.extend(Segment::split_lines(&table.rich_render(console, &inner)));
1446 }
1447 }
1448 // Fold this block's first row onto the row the image left open. `merge`
1449 // is only ever set by a preceding image, which always pushed at least
1450 // one row, so `start` is never zero here.
1451 if merge && lines.len() > start {
1452 let first = lines.remove(start);
1453 lines[start - 1].extend(first);
1454 }
1455 }
1456 lines
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461 use super::*;
1462 use crate::color::ColorSystem;
1463
1464 fn render(source: &str) -> String {
1465 let console = Console::builder()
1466 .force_terminal(true)
1467 .color_system(Some(ColorSystem::Truecolor))
1468 .width(20)
1469 .build();
1470 console.render_to_string(&Markdown::new(source))
1471 }
1472
1473 #[test]
1474 fn a_code_only_list_item_keeps_the_bullet_on_its_padding_row() {
1475 let console = Console::builder().width(30).no_color(true).build();
1476 assert_eq!(console.render_export(&Markdown::new("- ```\n code\n ```")),
1477 "\n โข \n code \n \n");
1478 }
1479
1480 #[test]
1481 fn table_cell_images_share_a_row_until_the_cell_closes() {
1482 let console = Console::builder().width(30).no_color(true).build();
1483 let output = console.render_to_string(&Markdown::new(
1484 "| h |\n|---|\n|   |\n|  |",
1485 ));
1486 assert!(output.starts_with("\n๐ a ๐ b \n๐ c \n"), "{output:?}");
1487 }
1488
1489 #[test]
1490 fn quoted_rule_spacing_uses_the_last_closed_child() {
1491 let console = Console::builder().width(30).no_color(true).build();
1492 assert_eq!(
1493 console.render_to_string(&Markdown::new("> ---")),
1494 "โ --------------------------\nโ "
1495 );
1496 let output = console.render_to_string(&Markdown::new("> ---\n>\n> text"));
1497 assert!(
1498 output.starts_with("\nโ --------------------------\n"),
1499 "{output:?}"
1500 );
1501 }
1502
1503 #[test]
1504 fn ignored_html_blocks_keep_upstream_paragraph_spacing() {
1505 let console = Console::builder().width(30).no_color(true).build();
1506 for (source, expected) in [
1507 (
1508 "<div>hidden</div>\n\nParagraph",
1509 "\nParagraph ",
1510 ),
1511 ("<div>hidden</div>", ""),
1512 (
1513 "A\n\n<div>x</div>\n\nB",
1514 "A \n\n\nB ",
1515 ),
1516 ] {
1517 assert_eq!(console.render_to_string(&Markdown::new(source)), expected);
1518 }
1519 }
1520
1521 #[test]
1522 fn paragraph_inline_styles() {
1523 assert_eq!(
1524 render("a `x` b"),
1525 "a \x1b[1;36;40mx\x1b[0m b "
1526 );
1527 }
1528
1529 #[test]
1530 fn link_renders_osc8_hyperlink() {
1531 // Matches real rich 15.0.0 exactly except upstream's random `id=` field,
1532 // which we omit for determinism (DIVERGENCES). markdown.link_url styling
1533 // is "underline blue" (4;34).
1534 let out = render("See [the site](https://example.com) now.");
1535 assert!(
1536 out.contains(
1537 "\x1b]8;;https://example.com\x1b\\\x1b[4;34mthe site\x1b[0m\x1b]8;;\x1b\\"
1538 ),
1539 "got {out:?}"
1540 );
1541 assert!(!out.contains("id="), "we omit the random link id");
1542 }
1543
1544 #[test]
1545 fn fenced_code_block_is_highlighted() {
1546 // Functional (not byte-parity): the fenced code renders via Syntax, so
1547 // its text survives and it's colored.
1548 let console = Console::builder()
1549 .force_terminal(true)
1550 .color_system(Some(ColorSystem::Truecolor))
1551 .width(24)
1552 .no_color(false)
1553 .build();
1554 let out = console.render_to_string(&Markdown::new("```rust\nfn main() {}\n```"));
1555 assert!(out.contains("fn"), "got {out:?}");
1556 assert!(out.contains("main"));
1557 assert!(out.contains('\x1b'), "code block should be colored");
1558 }
1559
1560 #[test]
1561 fn headings() {
1562 assert_eq!(render("# Head"), " \x1b[1;4mHead\x1b[0m ");
1563 assert_eq!(render("## Sub"), "\x1b[4;35mSub\x1b[0m ");
1564 }
1565
1566 #[test]
1567 fn two_paragraphs_separated_by_blank_line() {
1568 assert_eq!(
1569 render("First para.\n\nSecond para."),
1570 "First para. \n\nSecond para. "
1571 );
1572 }
1573
1574 #[test]
1575 fn bullet_list() {
1576 assert_eq!(
1577 render("- one\n- two"),
1578 "\n\x1b[1m \u{2022} \x1b[0mone \n\x1b[1m \u{2022} \x1b[0mtwo "
1579 );
1580 }
1581
1582 #[test]
1583 fn ordered_list() {
1584 assert_eq!(
1585 render("1. first\n2. second"),
1586 "\n\x1b[36m 1 \x1b[0mfirst \n\x1b[36m 2 \x1b[0msecond "
1587 );
1588 }
1589
1590 #[test]
1591 fn block_quote() {
1592 assert_eq!(
1593 render("> quoted text"),
1594 "\n\x1b[35m\u{258c} \x1b[0m\x1b[35mquoted text\x1b[0m\x1b[35m \x1b[0m"
1595 );
1596 }
1597
1598 #[test]
1599 fn gfm_table() {
1600 // Byte-parity is guaranteed by the `markdown_table` golden; this guards
1601 // the parser wiring (tables enabled, cells + alignment collected).
1602 let console = Console::builder()
1603 .force_terminal(true)
1604 .color_system(Some(ColorSystem::Truecolor))
1605 .width(40)
1606 .no_color(false)
1607 .build();
1608 let md = "| Name | Age |\n| :--- | ---: |\n| Alice | 30 |\n| Bob | 7 |\n";
1609 let out = console.render_to_string(&Markdown::new(md));
1610 assert!(out.contains("Name"), "header present: {out:?}");
1611 assert!(out.contains("Alice"), "body cell present");
1612 assert!(out.contains('\u{2500}'), "SIMPLE box head rule present");
1613 // Right-justified Age column: "30" padded on the left, "7" further.
1614 assert!(out.contains(" 30"), "right-justified 30");
1615 assert!(out.contains(" 7"), "right-justified 7");
1616 }
1617
1618 #[test]
1619 fn thematic_break() {
1620 assert_eq!(
1621 render("a\n\n---\n\nb"),
1622 "a \n\n\x1b[2m--------------------\x1b[0m\n\nb "
1623 );
1624 }
1625
1626 #[test]
1627 fn thematic_break_at_end_adds_trailing_blank() {
1628 // A document ending with a rule emits one extra trailing blank line
1629 // (upstream's hr element yields a trailing break). Byte-parity is
1630 // guaranteed by the `markdown_hr_end` golden; here we assert the shape.
1631 assert_eq!(
1632 render("a\n\n---"),
1633 "a \n\n\x1b[2m--------------------\x1b[0m\n"
1634 );
1635 }
1636}
1637
1638#[cfg(test)]
1639mod container_tests {
1640 use super::*;
1641
1642 fn plain(source: &str, width: usize) -> String {
1643 let console = Console::builder().width(width).no_color(true).build();
1644 console.render_to_string(&Markdown::new(source))
1645 }
1646
1647 /// Every case here lost content before parsing used a container stack: the
1648 /// open list, quote and paragraph lived in flat `Option`s, so a nested block
1649 /// overwrote its parent's pending text and the parent emitted nothing.
1650 fn assert_all_present(source: &str, expected: &[&str]) {
1651 let out = plain(source, 44);
1652 for item in expected {
1653 assert!(out.contains(item), "{item:?} missing from:\n{out}");
1654 }
1655 }
1656
1657 #[test]
1658 fn a_nested_list_keeps_every_item() {
1659 assert_all_present("- one\n- two\n - nested\n", &["one", "two", "nested"]);
1660 }
1661
1662 #[test]
1663 fn nesting_three_deep_keeps_every_item() {
1664 assert_all_present("- top\n - mid\n - deep\n", &["top", "mid", "deep"]);
1665 }
1666
1667 #[test]
1668 fn an_item_following_a_sublist_keeps_its_place() {
1669 let out = plain("- one\n - nested\n- two\n", 44);
1670 let (a, b, c) = (
1671 out.find("one").expect("one"),
1672 out.find("nested").expect("nested"),
1673 out.find("two").expect("two"),
1674 );
1675 assert!(a < b && b < c, "order was wrong:\n{out}");
1676 }
1677
1678 #[test]
1679 fn each_level_of_an_ordered_list_numbers_independently() {
1680 let out = plain("1. first\n2. second\n 1. sub\n", 44);
1681 for expected in ["1 first", "2 second", "1 sub"] {
1682 assert!(out.contains(expected), "expected {expected:?} in:\n{out}");
1683 }
1684 }
1685
1686 #[test]
1687 fn nested_items_are_indented_under_their_parent() {
1688 let out = plain("- top\n - child\n", 44);
1689 let indent = |needle: &str| {
1690 let line = out.lines().find(|l| l.contains(needle)).expect(needle);
1691 line.len() - line.trim_start().len()
1692 };
1693 assert!(indent("child") > indent("top"), "not indented:\n{out}");
1694 }
1695
1696 /// A heading inside a list item used to delete the item's own text and take
1697 /// its place in the list.
1698 #[test]
1699 fn a_heading_inside_an_item_keeps_the_item_text() {
1700 assert_all_present(
1701 "- ITEMTEXT\n\n ## HEADTEXT\n\n- NEXTTEXT\n",
1702 &["ITEMTEXT", "HEADTEXT", "NEXTTEXT"],
1703 );
1704 }
1705
1706 /// A code block inside an item used to be hoisted above the whole list, so
1707 /// the code appeared before the text introducing it.
1708 #[test]
1709 fn a_code_block_inside_an_item_stays_in_the_item() {
1710 let out = plain("- FIRSTITEM\n\n ```\n CODETEXT\n ```\n", 44);
1711 let (item, code) = (
1712 out.find("FIRSTITEM").expect("item"),
1713 out.find("CODETEXT").expect("code"),
1714 );
1715 assert!(item < code, "the code was hoisted above its item:\n{out}");
1716 }
1717
1718 /// A second paragraph used to be fused onto the first with no separator.
1719 #[test]
1720 fn two_paragraphs_in_one_item_stay_separate() {
1721 let out = plain("- AAA\n\n BBB\n", 44);
1722 assert!(!out.contains("AAABBB"), "paragraphs were fused:\n{out}");
1723 assert!(out.contains("AAA") && out.contains("BBB"), "{out}");
1724 }
1725
1726 /// A nested quote used to delete the outer quote's text entirely.
1727 #[test]
1728 fn a_nested_quote_keeps_the_outer_text() {
1729 assert_all_present(
1730 "> OUTERTEXT\n>\n> > INNERTEXT\n",
1731 &["OUTERTEXT", "INNERTEXT"],
1732 );
1733 }
1734
1735 /// A list inside a quote used to be reordered ahead of the quote's own text
1736 /// and to lose the quote bar.
1737 #[test]
1738 fn a_list_inside_a_quote_stays_quoted_and_in_order() {
1739 let out = plain("> intro\n>\n> - item one\n> - item two\n", 44);
1740 for line in out
1741 .lines()
1742 .filter(|l| l.contains("item one") || l.contains("intro"))
1743 {
1744 assert!(
1745 line.trim_start().starts_with(QUOTE_PREFIX.trim_end()),
1746 "lost the quote bar: {line:?}\n{out}"
1747 );
1748 }
1749 let (intro, one) = (
1750 out.find("intro").expect("intro"),
1751 out.find("item one").expect("item one"),
1752 );
1753 assert!(intro < one, "quote content was reordered:\n{out}");
1754 }
1755
1756 #[test]
1757 fn a_quote_inside_an_item_stays_inside_it() {
1758 let out = plain("- alpha\n\n > quoted\n", 44);
1759 assert!(!out.contains("alphaquoted"), "fused:\n{out}");
1760 let quoted = out.lines().find(|l| l.contains("quoted")).expect("quoted");
1761 assert!(
1762 quoted.contains(QUOTE_PREFIX.trim_end()),
1763 "lost the quote bar:\n{out}"
1764 );
1765 }
1766
1767 /// In a *tight* list the item's text arrives as bare `Text` events, so any
1768 /// block-level start used to overwrite it: the item's own content vanished
1769 /// and the block took its place.
1770 #[test]
1771 fn a_tight_item_keeps_its_text_before_a_heading() {
1772 assert_all_present(
1773 "- P1_text\n ## H1_head\n- P2_text\n",
1774 &["P1_text", "H1_head", "P2_text"],
1775 );
1776 }
1777
1778 #[test]
1779 fn a_tight_item_keeps_its_text_before_a_quote() {
1780 assert_all_present("- Q1_text\n > Q1_quote\n", &["Q1_text", "Q1_quote"]);
1781 }
1782
1783 #[test]
1784 fn a_tight_ordered_item_keeps_its_text_before_a_quote() {
1785 assert_all_present("1. C_num_text\n > C_quote\n", &["C_num_text", "C_quote"]);
1786 }
1787
1788 #[test]
1789 fn a_nested_tight_item_keeps_its_text_before_a_heading() {
1790 assert_all_present(
1791 "- A\n - B_inner\n ## B_head\n",
1792 &["A", "B_inner", "B_head"],
1793 );
1794 }
1795
1796 /// A fenced block tight after the item's text used to render *before* it โ
1797 /// #69 stopped hoisting it above the whole list, but it still overtook the
1798 /// paragraph that introduced it.
1799 #[test]
1800 fn a_tight_code_block_renders_after_the_text_that_introduces_it() {
1801 let out = plain("- F1_text\n ```\n F1_code\n ```\n- F2_text\n", 55);
1802 let (text, code) = (
1803 out.find("F1_text").expect("F1_text"),
1804 out.find("F1_code").expect("F1_code"),
1805 );
1806 assert!(text < code, "the code block overtook its paragraph:\n{out}");
1807 }
1808
1809 /// Rendering recurses once per nesting level, so an unbounded document
1810 /// overflowed the stack and killed the process: 400 nested quotes aborted
1811 /// with STATUS_STACK_OVERFLOW after four seconds, no output at all.
1812 #[test]
1813 fn deeply_nested_input_does_not_overflow_the_stack() {
1814 for depth in [50usize, 400, 2000] {
1815 let quotes = ">".repeat(depth) + " x\n";
1816 let _ = plain("es, 80);
1817
1818 let list: String = (0..depth)
1819 .map(|i| format!("{}- L{i}\n", " ".repeat(i)))
1820 .collect();
1821 let _ = plain(&list, 80);
1822 }
1823 // Reaching here without aborting is the assertion.
1824 }
1825
1826 /// Text after a nested block inside a tight item arrives as a bare `Text`
1827 /// event with no buffer open โ the previous one having been flushed by that
1828 /// block's start โ and was silently dropped at exit 0.
1829 #[test]
1830 fn a_tight_item_keeps_text_that_follows_a_nested_block() {
1831 assert_all_present(
1832 "- ITEM\n ```\n FIRST code\n ```\n SECOND para\n",
1833 &["ITEM", "FIRST code", "SECOND para"],
1834 );
1835 assert_all_present(
1836 "- ITEM\n ## HEAD\n TAIL para\n",
1837 &["ITEM", "HEAD", "TAIL para"],
1838 );
1839 assert_all_present("- ITEM\n ---\n TAIL para\n", &["ITEM", "TAIL para"]);
1840 }
1841
1842 /// A heading inside a quote was flattened to body text: it lost its own
1843 /// style and its centring, keeping only the quote's magenta.
1844 #[test]
1845 fn a_heading_inside_a_quote_keeps_its_alignment() {
1846 let out = plain("> # Heading in quote\n", 50);
1847 let line = out
1848 .lines()
1849 .find(|l| l.contains("Heading in quote"))
1850 .expect("heading line");
1851 // Centred: the text does not start immediately after the quote bar.
1852 let after_bar = line.split(QUOTE_PREFIX.trim_end()).nth(1).expect("bar");
1853 assert!(
1854 after_bar.starts_with(" "),
1855 "heading was left-aligned inside the quote: {line:?}"
1856 );
1857 }
1858
1859 /// Upstream enables strikethrough explicitly; without the parser option the
1860 /// tilde markers leaked into the output and widened table columns.
1861 #[test]
1862 fn strikethrough_is_rendered_rather_than_leaked() {
1863 let out = plain("~~Deprecated~~ text\n", 50);
1864 assert!(!out.contains("~~"), "tildes leaked into output: {out:?}");
1865 assert!(out.contains("Deprecated"), "content lost: {out:?}");
1866 }
1867
1868 /// Blank lines between blocks are a document convention. Applying them
1869 /// inside a container added a stray row per block and per nesting level โ
1870 /// upstream emits none there.
1871 #[test]
1872 fn nested_blocks_gain_no_phantom_blank_row() {
1873 let out = plain("- a\n - b\n - c\n- d\n", 50);
1874 let rows: Vec<&str> = out
1875 .lines()
1876 .map(str::trim_end)
1877 .filter(|l| !l.is_empty())
1878 .collect();
1879 assert_eq!(
1880 rows.len(),
1881 4,
1882 "expected exactly four content rows, got {rows:?}"
1883 );
1884 }
1885
1886 /// Upstream's `render_lines` pads a child back to the width it was handed
1887 /// (`pad=True`). We never padded, so every nesting level inherited the
1888 /// shortfall: quote rows measured 68, 66, 64, 62 at depths 1โ4 where
1889 /// upstream holds a flat 68.
1890 #[test]
1891 fn nesting_does_not_narrow_each_level() {
1892 let source = "> d1\n\n>> d2\n\n>>> d3\n\n>>>> d4\n";
1893 let out = plain(source, 70);
1894 let widths: Vec<usize> = out
1895 .lines()
1896 .filter(|l| {
1897 l.contains("d1") || l.contains("d2") || l.contains("d3") || l.contains("d4")
1898 })
1899 .map(|l| l.chars().count())
1900 .collect();
1901 assert_eq!(widths.len(), 4, "expected one row per depth: {widths:?}");
1902 assert!(
1903 widths.iter().all(|w| *w == widths[0]),
1904 "each nesting level lost width: {widths:?}"
1905 );
1906 }
1907
1908 /// pulldown-cmark accepts a single tilde as a strikethrough delimiter;
1909 /// upstream's markdown-it requires two, so `~struck~` had its tildes deleted
1910 /// and its content restyled where upstream leaves the text alone.
1911 #[test]
1912 fn a_single_tilde_is_literal_text() {
1913 let out = plain("a ~struck~ b and ~~gone~~ here", 60);
1914 assert!(
1915 out.contains("~struck~"),
1916 "single tildes were eaten: {out:?}"
1917 );
1918 assert!(!out.contains("~~gone~~"), "double tildes leaked: {out:?}");
1919 assert!(out.contains("gone"), "struck content lost: {out:?}");
1920 }
1921
1922 /// Upstream renders a fenced block as `Syntax(..., padding=1)`: a blank
1923 /// inset row above and below and a one-column gutter. Without it the code
1924 /// sat flush against the surrounding text.
1925 #[test]
1926 fn a_code_block_is_inset_by_one_cell() {
1927 let out = plain("intro para\n\n```\nCODEWORD\n```\n", 40);
1928 let rows: Vec<&str> = out.lines().collect();
1929 let index = rows
1930 .iter()
1931 .position(|r| r.contains("CODEWORD"))
1932 .expect("code row present");
1933 assert!(
1934 rows[index].starts_with(' '),
1935 "no left gutter on the code row: {:?}",
1936 rows[index]
1937 );
1938 assert!(
1939 rows[index - 1].trim().is_empty(),
1940 "no blank inset row above the code: {:?}",
1941 rows[index - 1]
1942 );
1943 assert!(
1944 rows.get(index + 1).is_some_and(|r| r.trim().is_empty()),
1945 "no blank inset row below the code"
1946 );
1947 }
1948
1949 /// A rule carries its own trailing blank in place of the usual inter-block
1950 /// gap, so a block after it is separated by exactly one blank row โ not two,
1951 /// and not none.
1952 #[test]
1953 fn a_rule_is_followed_by_exactly_one_blank_row() {
1954 let out = plain("before\n\n---\n\nafter\n", 40);
1955 let rows: Vec<&str> = out.lines().collect();
1956 let rule = rows
1957 .iter()
1958 .position(|r| r.trim_end().ends_with('-') && r.trim().len() > 3)
1959 .expect("rule row present");
1960 let after = rows
1961 .iter()
1962 .position(|r| r.contains("after"))
1963 .expect("following row present");
1964 assert_eq!(
1965 after - rule,
1966 2,
1967 "expected one blank row between rule and next block: {rows:?}"
1968 );
1969 }
1970
1971 /// Upstream's `ImageItem` renders `๐ <title> ` and yields it *before* the
1972 /// element it was lifted out of, with no line break of its own. We rendered
1973 /// the alt text inline with no marker at all, and `` โ a badge row,
1974 /// which is what most READMEs open with โ came out as a blank line.
1975 ///
1976 /// Every expectation captured verbatim from real rich 15.0.0 at width 40:
1977 ///
1978 /// ```text
1979 ///  -> '๐ alt text'
1980 ///  -> '๐ pic.png' <- filename
1981 ///  -> '๐ img'
1982 /// Before  after. -> '๐ alt text Before after.'
1983 ///  -> '๐ alt *em*' <- raw alt
1984 /// ```
1985 #[test]
1986 fn an_image_is_marked_and_hoisted() {
1987 let row = |source: &str| {
1988 plain(source, 40)
1989 .lines()
1990 .next()
1991 .expect("a row")
1992 .trim_end()
1993 .to_string()
1994 };
1995 assert_eq!(
1996 row(""),
1997 "๐ alt text"
1998 );
1999 assert_eq!(row(""), "๐ pic.png");
2000 assert_eq!(row(""), "๐ img");
2001 // Hoisted to the front of the paragraph it sat inside, on the same row.
2002 assert_eq!(
2003 row("Before  after."),
2004 "๐ alt text Before after."
2005 );
2006 // The alt is the raw markdown source, markers included: upstream reads
2007 // markdown-it's `token.content`, which is never inline-parsed.
2008 assert_eq!(row(""), "๐ alt *em*");
2009 }
2010
2011 /// An image inside a container is lifted clear of it: upstream renders the
2012 /// element the moment its token is reached, while the list or quote holding
2013 /// it is still open and will not render until it closes.
2014 ///
2015 /// Real rich 15.0.0 at width 40 (trailing padding trimmed):
2016 ///
2017 /// ```text
2018 /// '- item with  inside'
2019 /// -> ['๐ pic', ' โข item with inside']
2020 /// '> quoted  end'
2021 /// -> ['๐ pic', 'โ quoted end']
2022 /// ```
2023 ///
2024 /// Note the absence of the blank row a list or quote normally brings with
2025 /// it: the image asks for no line break after itself.
2026 #[test]
2027 fn an_image_is_lifted_out_of_a_list_or_quote() {
2028 let rows = |source: &str| -> Vec<String> {
2029 plain(source, 40)
2030 .lines()
2031 .map(|line| line.trim_end().to_string())
2032 .collect()
2033 };
2034 assert_eq!(
2035 rows("- item with  inside"),
2036 vec!["๐ pic", " โข item with inside"]
2037 );
2038 assert_eq!(
2039 rows("> quoted  end"),
2040 vec!["๐ pic", "โ quoted end"]
2041 );
2042 }
2043
2044 /// Markdown code blocks are `Syntax(..., word_wrap=True)` upstream. Without
2045 /// it a long line was cropped dead at the console width and its tail
2046 /// discarded โ a README's install command lost half its flags, silently.
2047 #[test]
2048 fn a_long_code_line_keeps_its_tail() {
2049 let source = "```bash\npip install some-package another-package \
2050yet-another-package --upgrade --no-cache-dir\n```\n";
2051 let out = plain(source, 80);
2052 assert!(
2053 out.contains("no-cache-dir"),
2054 "the tail of the code line was discarded: {out:?}"
2055 );
2056 }
2057
2058 /// A tab in a fenced block reaches the terminal as U+0009, which jumps to
2059 /// the next 8-cell stop while we had counted it as one cell โ so the block
2060 /// overran the width it was given. Upstream expands tabs before
2061 /// highlighting; the fenced block inherits that through `Syntax`.
2062 #[test]
2063 fn a_fenced_block_expands_its_tabs() {
2064 // Rows captured from rich 15.0.0 at width 30.
2065 let out = plain("```python\ndef f():\n\tif x:\n\t\treturn 1\n```", 30);
2066 assert_eq!(
2067 out.split('\n').collect::<Vec<_>>(),
2068 [
2069 " ",
2070 " def f(): ",
2071 " if x: ",
2072 " return 1 ",
2073 " ",
2074 ]
2075 );
2076 }
2077}
2078
2079/// `Markdown(hyperlinks=โฆ)`. Every expectation here was captured verbatim from
2080/// real rich 15.0.0 (with its random OSC 8 `id=` field removed, which we
2081/// deliberately do not reproduce โ see docs/DIVERGENCES.md).
2082#[cfg(test)]
2083mod hyperlink_tests {
2084 use super::*;
2085 use crate::color::ColorSystem;
2086
2087 fn plain(source: &str, width: usize, hyperlinks: bool) -> String {
2088 Console::builder()
2089 .width(width)
2090 .no_color(true)
2091 .build()
2092 .render_to_string(&Markdown::new(source).hyperlinks(hyperlinks))
2093 }
2094
2095 fn ansi(source: &str, width: usize, hyperlinks: bool) -> String {
2096 Console::builder()
2097 .force_terminal(true)
2098 .color_system(Some(ColorSystem::Truecolor))
2099 .width(width)
2100 .no_color(false)
2101 .build()
2102 .render_to_string(&Markdown::new(source).hyperlinks(hyperlinks))
2103 }
2104
2105 /// THE defect: an OSC 8 escape is only written when the console has a colour
2106 /// system, so with hyperlinks on a piped or `NO_COLOR` render dropped every
2107 /// destination and left nothing to recover it from. `rich -m` passes
2108 /// `hyperlinks=False` precisely so the URL is written out as text.
2109 #[test]
2110 fn hyperlinks_off_writes_the_url_out_after_the_label() {
2111 assert_eq!(
2112 plain("A [link](https://example.com) here.", 40, false),
2113 "A link (https://example.com) here. "
2114 );
2115 }
2116
2117 #[test]
2118 fn hyperlinks_on_keeps_the_label_alone() {
2119 assert_eq!(
2120 plain("A [link](https://example.com) here.", 40, true),
2121 "A link here. "
2122 );
2123 }
2124
2125 /// The knock-on: the URL is part of the cell's *text*, so it drives the
2126 /// column width. Laying the table out against the bare label made it far too
2127 /// narrow and the URL was then wrapped or cropped away.
2128 #[test]
2129 fn hyperlinks_off_widens_a_table_column_to_fit_the_url() {
2130 let source = "| T | W |\n| :-- | --: |\n| r | [repo](https://ex.org/a) |\n";
2131 assert_eq!(
2132 plain(source, 60, false).split('\n').collect::<Vec<_>>(),
2133 [
2134 "",
2135 " ",
2136 " T W ",
2137 " โโโโโโโโโโโโโโโโโโโโโโโโโโ ",
2138 " r repo (https://ex.org/a) ",
2139 " ",
2140 ]
2141 );
2142 // ...and with hyperlinks on the column stays at the label's width.
2143 assert_eq!(
2144 plain(source, 60, true).split('\n').collect::<Vec<_>>(),
2145 [
2146 "",
2147 " ",
2148 " T W ",
2149 " โโโโโโโ ",
2150 " r repo ",
2151 " "
2152 ]
2153 );
2154 }
2155
2156 /// Upstream buffers the label in a `Link` element and re-emits only
2157 /// `element.text.plain`, so emphasis *inside* the label is lost.
2158 #[test]
2159 fn hyperlinks_off_flattens_the_labels_own_emphasis() {
2160 assert_eq!(
2161 plain("A [**b** and *i* l](https://e.org) t.", 60, false),
2162 "A b and i l (https://e.org) t. "
2163 );
2164 }
2165
2166 /// `markdown.link` (bright_blue) paints the label, `markdown.link_url`
2167 /// (underline blue) the URL, and both compose over the heading's own style โ
2168 /// h2's magenta loses to each in turn.
2169 #[test]
2170 fn hyperlinks_off_styles_the_label_and_the_url_under_a_heading() {
2171 assert_eq!(
2172 ansi("## H [x](https://e.org)", 40, false),
2173 "\x1b[4;35mH \x1b[0m\x1b[4;94mx\x1b[0m\x1b[4;35m (\x1b[0m\
2174 \x1b[4;34mhttps://e.org\x1b[0m\x1b[4;35m)\x1b[0m "
2175 );
2176 assert_eq!(
2177 ansi("## H [x](https://e.org)", 40, true),
2178 "\x1b[4;35mH \x1b[0m\x1b]8;;https://e.org\x1b\\\x1b[4;34mx\x1b[0m\
2179 \x1b]8;;\x1b\\ "
2180 );
2181 }
2182
2183 /// Upstream pushes `markdown.link_url` *onto* the open style stack, so a
2184 /// link inside `**bold**` is bold as well. Replacing the stack with the link
2185 /// style alone dropped the bold.
2186 #[test]
2187 fn a_link_inside_bold_stays_bold() {
2188 assert_eq!(
2189 ansi("x **b [l](https://e.org) b** y", 60, true),
2190 "x \x1b[1mb \x1b[0m\x1b]8;;https://e.org\x1b\\\x1b[1;4;34ml\x1b[0m\
2191 \x1b]8;;\x1b\\\x1b[1m b\x1b[0m y "
2192 );
2193 }
2194
2195 /// `markdown.code` is pushed on top of the link, so a label that is entirely
2196 /// inline code keeps its destination. Applying the code style alone threw the
2197 /// URL away even with hyperlinks *on*.
2198 #[test]
2199 fn a_link_labelled_with_inline_code_keeps_its_destination() {
2200 assert_eq!(
2201 ansi("A [`code`](https://e.org/x) tail.", 60, true),
2202 "A \x1b]8;;https://e.org/x\x1b\\\x1b[1;4;36;40mcode\x1b[0m\x1b]8;;\x1b\\ \
2203 tail. "
2204 );
2205 }
2206
2207 /// CommonMark gives an email autolink a `mailto:` destination, but
2208 /// pulldown-cmark leaves the scheme to the renderer and hands over the bare
2209 /// address โ so the URL we printed was not a URL.
2210 #[test]
2211 fn an_email_autolink_keeps_its_mailto_scheme() {
2212 assert_eq!(
2213 plain("Mail <who@where.net> now.", 50, false),
2214 "Mail who@where.net (mailto:who@where.net) now. "
2215 );
2216 assert_eq!(
2217 ansi("Mail <who@where.net> now.", 50, true),
2218 "Mail \x1b]8;;mailto:who@where.net\x1b\\\x1b[4;34mwho@where.net\x1b[0m\
2219 \x1b]8;;\x1b\\ now. "
2220 );
2221 }
2222
2223 /// A badge wrapped in a link: `ImageItem` appends its title with the style
2224 /// open around it, so the alt text carries the link's `markdown.link_url`
2225 /// too, not just the OSC 8 target.
2226 #[test]
2227 fn an_image_inside_a_link_carries_the_links_style() {
2228 assert_eq!(
2229 ansi("[](https://e.org)", 40, true),
2230 "\u{1f306} \x1b]8;;https://e.org\x1b\\\x1b[4;34mbadge\x1b[0m\
2231 \x1b]8;;\x1b\\ "
2232 );
2233 }
2234
2235 /// A single-tilde span inside a link label put BOTH tildes in front of the
2236 /// label, because the tilde went to the paragraph buffer while the label
2237 /// text accumulated in its own โ characters reordered, not restyled.
2238 #[test]
2239 fn a_single_tilde_inside_a_link_label_keeps_its_place() {
2240 let out = plain("A [~a~ label](https://e.com) here.\n", 60, false);
2241 assert!(
2242 out.contains("~a~ label"),
2243 "tilde moved out of the label: {out:?}"
2244 );
2245 assert!(!out.contains("~~a"), "tildes were reordered: {out:?}");
2246 }
2247
2248 /// Outside a link there may be no open buffer yet; routing the tilde
2249 /// through `as_mut()` dropped it and 11 of 102 sweep cases regressed.
2250 #[test]
2251 fn a_single_tilde_survives_with_no_buffer_open() {
2252 let out = plain("~5~10 and ~x~\n", 40, false);
2253 assert!(out.contains("~5~10"), "tilde dropped: {out:?}");
2254 assert!(out.contains("~x~"), "tilde dropped: {out:?}");
2255 }
2256
2257 /// Table cells render unstyled (#9), but their tildes still pair by
2258 /// markdown-it's rules: upstream shows `~c~` with the `c` struck.
2259 #[test]
2260 fn table_cell_tildes_pair_like_markdown_it() {
2261 let out = plain("| h |\n|---|\n| ~~~c~~~ |\n", 20, false);
2262 assert!(out.contains("~c~"), "{out:?}");
2263 assert!(!out.contains("~~"), "{out:?}");
2264 }
2265
2266 /// Tildes in a fenced block are code, not delimiters.
2267 #[test]
2268 fn code_block_tildes_are_untouched() {
2269 let out = plain("```\na ~~~x~~~ b\n```\n", 30, false);
2270 assert!(out.contains("a ~~~x~~~ b"), "{out:?}");
2271 }
2272
2273 /// DIVERGENCES ยง21: when a tilde pair would cross an emphasis span whose
2274 /// opener comes after the tilde opener, upstream dissolves the emphasis
2275 /// (`~~a *b~~ c*` strikes `a *b`); this port keeps pulldown-cmark's emphasis
2276 /// and leaves the tildes literal. Pinned so a fix shows up here.
2277 #[test]
2278 fn tildes_crossing_a_later_emphasis_stay_literal() {
2279 let out = plain("~~a *b~~ c*", 30, false);
2280 assert!(out.contains("~~a b~~ c"), "{out:?}");
2281 }
2282}