Skip to main content

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 `![](logo.png)` 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: `![alt *em*](u)` 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| ![a](x) ![b](y) |\n| ![c](z) |",
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(&quotes, 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 `![](url)` โ€” 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](https://example.com/pic.png)  -> '๐ŸŒ† alt text'
1980    /// ![](https://example.com/pic.png)          -> '๐ŸŒ† pic.png'   <- filename
1981    /// ![](img/)                                 -> '๐ŸŒ† img'
1982    /// Before ![alt text](img/pic.png) after.    -> '๐ŸŒ† alt text Before  after.'
1983    /// ![alt *em*](u/v.png)                      -> '๐ŸŒ† 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("![alt text](https://example.com/pic.png)"),
1997            "๐ŸŒ† alt text"
1998        );
1999        assert_eq!(row("![](https://example.com/pic.png)"), "๐ŸŒ† pic.png");
2000        assert_eq!(row("![](img/)"), "๐ŸŒ† img");
2001        // Hoisted to the front of the paragraph it sat inside, on the same row.
2002        assert_eq!(
2003            row("Before ![alt text](img/pic.png) 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*](u/v.png)"), "๐ŸŒ† 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 ![pic](a/b.png) inside'
2019    ///     -> ['๐ŸŒ† pic', ' โ€ข item with  inside']
2020    /// '> quoted ![pic](a/b.png) 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 ![pic](a/b.png) inside"),
2036            vec!["๐ŸŒ† pic", " โ€ข item with  inside"]
2037        );
2038        assert_eq!(
2039            rows("> quoted ![pic](a/b.png) 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("[![badge](b.svg)](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}