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`], each cell a styled [`Text`] carrying
11//! its inline strong/emphasis/code/link/strike runs, as upstream's
12//! `TableDataElement` builds it).
13
14use std::sync::Arc;
15
16use pulldown_cmark::{
17    Alignment, CodeBlockKind, CowStr, Event, HeadingLevel, LinkType, Options, Parser, Tag, TagEnd,
18};
19
20use crate::cells::cell_len;
21use crate::console::{Console, ConsoleOptions, Justify, Overflow};
22use crate::markdown_url::{normalize_link, normalize_link_text, validate_link};
23use crate::protocol::{CodeHighlighter, FenceRenderer, Renderable};
24use crate::r#box::SIMPLE;
25use crate::segment::Segment;
26use crate::style::Style;
27use crate::syntax::Syntax;
28use crate::table::Table;
29use crate::text::Text;
30
31const CODE_STYLE: &str = "bold cyan on black"; // markdown.code
32const QUOTE_STYLE: &str = "magenta"; // markdown.block_quote
33/// The placeholder upstream's `ImageItem` puts in front of an image
34/// (`Text.assemble("๐ŸŒ† ", title, " ")`). U+1F306 measures two cells.
35const IMAGE_MARKER: &str = "\u{1f306} ";
36const BULLET: &str = " \u{2022} "; // " โ€ข ", markdown.item.bullet = bold
37const QUOTE_PREFIX: &str = "\u{258c} "; // "โ–Œ ", markdown.block_quote = magenta
38const LINK_STYLE: &str = "bright_blue"; // markdown.link
39const LINK_URL_STYLE: &str = "underline blue"; // markdown.link_url
40const TABLE_BORDER_STYLE: &str = "cyan"; // markdown.table.border
41const TABLE_HEADER_STYLE: &str = "not bold cyan"; // markdown.table.header
42
43/// One item of a list. An item is a **container**: it holds whatever blocks it
44/// contains โ€” paragraphs, code, tables, quotes, further lists โ€” not a single
45/// line of text.
46///
47/// `number` is `Some` for an ordered list and carries the value to print.
48struct ListEntry {
49    number: Option<u64>,
50    blocks: Vec<Block>,
51}
52
53/// An open container while parsing.
54///
55/// Markdown nests, so parsing it needs a stack. Tracking the open list, quote
56/// and paragraph in flat `Option`s meant any nested block overwrote its
57/// parent's pending content: a heading inside a list item deleted the item's
58/// own text, a nested quote deleted the outer quote, and a code block inside an
59/// item was hoisted above the whole list.
60enum Frame {
61    List {
62        ordered: bool,
63        start: u64,
64        entries: Vec<ListEntry>,
65    },
66    Item {
67        blocks: Vec<Block>,
68    },
69    Quote {
70        blocks: Vec<Block>,
71    },
72}
73
74/// A parsed Markdown block.
75enum Block {
76    /// A paragraph or heading (its `Text` carries justify + any heading span).
77    /// The level is set for a heading; only semantic regions read it.
78    Text(Text, Option<u8>),
79    /// A bullet or ordered list. Each item holds its own blocks, so a nested
80    /// list, code block or quote inside an item is simply part of that item.
81    List { items: Vec<ListEntry> },
82    /// A block quote, holding whatever blocks it contains.
83    Quote {
84        blocks: Vec<Block>,
85        leading_break: bool,
86    },
87    /// An ignored HTML block still participates in upstream block spacing.
88    Html,
89    /// A fenced/indented code block, syntax-highlighted via [`Syntax`].
90    Code {
91        language: String,
92        code: String,
93        /// `Markdown(code_theme=โ€ฆ)`; `None` keeps the `Syntax` default.
94        theme: Option<String>,
95        /// [`Markdown::highlighter`]; `None` keeps the `Syntax` default.
96        highlighter: Option<Arc<dyn CodeHighlighter>>,
97        /// [`Markdown::fence_renderer`]s, asked before `Syntax` for a fenced
98        /// block with a language. Empty unless the caller added one.
99        fences: Vec<Arc<dyn FenceRenderer>>,
100    },
101    /// A thematic break (horizontal rule).
102    Rule,
103    /// An image placeholder. Upstream's `ImageItem` renders `๐ŸŒ† <title> ` and
104    /// says nothing about the picture itself; `text` is that whole assembly.
105    ///
106    /// `joins_next` reproduces `ImageItem.new_line = False` together with the
107    /// `end=""` on its text: nothing separates the marker from whatever renders
108    /// next, so the following block continues on the marker's own row. Only an
109    /// image lifted out of a *top-level* paragraph or heading behaves that way โ€”
110    /// see [`parse`] for why one inside a list or quote does not.
111    ///
112    /// `leading_break` is upstream's `new_line` flag frozen at the moment the
113    /// image was reached: a break precedes it only if some element had already
114    /// closed. It replaces the usual inter-block gap rather than adding to it.
115    Image {
116        text: Text,
117        joins_next: bool,
118        leading_break: bool,
119    },
120    /// A GFM table: per-column justify (from the alignment row), header cells,
121    /// and body rows. Rendered via [`Table`], matching upstream's construction.
122    Table {
123        alignments: Vec<Justify>,
124        headers: Vec<Text>,
125        rows: Vec<Vec<Text>>,
126    },
127}
128
129/// Accumulates a GFM table across `pulldown-cmark`'s table events.
130#[derive(Default)]
131struct TableAccum {
132    alignments: Vec<Justify>,
133    headers: Vec<Text>,
134    rows: Vec<Vec<Text>>,
135    in_head: bool,
136    in_cell: bool,
137    cur_row: Vec<Text>,
138    /// The open cell's content: upstream's `TableDataElement.content`, which
139    /// appends each text run under the context's current style.
140    cur_cell: Text,
141}
142
143/// Where an inline run lands: the open table cell if there is one (upstream's
144/// `TableDataElement.on_text`), else the open paragraph-level buffer.
145fn inline_target<'a>(
146    current: &'a mut Option<Text>,
147    table: &'a mut Option<TableAccum>,
148) -> &'a mut Text {
149    match table.as_mut().filter(|acc| acc.in_cell) {
150        Some(acc) => &mut acc.cur_cell,
151        None => current.get_or_insert_with(|| Text::new("")),
152    }
153}
154
155fn alignment_justify(alignment: Alignment) -> Justify {
156    match alignment {
157        Alignment::Right => Justify::Right,
158        Alignment::Center => Justify::Center,
159        // `None` has no explicit marker; upstream leaves it default (left).
160        Alignment::Left | Alignment::None => Justify::Left,
161    }
162}
163
164/// A rendered Markdown document. Mirrors `rich.markdown.Markdown`.
165pub struct Markdown {
166    source: String,
167    options: MarkdownOptions,
168    blocks: Vec<Block>,
169}
170
171/// The constructor options of `rich.markdown.Markdown` that change what the
172/// parsed blocks contain, so changing one re-parses the document.
173#[derive(Clone, Default)]
174struct MarkdownOptions {
175    /// `hyperlinks` (see [`Markdown::hyperlinks`]); stored inverted so the
176    /// derived default matches upstream's `True`.
177    no_hyperlinks: bool,
178    /// `justify` for paragraphs; `None` is upstream's `markdown.justify or "left"`.
179    justify: Option<Justify>,
180    /// `style`, the root of upstream's style stack; `None` is `"none"`.
181    style: Option<Style>,
182    /// `code_theme` for fenced and indented code blocks.
183    code_theme: Option<String>,
184    /// `inline_code_lexer`: when set, inline code is highlighted as this language.
185    inline_code_lexer: Option<String>,
186    /// `inline_code_theme`, defaulting to `code_theme`.
187    inline_code_theme: Option<String>,
188    /// The engine for code blocks and highlighted inline code.
189    highlighter: Option<Arc<dyn CodeHighlighter>>,
190    /// Renderers for fenced blocks, in the order they were added.
191    fences: Vec<Arc<dyn FenceRenderer>>,
192}
193
194impl Markdown {
195    /// Parse CommonMark `source` into renderable blocks.
196    ///
197    /// Hyperlinks are on, matching `rich.markdown.Markdown(hyperlinks=True)`.
198    /// **The CLI wants them off** โ€” see [`hyperlinks`](Self::hyperlinks).
199    pub fn new(source: &str) -> Self {
200        let options = MarkdownOptions::default();
201        Markdown {
202            source: source.to_string(),
203            blocks: parse(source, &options),
204            options,
205        }
206    }
207
208    /// Choose how a `[text](url)` is rendered. Port of
209    /// `rich.markdown.Markdown(hyperlinks=โ€ฆ)`, default `true`.
210    ///
211    /// * `true` โ€” the text becomes an OSC 8 hyperlink pointing at the URL.
212    /// * `false` โ€” the URL is written out after the text, as
213    ///   `text (https://example.com)`.
214    ///
215    /// The distinction is not cosmetic. An OSC 8 escape is only emitted when
216    /// the console has a colour system, so with hyperlinks on a piped or
217    /// `NO_COLOR` render drops every destination with nothing left to recover
218    /// it from. That is why upstream's **`rich-cli` passes `hyperlinks=False`
219    /// by default** and puts the OSC 8 form behind its opt-in `-y/--hyperlinks`
220    /// flag; a CLI built on this crate should do the same:
221    ///
222    /// ```
223    /// # use rich::markdown::Markdown;
224    /// let opt_in = false; // set by `-y/--hyperlinks`
225    /// let md = Markdown::new("A [link](https://example.com).").hyperlinks(opt_in);
226    /// ```
227    pub fn hyperlinks(mut self, hyperlinks: bool) -> Self {
228        // The flag changes what the *text* of a paragraph or table cell is, not
229        // just how it is painted, so the document has to be re-parsed.
230        self.options.no_hyperlinks = !hyperlinks;
231        self.reparse()
232    }
233
234    /// Justify every paragraph. Port of `Markdown(justify=โ€ฆ)`; by default
235    /// paragraphs are left-justified. Headings keep their own alignment.
236    pub fn justify(mut self, justify: Justify) -> Self {
237        self.options.justify = Some(justify);
238        self.reparse()
239    }
240
241    /// The root style every run of text is drawn in. Port of
242    /// `Markdown(style=โ€ฆ)`, default `"none"`.
243    pub fn style(mut self, style: Style) -> Self {
244        self.options.style = Some(style).filter(|style| !style.is_null());
245        self.reparse()
246    }
247
248    /// The theme for code blocks. Port of `Markdown(code_theme=โ€ฆ)`. Names are
249    /// the highlighter's own: with the default, `syntect` theme names plus
250    /// upstream's `ansi_dark` and `ansi_light` (see DIVERGENCES #18).
251    pub fn code_theme(mut self, theme: impl Into<String>) -> Self {
252        self.options.code_theme = Some(theme.into());
253        self.reparse()
254    }
255
256    /// Highlight inline code as `lexer`. Port of `Markdown(inline_code_lexer=โ€ฆ)`;
257    /// by default inline code is not highlighted.
258    pub fn inline_code_lexer(mut self, lexer: impl Into<String>) -> Self {
259        self.options.inline_code_lexer = Some(lexer.into());
260        self.reparse()
261    }
262
263    /// The theme for highlighted inline code. Port of
264    /// `Markdown(inline_code_theme=โ€ฆ)`, defaulting to the code theme.
265    pub fn inline_code_theme(mut self, theme: impl Into<String>) -> Self {
266        self.options.inline_code_theme = Some(theme.into());
267        self.reparse()
268    }
269
270    /// Highlight code blocks, and inline code when an
271    /// [`inline_code_lexer`](Self::inline_code_lexer) is set, with `highlighter`
272    /// instead of the default [`SyntectHighlighter`](crate::syntax::SyntectHighlighter).
273    /// Theme names are then the highlighter's own.
274    pub fn highlighter(mut self, highlighter: Arc<dyn CodeHighlighter>) -> Self {
275        self.options.highlighter = Some(highlighter);
276        self.reparse()
277    }
278
279    /// Let `renderer` draw fenced code blocks instead of highlighting them, for
280    /// the languages it accepts (see [`FenceRenderer`]). Renderers are asked in
281    /// the order they were added. Not in upstream, which always highlights; with
282    /// none added, output is unchanged.
283    pub fn fence_renderer(mut self, renderer: Arc<dyn FenceRenderer>) -> Self {
284        self.options.fences.push(renderer);
285        self.reparse()
286    }
287
288    fn reparse(mut self) -> Self {
289        self.blocks = parse(&self.source, &self.options);
290        self
291    }
292}
293
294fn heading_level(level: HeadingLevel) -> usize {
295    match level {
296        HeadingLevel::H1 => 1,
297        HeadingLevel::H2 => 2,
298        HeadingLevel::H3 => 3,
299        HeadingLevel::H4 => 4,
300        HeadingLevel::H5 => 5,
301        HeadingLevel::H6 => 6,
302    }
303}
304
305/// `(base style, justify)` for a heading level (`default_styles.py` +
306/// `Heading.LEVEL_ALIGN`).
307fn heading_format(level: usize) -> (Style, Justify) {
308    let (spec, justify) = match level {
309        1 => ("bold underline", Justify::Center),
310        2 => ("underline magenta", Justify::Left),
311        3 => ("bold magenta", Justify::Left),
312        4 => ("italic magenta", Justify::Left),
313        5 => ("italic", Justify::Left),
314        _ => ("dim", Justify::Left),
315    };
316    (Style::parse(spec).unwrap_or_default(), justify)
317}
318
319fn inline_style(strong: usize, emphasis: usize, strike: usize) -> Option<Style> {
320    if strong == 0 && emphasis == 0 && strike == 0 {
321        return None;
322    }
323    let mut style = Style::new();
324    if strong > 0 {
325        style = style.combine(&Style::parse("bold").expect("valid style"));
326    }
327    if emphasis > 0 {
328        style = style.combine(&Style::parse("italic").expect("valid style"));
329    }
330    if strike > 0 {
331        // `markdown.s` in upstream's default theme.
332        style = style.combine(&Style::parse("strike").expect("valid style"));
333    }
334    Some(style)
335}
336
337/// `markdown.link_url` plus the OSC 8 target, which is what upstream pushes for
338/// a link when `hyperlinks=True`.
339fn link_style(url: &str) -> Style {
340    Style::parse(LINK_URL_STYLE)
341        .expect("valid style")
342        .with_link(url.to_string())
343}
344
345/// Upstream's `MarkdownContext.style_stack.current`: the product of every style
346/// open at this point, outermost first, each layer overriding the last.
347///
348/// The order is what makes an inline style compose rather than replace. A link
349/// inside `**bold**` is `bold underline blue`, not plain `underline blue`; a
350/// `` `code` `` inside a link keeps the link *and* takes cyan over the link's
351/// blue. Applying only the innermost layer dropped the outer attributes, and โ€”
352/// worse โ€” a link whose whole text was inline code lost its URL entirely.
353///
354/// `extra` is the run's own style (`markdown.code` for a code span), pushed last
355/// because upstream enters it after the link.
356/// The bottom of upstream's style stack at this point in the parse: the
357/// document `style`, with `markdown.block_quote` pushed for each enclosing quote
358/// (`markdown.item` is `none`).
359fn quote_root(md: &MarkdownOptions, stack: &[Frame]) -> Option<Style> {
360    let mut root = md.style.clone();
361    if stack
362        .iter()
363        .any(|frame| matches!(frame, Frame::Quote { .. }))
364    {
365        let quote = Style::parse(QUOTE_STYLE).expect("valid style");
366        root = Some(match root {
367            Some(root) => root.combine(&quote),
368            None => quote,
369        });
370    }
371    root
372}
373
374fn stack_style(
375    root: Option<&Style>,
376    heading: Option<&Style>,
377    inline: Option<Style>,
378    link: Option<&str>,
379    extra: Option<Style>,
380) -> Option<Style> {
381    let mut current: Option<Style> = None;
382    for layer in [
383        root.cloned(),
384        heading.cloned(),
385        inline,
386        link.map(link_style),
387        extra,
388    ] {
389        let Some(next) = layer else { continue };
390        current = Some(match current {
391            Some(previous) => previous.combine(&next),
392            None => next,
393        });
394    }
395    current
396}
397
398/// The title upstream shows when an image has no alt text: the last path
399/// component of its destination, `destination.strip("/").rsplit("/", 1)[-1]`.
400///
401/// Without it `![](logo.png)` rendered as a blank line โ€” a badge row in a README
402/// simply disappeared.
403fn image_fallback_title(destination: &str) -> &str {
404    let trimmed = destination.trim_matches('/');
405    match trimmed.rsplit_once('/') {
406        Some((_, last)) => last,
407        None => trimmed,
408    }
409}
410
411/// Assemble upstream's `Text.assemble("๐ŸŒ† ", title, " ")` for one image.
412///
413/// `link` is the URL of an enclosing `[โ€ฆ](โ€ฆ)`, which upstream prefers over the
414/// image's own destination (`self.link or self.destination`) so that a linked
415/// badge points at the link, not at the picture.
416///
417/// With `hyperlinks` off the target is dropped entirely:
418/// `ImageItem.__rich_console__` guards its `title.stylize(link_style)` behind
419/// `if self.hyperlinks`, so the marker carries no OSC 8 escape at all.
420fn image_text(
421    destination: &str,
422    alt: Text,
423    link: Option<&str>,
424    outer: Option<Style>,
425    hyperlinks: bool,
426) -> Text {
427    let mut title = if alt.plain().is_empty() {
428        Text::new(image_fallback_title(destination))
429    } else {
430        alt
431    };
432    let end = title.plain().len();
433    // `ImageItem.on_text` appends with `context.current_style`, so the title
434    // carries whatever was open around the image โ€” a heading's style, and the
435    // enclosing link's `markdown.link_url` for a badge wrapped in a link.
436    if let Some(style) = outer {
437        title.stylize(style, 0, end);
438    }
439    // `Style(link=self.link or self.destination or None)`: the enclosing link
440    // wins, the image's own destination is the fallback, and neither being set
441    // leaves the title unlinked.
442    if hyperlinks {
443        let target = link.unwrap_or(destination);
444        if !target.is_empty() {
445            title.stylize(Style::new().with_link(target.to_string()), 0, end);
446        }
447    }
448    let mut text = Text::new(IMAGE_MARKER).append_text(&title);
449    text.append(" ", None);
450    text
451}
452
453/// Where a finished block belongs: the innermost open item or quote, else the
454/// document. A `List` frame holds entries rather than blocks, so content passes
455/// straight through it to the item that owns it.
456fn sink<'a>(document: &'a mut Vec<Block>, stack: &'a mut [Frame]) -> &'a mut Vec<Block> {
457    match stack
458        .iter()
459        .rposition(|frame| matches!(frame, Frame::Item { .. } | Frame::Quote { .. }))
460    {
461        Some(index) => match &mut stack[index] {
462            Frame::Item { blocks } | Frame::Quote { blocks } => blocks,
463            Frame::List { .. } => unreachable!("rposition matched Item or Quote"),
464        },
465        None => document,
466    }
467}
468
469/// How deep containers may nest before further nesting is flattened.
470///
471/// Rendering recurses once per level, so an unbounded document overflows the
472/// stack and takes the process with it: 400 nested block quotes aborted with
473/// STATUS_STACK_OVERFLOW, no output, after burning four seconds of CPU.
474///
475/// Upstream caps this too โ€” markdown-it's `maxNesting` defaults to 20, which is
476/// why it renders such a document rather than dying. Content past the cap is
477/// kept; it simply stops indenting.
478const MAX_NESTING: usize = 20;
479
480/// Commit any pending inline text to the innermost open container.
481///
482/// A *tight* list item's text arrives as bare `Text` events with no enclosing
483/// paragraph, so it sits in `current` until something closes it. Every
484/// block-level start must call this first, or it overwrites that text โ€” which
485/// silently deleted the item's own content and reordered code blocks ahead of
486/// the paragraph introducing them.
487fn flush_pending(
488    current: &mut Option<Text>,
489    blocks: &mut Vec<Block>,
490    stack: &mut [Frame],
491    justify: Justify,
492) {
493    let Some(mut text) = current.take() else {
494        return;
495    };
496    // A freshly opened item holds an empty buffer; committing it would emit a
497    // blank block.
498    if text.plain().is_empty() {
499        return;
500    }
501    text.set_justify(justify);
502    sink(blocks, stack).push(Block::Text(text, None));
503}
504
505/// Emit a literal `~` for a single-tilde span, into whichever buffer the
506/// surrounding characters are going to.
507///
508/// Inside a link label the label text is buffered separately, so appending
509/// straight to `current` put BOTH tildes in front of the label: `[~a~ label]`
510/// rendered as `~~a label`, characters reordered rather than restyled. Outside
511/// one the buffer may not be open yet, so it still has to be created โ€” routing
512/// through a plain `as_mut()` silently DROPPED the tilde instead.
513fn push_tilde(
514    current: &mut Option<Text>,
515    table: &mut Option<TableAccum>,
516    link_label: &mut Option<String>,
517) {
518    if let Some(label) = link_label.as_mut() {
519        label.push('~');
520    } else {
521        inline_target(current, table).append("~", None);
522    }
523}
524
525/// Append a soft/hard break to the open link label if one is being buffered,
526/// else to the open text buffer if there is one.
527fn append_break(
528    current: Option<&mut Text>,
529    link_label: Option<&mut String>,
530    text: &str,
531    style: Option<Style>,
532) {
533    if let Some(label) = link_label {
534        label.push_str(text);
535    } else if let Some(block) = current {
536        block.append(text, style.map(Into::into));
537    }
538}
539
540/// One inline token while pairing tildes: an untouched event, literal source
541/// text, a `~~` delimiter, or a delimiter that has been paired.
542enum Piece<'a> {
543    Event(Event<'a>, std::ops::Range<usize>),
544    Literal(std::ops::Range<usize>),
545    Tilde(std::ops::Range<usize>),
546    Open(std::ops::Range<usize>),
547    Close(std::ops::Range<usize>),
548}
549
550/// A `~~` delimiter in markdown-it's `Delimiter` sense. `length` is always 0
551/// for strikethrough (upstream disables the "rule of 3"), so it is omitted.
552struct Delimiter {
553    piece: usize,
554    open: bool,
555    close: bool,
556    end: Option<usize>,
557    /// Innermost emphasis/strong span containing the delimiter. markdown-it
558    /// pairs `*`/`_` and `~` in one pass, and a matched pair's jump hides every
559    /// delimiter inside it from later closers; tildes are never paired across
560    /// an emphasis span here for the same reason (see DIVERGENCES ยง21).
561    emphasis: usize,
562}
563
564fn is_md_ascii_punct(c: char) -> bool {
565    c.is_ascii_punctuation()
566}
567
568/// markdown-it's `isPunctChar`: ASCII punctuation or a Unicode punctuation
569/// category. `char::is_ascii_punctuation` plus general punctuation/symbols is
570/// the closest std-only equivalent.
571fn is_punct_char(c: char) -> bool {
572    c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control())
573}
574
575/// Port of markdown-it's `StateInline.scanDelims` for a tilde run
576/// (`canSplitWord = True`), returning `(can_open, can_close)`.
577fn scan_delims(last: char, next: char) -> (bool, bool) {
578    let last_punct = is_md_ascii_punct(last) || is_punct_char(last);
579    let next_punct = is_md_ascii_punct(next) || is_punct_char(next);
580    let last_space = last.is_whitespace();
581    let next_space = next.is_whitespace();
582    let left_flanking = !(next_space || (next_punct && !(last_space || last_punct)));
583    let right_flanking = !(last_space || (last_punct && !(next_space || next_punct)));
584    (left_flanking, right_flanking)
585}
586
587/// Port of markdown-it's `balance_pairs.processDelimiters` for one delimiter
588/// list (a single marker, lengths 0).
589fn process_delimiters(delimiters: &mut [Delimiter]) {
590    if delimiters.is_empty() {
591        return;
592    }
593    // `openersBottom[marker]`, indexed by `closer.open ? 3 : 0` (length % 3 = 0).
594    let mut openers_bottom = [-1isize; 6];
595    let mut header = 0usize;
596    let mut last_piece: isize = -2;
597    let mut jumps: Vec<usize> = Vec::with_capacity(delimiters.len());
598    for closer_index in 0..delimiters.len() {
599        jumps.push(0);
600        if last_piece != delimiters[closer_index].piece as isize - 1 {
601            header = closer_index;
602        }
603        last_piece = delimiters[closer_index].piece as isize;
604        if !delimiters[closer_index].close {
605            continue;
606        }
607        let slot = if delimiters[closer_index].open { 3 } else { 0 };
608        let min_opener = openers_bottom[slot];
609        let mut opener_index = header as isize - jumps[header] as isize - 1;
610        let mut new_min = opener_index;
611        while opener_index > min_opener {
612            let i = opener_index as usize;
613            let usable = delimiters[i].open
614                && delimiters[i].end.is_none()
615                && delimiters[i].emphasis == delimiters[closer_index].emphasis;
616            if usable {
617                let last_jump = if i > 0 && !delimiters[i - 1].open {
618                    jumps[i - 1] + 1
619                } else {
620                    0
621                };
622                jumps[closer_index] = closer_index - i + last_jump;
623                jumps[i] = last_jump;
624                delimiters[closer_index].open = false;
625                delimiters[i].end = Some(closer_index);
626                delimiters[i].close = false;
627                new_min = -1;
628                last_piece = -2;
629                break;
630            }
631            opener_index -= jumps[i] as isize + 1;
632        }
633        if new_min != -1 {
634            openers_bottom[slot] = new_min;
635        }
636    }
637}
638
639/// A link or image whose destination markdown-it's `validateLink` refuses.
640enum Rejected {
641    /// `<javascript:โ€ฆ>`: the whole autolink is literal text.
642    Autolink,
643    /// `[label](โ€ฆ)` / `![alt](โ€ฆ)`: the brackets and destination are literal,
644    /// the label still parses. `range` is the whole link's source; `last_end`
645    /// where its last inner event ended (the closing `]` follows it).
646    Bracket {
647        range: std::ops::Range<usize>,
648        last_end: usize,
649    },
650}
651
652/// Un-link destinations markdown-it would refuse (`validateLink`): upstream's
653/// link, image and autolink rules fail on them, so the source stays text โ€”
654/// `[j](javascript:x)` prints as written, with only its label's own inline
655/// markup (emphasis, codeโ€ฆ) still parsed. pulldown-cmark makes a link of any
656/// destination, so the refused ones are turned back into their source here.
657///
658/// A refused *reference definition* (`[1]: javascript:x`) is not recovered
659/// (DIVERGENCES #24): pulldown-cmark consumes the definition line, which
660/// upstream prints as a paragraph. The link using it does print as literal text.
661fn reject_invalid_links<'a>(
662    source: &'a str,
663    events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
664) -> Vec<(Event<'a>, std::ops::Range<usize>)> {
665    let literal = |range: std::ops::Range<usize>| {
666        (Event::Text(CowStr::Borrowed(&source[range.clone()])), range)
667    };
668    let mut out = Vec::new();
669    // One entry per open link or image: `None` when it is kept.
670    let mut open: Vec<Option<Rejected>> = Vec::new();
671    for (event, range) in events {
672        let href = match &event {
673            Event::Start(Tag::Link {
674                link_type: LinkType::Email,
675                dest_url,
676                ..
677            }) => Some(normalize_link(&format!("mailto:{dest_url}"))),
678            Event::Start(Tag::Link { dest_url, .. } | Tag::Image { dest_url, .. }) => {
679                Some(normalize_link(dest_url))
680            }
681            _ => None,
682        };
683        let pushed = match (&event, href) {
684            (_, Some(href)) if validate_link(&href) => {
685                open.push(None);
686                vec![(event, range.clone())]
687            }
688            (
689                Event::Start(Tag::Link {
690                    link_type: LinkType::Autolink | LinkType::Email,
691                    ..
692                }),
693                Some(_),
694            ) => {
695                open.push(Some(Rejected::Autolink));
696                vec![literal(range.clone())]
697            }
698            (Event::Start(tag), Some(_)) => {
699                let opener = if matches!(tag, Tag::Image { .. }) {
700                    2
701                } else {
702                    1
703                };
704                let opener = range.start..(range.start + opener).min(range.end);
705                open.push(Some(Rejected::Bracket {
706                    range: range.clone(),
707                    last_end: opener.end,
708                }));
709                vec![literal(opener)]
710            }
711            (Event::End(TagEnd::Link | TagEnd::Image), _) => match open.pop() {
712                Some(Some(Rejected::Autolink)) => Vec::new(),
713                Some(Some(Rejected::Bracket { range, last_end })) => {
714                    vec![literal(last_end.min(range.end)..range.end)]
715                }
716                Some(None) | None => vec![(event, range.clone())],
717            },
718            // The text inside a refused autolink is already in its literal.
719            _ if matches!(open.last(), Some(Some(Rejected::Autolink))) => Vec::new(),
720            _ => vec![(event, range.clone())],
721        };
722        for (_, pushed_range) in &pushed {
723            for entry in open.iter_mut() {
724                if let Some(Rejected::Bracket { last_end, .. }) = entry {
725                    *last_end = (*last_end).max(pushed_range.end);
726                }
727            }
728        }
729        out.extend(pushed);
730    }
731    out
732}
733
734/// Turn a GFM table that markdown-it would not accept back into a paragraph.
735///
736/// pulldown-cmark's delimiter row is laxer than markdown-it's (`table.py`):
737/// it takes a cell of a lone `:` (`a|b` over `-|:`), where markdown-it wants
738/// every cell to match `^:?-+:?$`, no empty cell between two others, and as
739/// many cells as the header row. A rejected table is re-parsed as ordinary
740/// text (tables off), with its offsets moved back into `source`.
741///
742/// Only top-level tables are re-parsed: inside a list or quote the source
743/// range carries the container's markers, which a standalone parse would
744/// read as new containers.
745fn reject_invalid_tables<'a>(
746    source: &'a str,
747    events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
748) -> Vec<(Event<'a>, std::ops::Range<usize>)> {
749    let mut out = Vec::new();
750    let mut depth = 0usize;
751    let mut skipping = false;
752    for (event, range) in events {
753        if skipping {
754            if matches!(event, Event::End(TagEnd::Table)) {
755                skipping = false;
756            }
757            continue;
758        }
759        match &event {
760            Event::Start(Tag::BlockQuote(_) | Tag::List(_) | Tag::FootnoteDefinition(_)) => {
761                depth += 1
762            }
763            Event::End(TagEnd::BlockQuote(_) | TagEnd::List(_) | TagEnd::FootnoteDefinition) => {
764                depth = depth.saturating_sub(1)
765            }
766            Event::Start(Tag::Table(_)) if depth == 0 => {
767                let table = &source[range.clone()];
768                let mut lines = table.split('\n');
769                let header = lines.next().unwrap_or("");
770                let delimiter = lines.next().unwrap_or("");
771                if !markdown_it_table_start(header, delimiter) {
772                    let offset = range.start;
773                    out.extend(
774                        Parser::new_ext(table, Options::empty())
775                            .into_offset_iter()
776                            .map(|(event, inner)| {
777                                (event, inner.start + offset..inner.end + offset)
778                            }),
779                    );
780                    skipping = true;
781                    continue;
782                }
783            }
784            _ => {}
785        }
786        out.push((event, range));
787    }
788    out
789}
790
791/// markdown-it's test for a table start (`rules_block/table.py`): the
792/// delimiter row's characters, each of its cells, and the header's cell count.
793fn markdown_it_table_start(header: &str, delimiter: &str) -> bool {
794    let is_space = |c: char| c == ' ' || c == '\t';
795    let is_delimiter_char = |c: char| matches!(c, '|' | '-' | ':');
796    let delimiter = delimiter
797        .trim_end_matches('\r')
798        .trim_start_matches(is_space);
799    let mut chars = delimiter.chars();
800    let (Some(first), Some(second)) = (chars.next(), chars.next()) else {
801        return false;
802    };
803    if !is_delimiter_char(first)
804        || !(is_delimiter_char(second) || is_space(second))
805        || (first == '-' && is_space(second))
806        || !chars.all(|c| is_delimiter_char(c) || is_space(c))
807    {
808        return false;
809    }
810    let columns: Vec<&str> = delimiter.split('|').collect();
811    let mut aligns = 0usize;
812    for (index, column) in columns.iter().enumerate() {
813        let cell = column.trim_matches(crate::text::is_python_space);
814        if cell.is_empty() {
815            if index == 0 || index == columns.len() - 1 {
816                continue;
817            }
818            return false;
819        }
820        let dashes = cell.trim_start_matches(':');
821        let dashes = dashes.strip_suffix(':').unwrap_or(dashes);
822        let leading = cell.len() - cell.trim_start_matches(':').len();
823        if leading > 1 || dashes.is_empty() || !dashes.bytes().all(|b| b == b'-') {
824            return false;
825        }
826        aligns += 1;
827    }
828    let header = header
829        .trim_end_matches('\r')
830        .trim_matches(crate::text::is_python_space);
831    if !header.contains('|') {
832        return false;
833    }
834    // `escapedSplit`: a `\|` stays in its cell.
835    let mut cells: Vec<String> = vec![String::new()];
836    let mut escaped = false;
837    for c in header.chars() {
838        if c == '|' && !escaped {
839            cells.push(String::new());
840        } else {
841            let cell = cells.last_mut().expect("a cell");
842            if c == '|' {
843                cell.pop();
844            }
845            cell.push(c);
846        }
847        escaped = c == '\\';
848    }
849    if cells.first().is_some_and(String::is_empty) {
850        cells.remove(0);
851    }
852    if cells.last().is_some_and(String::is_empty) {
853        cells.pop();
854    }
855    !cells.is_empty() && cells.len() == aligns
856}
857
858/// Pair tilde runs the way upstream's markdown-it does (its `strikethrough`
859/// tokenize + `balance_pairs` + postProcess), over pulldown-cmark events parsed
860/// *without* strikethrough.
861///
862/// Per inline run (a paragraph, heading, table cell or tight list item, with a
863/// link label as its own nested scope, as markdown-it scopes delimiters per
864/// opening token): each run of two or more tildes in literal text becomes an
865/// optional leading `~` (odd runs) plus `~~` delimiters; paired delimiters turn
866/// into `Strikethrough` events, and a lone `~` left before a closer moves after
867/// it. `a ~~~x~~~ b` renders `a ~` + struck `x` + `~ b`, as upstream does.
868fn pair_strikethrough<'a>(
869    source: &'a str,
870    events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
871) -> Vec<(Event<'a>, std::ops::Range<usize>)> {
872    let mut pieces: Vec<Piece<'a>> = Vec::new();
873    // Delimiter lists: one per open scope; a link pushes a nested one.
874    let mut scopes: Vec<Vec<Delimiter>> = vec![Vec::new()];
875    let mut finished: Vec<Vec<Delimiter>> = Vec::new();
876    let mut emphasis_stack: Vec<usize> = Vec::new();
877    let mut next_emphasis = 1usize;
878    let mut in_code = false;
879    let mut in_cell = false;
880    let mut image_depth = 0usize;
881    // markdown-it's autolink rule consumes `<โ€ฆ>` whole, so tildes inside one
882    // are never delimiters.
883    let mut in_autolink = false;
884
885    let neighbour = |c: Option<char>, in_cell: bool| match c {
886        None => ' ',
887        // markdown-it parses a trimmed cell, so a pipe reads as the edge.
888        Some('|') if in_cell => ' ',
889        Some(c) => c,
890    };
891
892    for (event, range) in events {
893        if image_depth > 0 {
894            match &event {
895                Event::Start(Tag::Image { .. }) => image_depth += 1,
896                Event::End(TagEnd::Image) => image_depth -= 1,
897                _ => {}
898            }
899            pieces.push(Piece::Event(event, range));
900            continue;
901        }
902        match &event {
903            Event::Text(text) if !in_code && !in_autolink && **text == source[range.clone()] => {
904                // Merge with a directly preceding literal so a run split across
905                // two text events is scanned as one.
906                let bytes = source.as_bytes();
907                let mut literal_from = range.start;
908                let mut at = range.start;
909                if let Some(Piece::Literal(previous)) = pieces.last() {
910                    if previous.end == range.start {
911                        literal_from = previous.start;
912                        // The previous literal holds no run of two or more
913                        // tildes (those became pieces), so only a run at its
914                        // very end can continue into this event. Rescanning
915                        // the whole literal made `[` * 20000 โ€” one text
916                        // event per bracket โ€” quadratic.
917                        at = previous.end;
918                        while at > previous.start && bytes[at - 1] == b'~' {
919                            at -= 1;
920                        }
921                        pieces.pop();
922                    }
923                }
924                let end = range.end;
925                while at < end {
926                    if bytes[at] != b'~' {
927                        at += 1;
928                        continue;
929                    }
930                    let run_start = at;
931                    while at < end && bytes[at] == b'~' {
932                        at += 1;
933                    }
934                    let length = at - run_start;
935                    if length < 2 {
936                        continue;
937                    }
938                    if literal_from < run_start {
939                        pieces.push(Piece::Literal(literal_from..run_start));
940                    }
941                    let last = neighbour(source[..run_start].chars().next_back(), in_cell);
942                    let next = neighbour(source[at..].chars().next(), in_cell);
943                    let (open, close) = scan_delims(last, next);
944                    let mut from = run_start;
945                    if length % 2 == 1 {
946                        pieces.push(Piece::Literal(from..from + 1));
947                        from += 1;
948                    }
949                    let emphasis = emphasis_stack.last().copied().unwrap_or(0);
950                    while from < at {
951                        pieces.push(Piece::Tilde(from..from + 2));
952                        scopes.last_mut().expect("scope").push(Delimiter {
953                            piece: pieces.len() - 1,
954                            open,
955                            close,
956                            end: None,
957                            emphasis,
958                        });
959                        from += 2;
960                    }
961                    literal_from = at;
962                }
963                if literal_from < end {
964                    pieces.push(Piece::Literal(literal_from..end));
965                }
966                continue;
967            }
968            Event::Start(Tag::Emphasis | Tag::Strong) => {
969                emphasis_stack.push(next_emphasis);
970                next_emphasis += 1;
971            }
972            Event::End(TagEnd::Emphasis | TagEnd::Strong) => {
973                emphasis_stack.pop();
974            }
975            Event::Start(Tag::Link { link_type, .. }) => {
976                in_autolink = matches!(link_type, LinkType::Autolink | LinkType::Email);
977                scopes.push(Vec::new());
978            }
979            Event::End(TagEnd::Link) => {
980                in_autolink = false;
981                if scopes.len() > 1 {
982                    finished.push(scopes.pop().expect("link scope"));
983                }
984            }
985            Event::Start(Tag::Image { .. }) => image_depth = 1,
986            Event::Text(_)
987            | Event::Code(_)
988            | Event::InlineHtml(_)
989            | Event::SoftBreak
990            | Event::HardBreak
991            | Event::FootnoteReference(_)
992            | Event::InlineMath(_) => {}
993            // Anything else is block structure: the inline run ends here.
994            _ => {
995                match &event {
996                    Event::Start(Tag::CodeBlock(_)) => in_code = true,
997                    Event::End(TagEnd::CodeBlock) => in_code = false,
998                    Event::Start(Tag::TableCell) => in_cell = true,
999                    Event::End(TagEnd::TableCell) => in_cell = false,
1000                    _ => {}
1001                }
1002                finished.append(&mut scopes);
1003                scopes.push(Vec::new());
1004                emphasis_stack.clear();
1005            }
1006        }
1007        pieces.push(Piece::Event(event, range));
1008    }
1009    finished.append(&mut scopes);
1010
1011    // Pair, then mark: markdown-it's strikethrough `_postProcess`.
1012    let mut lone_markers: Vec<usize> = Vec::new();
1013    for mut delimiters in finished {
1014        process_delimiters(&mut delimiters);
1015        for delimiter in &delimiters {
1016            let Some(end) = delimiter.end else { continue };
1017            let closer = delimiters[end].piece;
1018            if let Piece::Tilde(range) = &pieces[delimiter.piece] {
1019                pieces[delimiter.piece] = Piece::Open(range.clone());
1020            }
1021            if let Piece::Tilde(range) = &pieces[closer] {
1022                pieces[closer] = Piece::Close(range.clone());
1023            }
1024            if let Some(Piece::Literal(range)) = closer.checked_sub(1).map(|i| &pieces[i]) {
1025                if &source[range.clone()] == "~" {
1026                    lone_markers.push(closer - 1);
1027                }
1028            }
1029        }
1030    }
1031    // An odd run is split as `~` + `~~`โ€ฆ, so a closer can leave its lone `~`
1032    // in front of it: move it after the closing tags.
1033    while let Some(i) = lone_markers.pop() {
1034        let mut j = i + 1;
1035        while j < pieces.len() && matches!(pieces[j], Piece::Close(_)) {
1036            j += 1;
1037        }
1038        j -= 1;
1039        if i != j {
1040            pieces.swap(i, j);
1041        }
1042    }
1043
1044    // markdown-it's `fragments_join`: adjacent text tokens become one, so a
1045    // run like `a ~` renders as a single span rather than one per piece.
1046    let mut out: Vec<(Event<'a>, std::ops::Range<usize>)> = Vec::with_capacity(pieces.len());
1047    for piece in pieces {
1048        let (event, range) = match piece {
1049            Piece::Event(event, range) => (event, range),
1050            Piece::Literal(range) | Piece::Tilde(range) => {
1051                (Event::Text(CowStr::Borrowed(&source[range.clone()])), range)
1052            }
1053            Piece::Open(range) => (Event::Start(Tag::Strikethrough), range),
1054            Piece::Close(range) => (Event::End(TagEnd::Strikethrough), range),
1055        };
1056        if let (Event::Text(text), Some((Event::Text(previous), previous_range))) =
1057            (&event, out.last_mut())
1058        {
1059            let mut joined = previous.to_string();
1060            joined.push_str(text);
1061            *previous = CowStr::Boxed(joined.into_boxed_str());
1062            *previous_range =
1063                previous_range.start.min(range.start)..previous_range.end.max(range.end);
1064            continue;
1065        }
1066        out.push((event, range));
1067    }
1068    out
1069}
1070
1071fn parse(source: &str, md: &MarkdownOptions) -> Vec<Block> {
1072    let hyperlinks = !md.no_hyperlinks;
1073    let paragraph_justify = md.justify.unwrap_or(Justify::Left);
1074    let mut blocks: Vec<Block> = Vec::new();
1075    let mut current: Option<Text> = None;
1076    let mut heading_style: Option<Style> = None;
1077    // The open heading's level, for its block (semantic regions only).
1078    let mut heading_open: Option<u8> = None;
1079    let mut justify = Justify::Left;
1080    let mut strong = 0usize;
1081    let mut emphasis = 0usize;
1082    let mut strike = 0usize;
1083    // Depth of single-tilde spans currently open; their delimiters are re-emitted
1084    // as literal text so the run is not styled.
1085    let mut single_tilde = 0usize;
1086    // Open containers, innermost last. Markdown nests, so this has to be a
1087    // stack: with flat slots, any nested block overwrote its parent's pending
1088    // content and the parent then emitted nothing.
1089    let mut stack: Vec<Frame> = Vec::new();
1090    // Containers past MAX_NESTING are not pushed; these count them so the
1091    // matching End events unwind symmetrically and the stack stays balanced.
1092    let mut suppressed = 0usize;
1093    let mut item_suppressed = 0usize;
1094    // (language, accumulated source) while inside a code block.
1095    let mut code: Option<(String, String)> = None;
1096    // The destination URL while inside a link.
1097    let mut link: Option<String> = None;
1098    // Inside an autolink (`<http://โ€ฆ>`, `<user@host>`), whose text is shown
1099    // normalised.
1100    let mut autolink = false;
1101    // The label of the open link, when hyperlinks are off. Upstream pushes a
1102    // `Link` **element** at `link_close`-time rather than a style, so every
1103    // token in between is captured by it instead of by the paragraph, and only
1104    // `element.text.plain` is re-emitted at the close. That is why the label's
1105    // own emphasis is lost: `[**bold** label](u)` prints an unbolded
1106    // `bold label`. `None` whenever hyperlinks are on, where the label is
1107    // styled in place and this buffer must stay out of the way.
1108    let mut link_label: Option<String> = None;
1109    // Destination of the image being parsed, and the source span of its alt.
1110    let mut image: Option<String> = None;
1111    let mut image_span: Option<(usize, usize)> = None;
1112    // Upstream's `new_line` flag: set by every element that closes, cleared by
1113    // an image (`ImageItem.new_line = False`) and by a rule. Only images read
1114    // it, and it is why one lifted out of the *second* list item gets a blank
1115    // row above it while one lifted out of the first does not.
1116    let mut new_line = false;
1117    // The table being assembled while inside a GFM table.
1118    let mut table: Option<TableAccum> = None;
1119
1120    // Strikethrough is *not* enabled in pulldown-cmark: it pairs tilde runs by
1121    // GFM rules (equal-length runs, single tildes allowed), while upstream's
1122    // markdown-it splits runs into `~~` delimiters and pairs those. The
1123    // tildes arrive as literal text and `pair_strikethrough` reproduces
1124    // markdown-it's pairing, emitting ordinary `Strikethrough` events whose
1125    // range is the `~~` delimiter.
1126    let options = Options::ENABLE_TABLES;
1127    let events = Parser::new_ext(source, options).into_offset_iter();
1128    let events = reject_invalid_tables(source, events);
1129    let events = reject_invalid_links(source, events.into_iter());
1130    for (event, range) in pair_strikethrough(source, events.into_iter()) {
1131        // Everything between an image's brackets is its alt text, and upstream
1132        // takes that from the *raw* markdown (`token.content`) rather than from
1133        // parsed inline events: `![alt *em*](u)` shows `alt *em*`, asterisks and
1134        // all. Widening the source span is the only way back to the literal
1135        // text once pulldown-cmark has turned the markers into events.
1136        if image.is_some() && !matches!(event, Event::End(TagEnd::Image)) {
1137            image_span = Some(match image_span {
1138                Some((start, end)) => (start.min(range.start), end.max(range.end)),
1139                None => (range.start, range.end),
1140            });
1141            continue;
1142        }
1143        // Upstream's `new_line = element.new_line` bookkeeping, which runs for
1144        // every element that closes. Everything declares `new_line = True`
1145        // except an image and a rule. Images and closing quotes read the
1146        // preceding value before their own closing event changes it.
1147        let preceding_new_line = new_line;
1148        match &event {
1149            Event::End(
1150                TagEnd::Paragraph
1151                | TagEnd::Heading(_)
1152                | TagEnd::List(_)
1153                | TagEnd::Item
1154                | TagEnd::BlockQuote(_)
1155                | TagEnd::CodeBlock
1156                | TagEnd::Table
1157                | TagEnd::TableHead
1158                | TagEnd::TableRow
1159                | TagEnd::TableCell
1160                | TagEnd::HtmlBlock,
1161            ) => new_line = true,
1162            Event::Rule => new_line = false,
1163            _ => {}
1164        }
1165        match event {
1166            Event::End(TagEnd::HtmlBlock) => {
1167                sink(&mut blocks, &mut stack).push(Block::Html);
1168            }
1169            Event::Rule => {
1170                flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1171                sink(&mut blocks, &mut stack).push(Block::Rule);
1172            }
1173            Event::Start(Tag::Link {
1174                link_type,
1175                dest_url,
1176                ..
1177            }) => {
1178                // An email autolink (`<user@example.org>`) carries a `mailto:`
1179                // destination in CommonMark, but pulldown-cmark leaves the
1180                // scheme to the renderer and hands us the bare address. Adding
1181                // it is what makes the destination a usable URL โ€” upstream's
1182                // markdown-it puts it in the `href` itself.
1183                //
1184                // Every destination then goes through markdown-it's
1185                // `normalizeLink` (percent-encoding, punycoded host), as
1186                // upstream's does before rich ever sees it; pulldown-cmark
1187                // passes it through raw, control characters included.
1188                link = Some(normalize_link(&match link_type {
1189                    LinkType::Email => format!("mailto:{dest_url}"),
1190                    _ => dest_url.to_string(),
1191                }));
1192                // An autolink's text is its destination, which markdown-it
1193                // shows through `normalizeLinkText` instead.
1194                autolink = matches!(link_type, LinkType::Autolink | LinkType::Email);
1195                if !hyperlinks {
1196                    link_label = Some(String::new());
1197                }
1198            }
1199            Event::End(TagEnd::Link) => {
1200                autolink = false;
1201                let url = link.take();
1202                let label = link_label.take();
1203                // `hyperlinks=False`: upstream flushes the buffered label under
1204                // `markdown.link` and then writes the destination out after it โ€”
1205                // `A link (https://example.com) here.`
1206                //
1207                // Emitting nothing here (our only behaviour before) loses the
1208                // URL outright the moment the console has no colour system, and
1209                // a pipe has no OSC 8 escape to recover it from. `rich -m`
1210                // passes `hyperlinks=False`, so that was every URL in every
1211                // redirected render.
1212                if let Some(url) = url.filter(|_| !hyperlinks) {
1213                    let label = label.unwrap_or_default();
1214                    let inline = inline_style(strong, emphasis, strike);
1215                    // In a table cell the URL is part of the cell's text, so it
1216                    // counts towards the column width, as upstream measures it.
1217                    let block = inline_target(&mut current, &mut table);
1218                    let layer = |style: Option<Style>| {
1219                        stack_style(
1220                            quote_root(md, &stack).as_ref(),
1221                            heading_style.as_ref(),
1222                            inline.clone(),
1223                            None,
1224                            style,
1225                        )
1226                    };
1227                    // An empty label appends a zero-length span upstream,
1228                    // which renders as nothing at all.
1229                    if !label.is_empty() {
1230                        block.append(&label, layer(Style::parse(LINK_STYLE).ok()).map(Into::into));
1231                    }
1232                    block.append(" (", layer(None).map(Into::into));
1233                    block.append(
1234                        &url,
1235                        layer(Style::parse(LINK_URL_STYLE).ok()).map(Into::into),
1236                    );
1237                    block.append(")", layer(None).map(Into::into));
1238                }
1239            }
1240            // Images are emitted immediately rather than appended to their
1241            // parent element. `TableDataElement` uses that same base
1242            // `on_child_close`, so an image in a cell is hoisted above the
1243            // eventual table and contributes no text to the cell.
1244            Event::Start(Tag::Image { dest_url, .. }) => {
1245                image = Some(normalize_link(&dest_url));
1246                image_span = None;
1247            }
1248            Event::End(TagEnd::Image) => {
1249                if let Some(destination) = image.take() {
1250                    let alt = image_span
1251                        .take()
1252                        .map(|(start, end)| Text::new(&source[start..end]))
1253                        .unwrap_or_default();
1254                    // Pushed to the *document*, not to `sink`: upstream renders
1255                    // the image element the moment its token is reached, while
1256                    // the list or quote containing it is still open and will not
1257                    // render until it closes. An image inside a list therefore
1258                    // appears above the whole list, not inside the item.
1259                    //
1260                    // `joins_next` is only true at the top level: upstream emits
1261                    // no line break after an image, but a container closing
1262                    // after it (its paragraph having been captured) emits one of
1263                    // its own, so only a top-level paragraph or heading really
1264                    // continues on the marker's row.
1265                    blocks.push(Block::Image {
1266                        text: image_text(
1267                            &destination,
1268                            alt,
1269                            link.as_deref(),
1270                            stack_style(
1271                                quote_root(md, &stack).as_ref(),
1272                                heading_style.as_ref(),
1273                                inline_style(strong, emphasis, strike),
1274                                link.as_deref().filter(|_| hyperlinks),
1275                                None,
1276                            ),
1277                            hyperlinks,
1278                        ),
1279                        // A table is a container too, even though it uses a
1280                        // dedicated accumulator rather than a `Frame`. Its own
1281                        // render begins after the hoisted image's open row.
1282                        joins_next: stack.is_empty() && table.is_none(),
1283                        leading_break: new_line,
1284                    });
1285                    new_line = false;
1286                }
1287            }
1288            Event::Start(Tag::CodeBlock(kind)) => {
1289                flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1290                let language = match kind {
1291                    CodeBlockKind::Fenced(info) => {
1292                        // The info string is `lang` (possibly with extra tokens).
1293                        info.split_whitespace().next().unwrap_or("").to_string()
1294                    }
1295                    CodeBlockKind::Indented => String::new(),
1296                };
1297                code = Some((language, String::new()));
1298            }
1299            Event::End(TagEnd::CodeBlock) => {
1300                if let Some((language, mut source)) = code.take() {
1301                    // Drop the single trailing newline the parser appends.
1302                    if source.ends_with('\n') {
1303                        source.pop();
1304                    }
1305                    sink(&mut blocks, &mut stack).push(Block::Code {
1306                        language,
1307                        code: source,
1308                        theme: md.code_theme.clone(),
1309                        highlighter: md.highlighter.clone(),
1310                        fences: md.fences.clone(),
1311                    });
1312                }
1313            }
1314            Event::Start(Tag::Table(aligns)) => {
1315                flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1316                table = Some(TableAccum {
1317                    alignments: aligns.into_iter().map(alignment_justify).collect(),
1318                    ..TableAccum::default()
1319                });
1320            }
1321            Event::End(TagEnd::Table) => {
1322                if let Some(acc) = table.take() {
1323                    sink(&mut blocks, &mut stack).push(Block::Table {
1324                        alignments: acc.alignments,
1325                        headers: acc.headers,
1326                        rows: acc.rows,
1327                    });
1328                }
1329            }
1330            Event::Start(Tag::TableHead) => {
1331                if let Some(acc) = table.as_mut() {
1332                    acc.in_head = true;
1333                    acc.cur_row = Vec::new();
1334                }
1335            }
1336            Event::End(TagEnd::TableHead) => {
1337                if let Some(acc) = table.as_mut() {
1338                    acc.headers = std::mem::take(&mut acc.cur_row);
1339                    acc.in_head = false;
1340                }
1341            }
1342            Event::Start(Tag::TableRow) => {
1343                if let Some(acc) = table.as_mut() {
1344                    acc.cur_row = Vec::new();
1345                }
1346            }
1347            Event::End(TagEnd::TableRow) => {
1348                if let Some(acc) = table.as_mut() {
1349                    let row = std::mem::take(&mut acc.cur_row);
1350                    acc.rows.push(row);
1351                }
1352            }
1353            Event::Start(Tag::TableCell) => {
1354                if let Some(acc) = table.as_mut() {
1355                    acc.in_cell = true;
1356                    acc.cur_cell = Text::new("");
1357                }
1358            }
1359            Event::End(TagEnd::TableCell) => {
1360                if let Some(acc) = table.as_mut() {
1361                    let cell = std::mem::take(&mut acc.cur_cell);
1362                    acc.cur_row.push(cell);
1363                    acc.in_cell = false;
1364                }
1365            }
1366            Event::Start(Tag::BlockQuote(_)) => {
1367                flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1368                if stack.len() >= MAX_NESTING {
1369                    suppressed += 1;
1370                } else {
1371                    stack.push(Frame::Quote { blocks: Vec::new() });
1372                }
1373            }
1374            Event::End(TagEnd::BlockQuote(_)) => {
1375                if suppressed > 0 {
1376                    suppressed -= 1;
1377                } else if let Some(Frame::Quote { blocks: quoted }) = stack.pop() {
1378                    sink(&mut blocks, &mut stack).push(Block::Quote {
1379                        blocks: quoted,
1380                        leading_break: preceding_new_line,
1381                    });
1382                }
1383            }
1384            Event::Start(Tag::List(first)) => {
1385                flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1386                if stack.len() >= MAX_NESTING {
1387                    suppressed += 1;
1388                } else {
1389                    stack.push(Frame::List {
1390                        ordered: first.is_some(),
1391                        start: first.unwrap_or(1),
1392                        entries: Vec::new(),
1393                    });
1394                }
1395            }
1396            Event::End(TagEnd::List(_)) => {
1397                if suppressed > 0 {
1398                    suppressed -= 1;
1399                } else if let Some(Frame::List { entries, .. }) = stack.pop() {
1400                    sink(&mut blocks, &mut stack).push(Block::List { items: entries });
1401                }
1402            }
1403            Event::Start(Tag::Item) => {
1404                if stack.len() >= MAX_NESTING {
1405                    item_suppressed += 1;
1406                } else {
1407                    stack.push(Frame::Item { blocks: Vec::new() });
1408                }
1409                // A *tight* list emits its item text as bare `Text` events with
1410                // no enclosing Paragraph, so open a buffer here for it to land
1411                // in. A loose item simply resets this at its Start(Paragraph).
1412                current = Some(Text::new(""));
1413                heading_style = None;
1414                heading_open = None;
1415                justify = paragraph_justify;
1416            }
1417            Event::End(TagEnd::Item) => {
1418                // A *tight* list emits its item text without a Paragraph, so
1419                // anything still pending belongs to this item. markdown-it still
1420                // emits a (hidden) paragraph for it, so upstream justifies it as
1421                // a paragraph.
1422                if let Some(mut text) = current.take() {
1423                    text.set_justify(paragraph_justify);
1424                    sink(&mut blocks, &mut stack).push(Block::Text(text, None));
1425                }
1426                if item_suppressed > 0 {
1427                    item_suppressed -= 1;
1428                } else if let Some(Frame::Item {
1429                    blocks: item_blocks,
1430                }) = stack.pop()
1431                {
1432                    if let Some(Frame::List {
1433                        ordered,
1434                        start,
1435                        entries,
1436                    }) = stack.last_mut()
1437                    {
1438                        let number = ordered.then(|| *start + entries.len() as u64);
1439                        entries.push(ListEntry {
1440                            number,
1441                            blocks: item_blocks,
1442                        });
1443                    }
1444                }
1445            }
1446            Event::Start(Tag::Paragraph) => {
1447                flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1448                current = Some(Text::new(""));
1449                heading_style = None;
1450                heading_open = None;
1451                // `Paragraph.create`: `markdown.justify or "left"`.
1452                justify = paragraph_justify;
1453            }
1454            Event::Start(Tag::Heading { level, .. }) => {
1455                flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1456                let (style, heading_justify) = heading_format(heading_level(level));
1457                current = Some(Text::new(""));
1458                heading_style = Some(style);
1459                heading_open = Some(heading_level(level) as u8);
1460                justify = heading_justify;
1461            }
1462            Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Heading(_)) => {
1463                if let Some(mut text) = current.take() {
1464                    let in_quote = stack
1465                        .iter()
1466                        .rposition(|f| matches!(f, Frame::Item { .. } | Frame::Quote { .. }))
1467                        .is_some_and(|i| matches!(stack[i], Frame::Quote { .. }));
1468                    if in_quote {
1469                        // Quote paragraph: the quote style (over the document
1470                        // style) as its base, so its padding carries it too.
1471                        if let Some(root) = quote_root(md, &stack) {
1472                            text.set_base_style(root);
1473                        }
1474                    }
1475                    // A heading's style rides on each run (upstream pushes
1476                    // `markdown.h<n>` onto the style stack at `heading_open`, so
1477                    // every inline style composes *over* it), never as a base
1478                    // style โ€” a base style would paint the centring padding too,
1479                    // which upstream leaves unstyled. Only the alignment is left
1480                    // to apply here; treating a quoted heading as body text
1481                    // flattened h1 to plain magenta and left-aligned it.
1482                    text.set_justify(justify);
1483                    sink(&mut blocks, &mut stack).push(Block::Text(text, heading_open));
1484                }
1485                heading_style = None;
1486                heading_open = None;
1487                justify = Justify::Left;
1488                strong = 0;
1489                emphasis = 0;
1490            }
1491            Event::Start(Tag::Strong) => strong += 1,
1492            Event::End(TagEnd::Strong) => strong = strong.saturating_sub(1),
1493            Event::Start(Tag::Strikethrough) => {
1494                if source[range.clone()].starts_with("~~") {
1495                    strike += 1;
1496                } else {
1497                    // Single-tilde: not a delimiter upstream. Keep the literal
1498                    // text, tildes and all.
1499                    //
1500                    // Route it the same way as any other text: inside a link
1501                    // label the surrounding characters are buffered separately,
1502                    // so appending straight to `current` put BOTH tildes in
1503                    // front of the label โ€” `[~a~ label]` came out as
1504                    // `~~a label`, characters reordered rather than restyled.
1505                    single_tilde += 1;
1506                    push_tilde(&mut current, &mut table, &mut link_label);
1507                }
1508            }
1509            Event::End(TagEnd::Strikethrough) => {
1510                if single_tilde > 0 {
1511                    single_tilde -= 1;
1512                    push_tilde(&mut current, &mut table, &mut link_label);
1513                } else {
1514                    strike = strike.saturating_sub(1);
1515                }
1516            }
1517            Event::Start(Tag::Emphasis) => emphasis += 1,
1518            Event::End(TagEnd::Emphasis) => emphasis = emphasis.saturating_sub(1),
1519            Event::Text(text) => {
1520                let text = if autolink {
1521                    CowStr::from(normalize_link_text(&text))
1522                } else {
1523                    text
1524                };
1525                if let Some(label) = link_label.as_mut() {
1526                    label.push_str(&text);
1527                } else if let Some((_, source)) = code.as_mut() {
1528                    source.push_str(&text);
1529                } else {
1530                    // A table cell appends under the current style, exactly as
1531                    // a paragraph does (`TableDataElement.on_text`). Otherwise
1532                    // open a buffer if none is active: in a tight list item the
1533                    // text after a nested block arrives bare, with the previous
1534                    // buffer already flushed by that block's start.
1535                    let block = inline_target(&mut current, &mut table);
1536                    let style = stack_style(
1537                        quote_root(md, &stack).as_ref(),
1538                        heading_style.as_ref(),
1539                        inline_style(strong, emphasis, strike),
1540                        link.as_deref().filter(|_| hyperlinks),
1541                        None,
1542                    );
1543                    block.append(&text, style.map(Into::into));
1544                }
1545            }
1546            Event::Code(text) => {
1547                if let Some(label) = link_label.as_mut() {
1548                    label.push_str(&text);
1549                } else {
1550                    // A table cell or the open buffer, as for plain text.
1551                    let block = inline_target(&mut current, &mut table);
1552                    // `markdown.code` is pushed on TOP of the link, so a link
1553                    // whose whole label is inline code โ€” ``[`rich`](url)`` โ€”
1554                    // keeps its destination. Applying the code style alone
1555                    // discarded it.
1556                    if let Some(lexer) = &md.inline_code_lexer {
1557                        // `MarkdownContext.on_text` for `code_inline` with a
1558                        // lexer: the highlighted text, right-stripped, assembled
1559                        // under the current style (no `markdown.code` layer).
1560                        let theme = md.inline_code_theme.as_ref().or(md.code_theme.as_ref());
1561                        let mut syntax = Syntax::new(text.to_string(), lexer.as_str());
1562                        if let Some(theme) = theme {
1563                            syntax = syntax.theme(theme.as_str());
1564                        }
1565                        if let Some(highlighter) = &md.highlighter {
1566                            syntax = syntax.highlighter(highlighter.clone());
1567                        }
1568                        let mut highlighted = syntax.highlight();
1569                        highlighted.rstrip();
1570                        let style = stack_style(
1571                            quote_root(md, &stack).as_ref(),
1572                            heading_style.as_ref(),
1573                            inline_style(strong, emphasis, strike),
1574                            link.as_deref().filter(|_| hyperlinks),
1575                            None,
1576                        );
1577                        let mut fragment = Text::new("");
1578                        if let Some(style) = style {
1579                            fragment.set_base_style(style);
1580                        }
1581                        let fragment = fragment.append_text(&highlighted);
1582                        *block = std::mem::take(block).append_text(&fragment);
1583                        continue;
1584                    }
1585                    let style = stack_style(
1586                        quote_root(md, &stack).as_ref(),
1587                        heading_style.as_ref(),
1588                        inline_style(strong, emphasis, strike),
1589                        link.as_deref().filter(|_| hyperlinks),
1590                        Style::parse(CODE_STYLE).ok(),
1591                    );
1592                    block.append(&text, style.map(Into::into));
1593                }
1594            }
1595            // `softbreak`/`hardbreak` go through `context.on_text`, so they land
1596            // in the open link label if there is one, and otherwise carry
1597            // whatever styles are open just like any other run.
1598            Event::SoftBreak => append_break(
1599                current.as_mut(),
1600                link_label.as_mut(),
1601                " ",
1602                stack_style(
1603                    quote_root(md, &stack).as_ref(),
1604                    heading_style.as_ref(),
1605                    inline_style(strong, emphasis, strike),
1606                    link.as_deref().filter(|_| hyperlinks),
1607                    None,
1608                ),
1609            ),
1610            Event::HardBreak => append_break(
1611                current.as_mut(),
1612                link_label.as_mut(),
1613                "\n",
1614                stack_style(
1615                    quote_root(md, &stack).as_ref(),
1616                    heading_style.as_ref(),
1617                    inline_style(strong, emphasis, strike),
1618                    link.as_deref().filter(|_| hyperlinks),
1619                    None,
1620                ),
1621            ),
1622            _ => {}
1623        }
1624    }
1625    blocks
1626}
1627
1628impl Renderable for Markdown {
1629    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
1630        // Inline code is highlighted while parsing, before any console is
1631        // known. With a console-wide default highlighter and none of our own,
1632        // parse again with it so inline code and code blocks share one engine
1633        // and theme.
1634        use crate::protocol::ConsoleCodeHighlighting;
1635        let reparsed;
1636        let blocks = match (&self.options.highlighter, console.code_highlighting()) {
1637            (None, Some(default)) if self.options.inline_code_lexer.is_some() => {
1638                let mut with_default = self.options.clone();
1639                with_default.highlighter = Some(default.highlighter.clone());
1640                if with_default.code_theme.is_none() {
1641                    with_default.code_theme = default.theme.clone();
1642                }
1643                reparsed = parse(&self.source, &with_default);
1644                &reparsed
1645            }
1646            _ => &self.blocks,
1647        };
1648        let mut lines = render_blocks(
1649            blocks,
1650            console,
1651            options,
1652            options.max_width,
1653            true,
1654            self.options.style.as_ref(),
1655        );
1656
1657        // Upstream's thematic-break element emits a trailing line break, which is
1658        // only observable when the rule is the document's last block: it adds one
1659        // extra blank line there (a mid-document rule merges with the normal block
1660        // separator). Match that.
1661        if matches!(blocks.last(), Some(Block::Rule)) {
1662            lines.push(Vec::new());
1663        }
1664
1665        let mut segments = Vec::new();
1666        let last = lines.len().saturating_sub(1);
1667        for (index, line) in lines.into_iter().enumerate() {
1668            segments.extend(line);
1669            if index != last {
1670                segments.push(Segment::line());
1671            }
1672        }
1673        segments
1674    }
1675}
1676
1677/// Pad every row out to `width`, as upstream's `console.render_lines` does โ€”
1678/// `pad=True` is its default, and both the list-item and block-quote handlers
1679/// rely on it.
1680///
1681/// Without this a child rendered in a narrower box hands back short rows and
1682/// every enclosing level inherits the shortfall, so nesting lost two cells per
1683/// level: quotes measured 68, 66, 64, 62 at depths 1โ€“4 where upstream holds a
1684/// flat 68.
1685fn pad_lines(lines: &mut [Vec<Segment>], width: usize) {
1686    for line in lines.iter_mut() {
1687        let len: usize = line.iter().map(Segment::cell_length).sum();
1688        if len < width {
1689            line.push(Segment::new(" ".repeat(width - len), None));
1690        }
1691    }
1692}
1693
1694/// Render a run of blocks into rows of segments at `width`.
1695///
1696/// Recursive, because a list item and a quote are containers: whatever they
1697/// hold is rendered by this same function at a reduced width and then prefixed.
1698/// Render a block's `Text` as `Text.__rich_console__` would: its own justify,
1699/// overflow and no-wrap win, then the inherited options', then the defaults.
1700/// A paragraph in a narrow table cell therefore truncates with the cell's
1701/// `ellipsis` rather than folding.
1702fn render_text(
1703    text: &Text,
1704    console: &Console,
1705    options: &ConsoleOptions,
1706    width: usize,
1707) -> Vec<Vec<Segment>> {
1708    let justify = text.get_justify_option().unwrap_or(options.justify);
1709    text.render_lines_wrapped_tabs(
1710        console.theme(),
1711        console.base_style(),
1712        Some(width),
1713        justify,
1714        text.get_overflow()
1715            .or(options.overflow)
1716            .unwrap_or(Overflow::Fold),
1717        text.get_no_wrap().or(options.no_wrap).unwrap_or(false),
1718        text.console_tab_size(console),
1719    )
1720}
1721
1722fn render_blocks(
1723    blocks: &[Block],
1724    console: &Console,
1725    options: &ConsoleOptions,
1726    width: usize,
1727    top_level: bool,
1728    root: Option<&Style>,
1729) -> Vec<Vec<Segment>> {
1730    let mut lines: Vec<Vec<Segment>> = Vec::new();
1731    // Set by an image whose marker must stay on the same row as the block that
1732    // follows it (see [`Block::Image`]).
1733    let mut join_previous = false;
1734
1735    for (index, block) in blocks.iter().enumerate() {
1736        let mut merge = std::mem::take(&mut join_previous);
1737        // Consecutive images share their open row even when hoisted from a
1738        // container. A closed cell/item sets leading_break and ends that row.
1739        if matches!(
1740            block,
1741            Block::Image {
1742                leading_break: false,
1743                ..
1744            }
1745        ) && index > 0
1746            && matches!(blocks[index - 1], Block::Image { .. })
1747        {
1748            merge = true;
1749        }
1750        // `new_line` before an image is a single line break, not the blank-row
1751        // separator used between ordinary blocks. In particular, images
1752        // hoisted from consecutive table rows must occupy consecutive output
1753        // rows. It also cancels the preceding image's open-row join.
1754        if matches!(
1755            block,
1756            Block::Image {
1757                leading_break: true,
1758                ..
1759            }
1760        ) {
1761            merge = false;
1762        }
1763        // A blank line precedes every non-first block, and every
1764        // list/quote/table (which upstream renders with a leading gap).
1765        // Blank lines between blocks are a *document* convention. Upstream puts
1766        // none inside a list item or a quote โ€” neither before a nested list nor
1767        // between two paragraphs of one item โ€” so applying the rule there added
1768        // a stray row per block, and one per level of nesting.
1769        // A rule brings its own trailing blank, so the usual gap after it would
1770        // double up (upstream sets `HorizontalRule.new_line = False` for exactly
1771        // this reason).
1772        let after_rule = index > 0 && matches!(blocks[index - 1], Block::Rule);
1773        // A list, quote or table carries its own leading gap, which survives even
1774        // after a rule; only the generic inter-block separator is suppressed.
1775        let own_gap = matches!(block, Block::List { .. } | Block::Table { .. });
1776        // An image emits no line break after itself, so the block that follows
1777        // one gets no separator at all โ€” not even the leading gap a list, quote
1778        // or table would otherwise bring.
1779        let after_image = index > 0 && matches!(blocks[index - 1], Block::Image { .. });
1780        let separator = match block {
1781            Block::Quote { leading_break, .. } => top_level && *leading_break && !after_image,
1782            // After an ordinary element this is the usual blank-row gap;
1783            // after an image (whose text has `end=""`) it is only a line break,
1784            // represented above by declining to merge the two image rows.
1785            Block::Image { leading_break, .. } => top_level && *leading_break && !after_image,
1786            _ if after_image => false,
1787            _ => top_level && (own_gap || (index > 0 && !after_rule)),
1788        };
1789        if separator {
1790            lines.push(Vec::new());
1791        }
1792        let start = lines.len();
1793        match block {
1794            Block::Text(text, None) => lines.extend(render_text(text, console, options, width)),
1795            Block::Text(text, Some(level)) => {
1796                // Not upstream: a heading's semantic region, only when a sink
1797                // is installed.
1798                let region = crate::protocol::enter_region(console, || {
1799                    crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Heading {
1800                        level: *level,
1801                    })
1802                    .label(text.plain())
1803                });
1804                let mut rendered = render_text(text, console, options, width);
1805                if let Some(region) = region {
1806                    for line in rendered.iter_mut() {
1807                        region.tag(line);
1808                    }
1809                }
1810                lines.extend(rendered);
1811            }
1812            Block::Image {
1813                text, joins_next, ..
1814            } => {
1815                // No justify of its own โ€” upstream assembles a bare `Text` for
1816                // it โ€” so the marker takes the options' justify.
1817                lines.extend(render_text(text, console, options, width));
1818                join_previous = *joins_next;
1819            }
1820            Block::List { items } => {
1821                for item in items {
1822                    let (prefix, prefix_style) = match item.number {
1823                        Some(number) => (
1824                            format!(" {number} "),
1825                            Style::parse("cyan").expect("valid style"),
1826                        ),
1827                        None => (
1828                            BULLET.to_string(),
1829                            Style::parse("bold").expect("valid style"),
1830                        ),
1831                    };
1832                    let prefix_width = cell_len(&prefix);
1833                    // The item's own blocks, rendered in the space left beside
1834                    // its marker. A nested list is just one of those blocks, so
1835                    // indentation compounds naturally.
1836                    let item_lines = render_blocks(
1837                        &item.blocks,
1838                        console,
1839                        options,
1840                        width.saturating_sub(prefix_width),
1841                        false,
1842                        root,
1843                    );
1844                    // A leading blank row would push the marker off its content.
1845                    let mut item_lines: Vec<Vec<Segment>> = item_lines
1846                        .into_iter()
1847                        .skip_while(|line| line.is_empty())
1848                        .collect();
1849                    pad_lines(&mut item_lines, width.saturating_sub(prefix_width));
1850                    for (line_index, line) in item_lines.into_iter().enumerate() {
1851                        let mut row = Vec::new();
1852                        // `render_bullet`/`render_number`: continuation rows are
1853                        // padded in the marker's own style.
1854                        if line_index == 0 {
1855                            row.push(Segment::new(prefix.clone(), Some(prefix_style.clone())));
1856                        } else {
1857                            row.push(Segment::new(
1858                                " ".repeat(prefix_width),
1859                                Some(prefix_style.clone()),
1860                            ));
1861                        }
1862                        // `render_lines(self.elements, โ€ฆ, style=self.style)`: the
1863                        // item style (the document style under `markdown.item`)
1864                        // sits under its content and padding.
1865                        match root {
1866                            Some(root) => row.extend(Segment::apply_style(&line, root)),
1867                            None => row.extend(line),
1868                        }
1869                        lines.push(row);
1870                    }
1871                }
1872            }
1873            Block::Html => {}
1874            Block::Quote { blocks: quoted, .. } => {
1875                // `context.enter_style("markdown.block_quote")`: the quote style
1876                // over the enclosing style.
1877                let quote = Style::parse(QUOTE_STYLE).expect("valid style");
1878                let prefix_style = match root {
1879                    Some(root) => root.combine(&quote),
1880                    None => quote,
1881                };
1882                // Upstream renders quote content at `max_width - 4`.
1883                let content_width = width.saturating_sub(4);
1884                let quoted_lines = render_blocks(
1885                    quoted,
1886                    console,
1887                    options,
1888                    content_width,
1889                    false,
1890                    Some(&prefix_style),
1891                );
1892                let mut quoted_lines: Vec<Vec<Segment>> = quoted_lines
1893                    .into_iter()
1894                    .skip_while(|line| line.is_empty())
1895                    .collect();
1896                pad_lines(&mut quoted_lines, content_width);
1897                for line in quoted_lines {
1898                    let mut row = vec![Segment::new(
1899                        QUOTE_PREFIX.to_string(),
1900                        Some(prefix_style.clone()),
1901                    )];
1902                    // Upstream passes `style=self.style` to `render_lines`, so
1903                    // the quote colour reaches *every* child โ€” including a list
1904                    // or table, which set their own styles and so previously
1905                    // rendered inside a quote with no magenta at all.
1906                    row.extend(Segment::apply_style(&line, &prefix_style));
1907                    lines.push(row);
1908                }
1909            }
1910            Block::Code {
1911                language,
1912                code,
1913                theme,
1914                highlighter,
1915                fences,
1916            } => {
1917                let inner = options.update_width(width);
1918                // An indented block has no language, so it never reaches a
1919                // fence renderer.
1920                // Not upstream: the code block's semantic region, only when a
1921                // sink is installed.
1922                let region = crate::protocol::enter_region(console, || {
1923                    crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Code)
1924                        .label(language)
1925                });
1926                let drawn = if language.is_empty() {
1927                    None
1928                } else {
1929                    fences
1930                        .iter()
1931                        .find_map(|fence| fence.render_fence(language, code, console, &inner))
1932                };
1933                let segments = match drawn {
1934                    Some(segments) => segments,
1935                    None => {
1936                        // Render the code block via the Syntax renderable (functional,
1937                        // not byte-parity โ€” see DIVERGENCES). Split its segment stream
1938                        // back into per-line rows for the shared join below.
1939                        // Upstream: `Syntax(code, lexer, theme=..., word_wrap=True, padding=1)`.
1940                        // Without word_wrap a long line was cropped dead at the console
1941                        // width and its tail discarded entirely โ€” a README's install
1942                        // command lost half its flags, with no marker that anything went.
1943                        let mut syntax = Syntax::new(code.as_str(), language.as_str())
1944                            .word_wrap(true)
1945                            .padding(1);
1946                        if let Some(theme) = theme {
1947                            syntax = syntax.theme(theme.as_str());
1948                        }
1949                        if let Some(highlighter) = highlighter {
1950                            syntax = syntax.highlighter(highlighter.clone());
1951                        }
1952                        syntax.rich_render(console, &inner)
1953                    }
1954                };
1955                let mut segments = segments;
1956                if let Some(region) = region {
1957                    region.tag(&mut segments);
1958                }
1959                lines.extend(Segment::split_lines(&segments));
1960            }
1961            Block::Rule => {
1962                let style = Style::parse("dim").expect("valid style");
1963                lines.push(vec![Segment::new("-".repeat(width), Some(style))]);
1964                // Upstream's rule carries a trailing blank row of its own, in
1965                // place of the usual inter-block gap (`HorizontalRule.new_line
1966                // = False`). Inside a quote that row picks up the quote prefix,
1967                // which is why upstream shows a bare `โ–Œ` line under a quoted
1968                // rule and we showed none.
1969                //
1970                // At the very end of a document the trailing break already
1971                // arrives from the join below โ€” the `markdown_hr_end` golden
1972                // pins it โ€” so adding one here would double it.
1973                if index + 1 < blocks.len() || !top_level {
1974                    lines.push(Vec::new());
1975                }
1976            }
1977            Block::Table {
1978                alignments,
1979                headers,
1980                rows,
1981            } => {
1982                // Build the Table exactly as upstream's TableElement does:
1983                // box=SIMPLE, pad_edge=False, collapse_padding=True, and the
1984                // markdown.table.border/header styles. Per-column justify comes
1985                // from the alignment row.
1986                let mut table = Table::new()
1987                    .box_set(SIMPLE)
1988                    .pad_edge(false)
1989                    .collapse_padding(true)
1990                    .style(Style::parse(TABLE_BORDER_STYLE).expect("valid style"));
1991                let header_style = Style::parse(TABLE_HEADER_STYLE).expect("valid style");
1992                for (col, header) in headers.iter().enumerate() {
1993                    let justify = alignments.get(col).copied().unwrap_or(Justify::Left);
1994                    // `heading.stylize("markdown.table.header")`: a span over the
1995                    // header's own inline spans, applied at render.
1996                    table.add_column_text(header.clone(), justify);
1997                    table.column_header_style(header_style.clone());
1998                }
1999                for row in rows {
2000                    table.add_row_text(row.clone());
2001                }
2002                let inner = options.update_width(width);
2003                lines.extend(Segment::split_lines(&table.rich_render(console, &inner)));
2004            }
2005        }
2006        // Fold this block's first row onto the row the image left open. `merge`
2007        // is only ever set by a preceding image, which always pushed at least
2008        // one row, so `start` is never zero here.
2009        if merge && lines.len() > start {
2010            let first = lines.remove(start);
2011            lines[start - 1].extend(first);
2012        }
2013    }
2014    lines
2015}
2016
2017#[cfg(test)]
2018mod tests {
2019    use super::*;
2020    use crate::color::ColorSystem;
2021
2022    fn render(source: &str) -> String {
2023        let console = Console::builder()
2024            .force_terminal(true)
2025            .color_system(Some(ColorSystem::Truecolor))
2026            .width(20)
2027            .build();
2028        console.render_to_string(&Markdown::new(source))
2029    }
2030
2031    fn render_with(markdown: &Markdown) -> String {
2032        let console = Console::builder()
2033            .force_terminal(true)
2034            .color_system(Some(ColorSystem::Truecolor))
2035            .width(30)
2036            .build();
2037        console.render_to_string(markdown)
2038    }
2039
2040    #[test]
2041    fn code_theme_changes_the_code_block_colours() {
2042        let source = "```rust\nfn main() {}\n```";
2043        let default = render_with(&Markdown::new(source));
2044        let themed = render_with(&Markdown::new(source).code_theme("InspiredGitHub"));
2045        assert_ne!(default, themed);
2046        // An unknown theme falls back to the default, as `Syntax::theme` does.
2047        assert_eq!(
2048            default,
2049            render_with(&Markdown::new(source).code_theme("no-such-theme"))
2050        );
2051    }
2052
2053    /// A fence renderer draws the fences it accepts, including ones nested in a
2054    /// list or quote; it declines others (and indented code never reaches it),
2055    /// which then render exactly as without it.
2056    #[test]
2057    fn fence_renderers_draw_accepted_fences_and_decline_the_rest() {
2058        use crate::protocol::FenceRenderer;
2059        struct Boxed;
2060        impl FenceRenderer for Boxed {
2061            fn render_fence(
2062                &self,
2063                language: &str,
2064                code: &str,
2065                _console: &Console,
2066                options: &ConsoleOptions,
2067            ) -> Option<Vec<Segment>> {
2068                (language == "shout").then(|| {
2069                    let line = format!("<{}>", code.to_uppercase());
2070                    assert!(options.max_width >= line.len());
2071                    vec![Segment::new(line, None), Segment::line()]
2072                })
2073            }
2074        }
2075        let fences = Arc::new(Boxed);
2076        let source = "# T\n\n```shout\nhi\n```\n\n- item\n\n  ```shout\n  nested\n  ```\n\n```rust\nfn x() {}\n```\n\n    indented\n";
2077        let plain = render_with(&Markdown::new(source));
2078        let drawn = render_with(&Markdown::new(source).fence_renderer(fences.clone()));
2079        assert!(
2080            drawn.contains("<HI>") && drawn.contains("<NESTED>"),
2081            "{drawn}"
2082        );
2083        assert!(!plain.contains("<HI>"));
2084        // Declined fences and indented code are untouched.
2085        let rust = "```rust\nfn x() {}\n```\n\n    indented\n";
2086        assert_eq!(
2087            render_with(&Markdown::new(rust)),
2088            render_with(&Markdown::new(rust).fence_renderer(fences))
2089        );
2090    }
2091
2092    /// `Markdown::highlighter` reaches fenced code blocks and, with an inline
2093    /// lexer, inline code: here every highlighted byte is underlined.
2094    #[test]
2095    fn a_custom_highlighter_reaches_code_blocks_and_inline_code() {
2096        use crate::protocol::{
2097            CodeHighlighter, HighlightError, HighlightSpan, HighlightedCode, HighlightedLine,
2098        };
2099        struct Underline;
2100        impl CodeHighlighter for Underline {
2101            fn highlight(
2102                &self,
2103                code: &str,
2104                _language: Option<&str>,
2105                _theme: &str,
2106            ) -> Result<HighlightedCode, HighlightError> {
2107                let underline = Style::parse("underline").unwrap();
2108                let lines = code
2109                    .split('\n')
2110                    .map(|line| HighlightedLine {
2111                        spans: (!line.is_empty())
2112                            .then(|| HighlightSpan {
2113                                range: 0..line.len(),
2114                                style: underline.clone(),
2115                            })
2116                            .into_iter()
2117                            .collect(),
2118                        newline_style: None,
2119                    })
2120                    .collect();
2121                Ok(HighlightedCode {
2122                    lines,
2123                    ..Default::default()
2124                })
2125            }
2126            fn default_theme(&self) -> &str {
2127                "underline"
2128            }
2129            fn themes(&self) -> Vec<String> {
2130                vec!["underline".into()]
2131            }
2132            fn languages(&self) -> Vec<String> {
2133                Vec::new()
2134            }
2135        }
2136        let block = render_with(
2137            &Markdown::new("```rust\nfn main() {}\n```").highlighter(Arc::new(Underline)),
2138        );
2139        assert!(block.contains("\x1b[4mfn main() {}"), "{block:?}");
2140        let inline = render_with(
2141            &Markdown::new("Call `go()` now.")
2142                .inline_code_lexer("rust")
2143                .highlighter(Arc::new(Underline)),
2144        );
2145        assert!(inline.contains("\x1b[4mgo()"), "{inline:?}");
2146    }
2147
2148    #[test]
2149    fn inline_code_lexer_highlights_instead_of_the_code_style() {
2150        let source = "Call `fn main() {}` now.";
2151        let plain = render_with(&Markdown::new(source));
2152        // `markdown.code` (bold cyan on black) without a lexer.
2153        assert!(plain.contains("\x1b[1;36;40m"), "{plain:?}");
2154        let highlighted = render_with(&Markdown::new(source).inline_code_lexer("rust"));
2155        assert!(!highlighted.contains("\x1b[1;36;40m"), "{highlighted:?}");
2156        assert_ne!(plain, highlighted);
2157        let text = Console::builder().width(30).color_system(None).build();
2158        assert_eq!(
2159            text.render_to_string(&Markdown::new(source).inline_code_lexer("rust")),
2160            text.render_to_string(&Markdown::new(source)),
2161            "highlighting changes colours only, never the text"
2162        );
2163        // `inline_code_theme` defaults to `code_theme`, and overrides it.
2164        let by_code_theme = render_with(
2165            &Markdown::new(source)
2166                .inline_code_lexer("rust")
2167                .code_theme("InspiredGitHub"),
2168        );
2169        let by_inline_theme = render_with(
2170            &Markdown::new(source)
2171                .inline_code_lexer("rust")
2172                .inline_code_theme("InspiredGitHub"),
2173        );
2174        assert_ne!(highlighted, by_code_theme);
2175        assert_eq!(by_code_theme, by_inline_theme);
2176    }
2177
2178    #[test]
2179    fn a_code_only_list_item_keeps_the_bullet_on_its_padding_row() {
2180        let console = Console::builder().width(30).color_system(None).build();
2181        assert_eq!(console.render_export(&Markdown::new("- ```\n  code\n  ```")),
2182            "\n โ€ข                            \n    code                      \n                              \n");
2183    }
2184
2185    #[test]
2186    fn table_cell_images_share_a_row_until_the_cell_closes() {
2187        let console = Console::builder().width(30).color_system(None).build();
2188        let output = console.render_to_string(&Markdown::new(
2189            "| h |\n|---|\n| ![a](x) ![b](y) |\n| ![c](z) |",
2190        ));
2191        assert!(output.starts_with("\n๐ŸŒ† a ๐ŸŒ† b \n๐ŸŒ† c \n"), "{output:?}");
2192    }
2193
2194    #[test]
2195    fn quoted_rule_spacing_uses_the_last_closed_child() {
2196        let console = Console::builder().width(30).color_system(None).build();
2197        assert_eq!(
2198            console.render_to_string(&Markdown::new("> ---")),
2199            "โ–Œ --------------------------\nโ–Œ                           "
2200        );
2201        let output = console.render_to_string(&Markdown::new("> ---\n>\n> text"));
2202        assert!(
2203            output.starts_with("\nโ–Œ --------------------------\n"),
2204            "{output:?}"
2205        );
2206    }
2207
2208    #[test]
2209    fn ignored_html_blocks_keep_upstream_paragraph_spacing() {
2210        let console = Console::builder().width(30).color_system(None).build();
2211        for (source, expected) in [
2212            (
2213                "<div>hidden</div>\n\nParagraph",
2214                "\nParagraph                     ",
2215            ),
2216            ("<div>hidden</div>", ""),
2217            (
2218                "A\n\n<div>x</div>\n\nB",
2219                "A                             \n\n\nB                             ",
2220            ),
2221        ] {
2222            assert_eq!(console.render_to_string(&Markdown::new(source)), expected);
2223        }
2224    }
2225
2226    #[test]
2227    fn paragraph_inline_styles() {
2228        assert_eq!(
2229            render("a `x` b"),
2230            "a \x1b[1;36;40mx\x1b[0m b               "
2231        );
2232    }
2233
2234    #[test]
2235    fn link_renders_osc8_hyperlink() {
2236        // Matches real rich 15.0.0 exactly except upstream's random `id=` field,
2237        // which we omit for determinism (DIVERGENCES). markdown.link_url styling
2238        // is "underline blue" (4;34).
2239        let out = render("See [the site](https://example.com) now.");
2240        assert!(
2241            out.contains(
2242                "\x1b]8;;https://example.com\x1b\\\x1b[4;34mthe site\x1b[0m\x1b]8;;\x1b\\"
2243            ),
2244            "got {out:?}"
2245        );
2246        assert!(!out.contains("id="), "we omit the random link id");
2247    }
2248
2249    #[test]
2250    fn fenced_code_block_is_highlighted() {
2251        // Functional (not byte-parity): the fenced code renders via Syntax, so
2252        // its text survives and it's colored.
2253        let console = Console::builder()
2254            .force_terminal(true)
2255            .color_system(Some(ColorSystem::Truecolor))
2256            .width(24)
2257            .no_color(false)
2258            .build();
2259        let out = console.render_to_string(&Markdown::new("```rust\nfn main() {}\n```"));
2260        assert!(out.contains("fn"), "got {out:?}");
2261        assert!(out.contains("main"));
2262        assert!(out.contains('\x1b'), "code block should be colored");
2263    }
2264
2265    #[test]
2266    fn headings() {
2267        assert_eq!(render("# Head"), "        \x1b[1;4mHead\x1b[0m        ");
2268        assert_eq!(render("## Sub"), "\x1b[4;35mSub\x1b[0m                 ");
2269    }
2270
2271    #[test]
2272    fn two_paragraphs_separated_by_blank_line() {
2273        assert_eq!(
2274            render("First para.\n\nSecond para."),
2275            "First para.         \n\nSecond para.        "
2276        );
2277    }
2278
2279    #[test]
2280    fn bullet_list() {
2281        assert_eq!(
2282            render("- one\n- two"),
2283            "\n\x1b[1m \u{2022} \x1b[0mone              \n\x1b[1m \u{2022} \x1b[0mtwo              "
2284        );
2285    }
2286
2287    #[test]
2288    fn ordered_list() {
2289        assert_eq!(
2290            render("1. first\n2. second"),
2291            "\n\x1b[36m 1 \x1b[0mfirst            \n\x1b[36m 2 \x1b[0msecond           "
2292        );
2293    }
2294
2295    #[test]
2296    fn block_quote() {
2297        assert_eq!(
2298            render("> quoted text"),
2299            "\n\x1b[35m\u{258c} \x1b[0m\x1b[35mquoted text\x1b[0m\x1b[35m     \x1b[0m"
2300        );
2301    }
2302
2303    #[test]
2304    fn gfm_table() {
2305        // Byte-parity is guaranteed by the `markdown_table` golden; this guards
2306        // the parser wiring (tables enabled, cells + alignment collected).
2307        let console = Console::builder()
2308            .force_terminal(true)
2309            .color_system(Some(ColorSystem::Truecolor))
2310            .width(40)
2311            .no_color(false)
2312            .build();
2313        let md = "| Name | Age |\n| :--- | ---: |\n| Alice | 30 |\n| Bob | 7 |\n";
2314        let out = console.render_to_string(&Markdown::new(md));
2315        assert!(out.contains("Name"), "header present: {out:?}");
2316        assert!(out.contains("Alice"), "body cell present");
2317        assert!(out.contains('\u{2500}'), "SIMPLE box head rule present");
2318        // Right-justified Age column: "30" padded on the left, "7" further.
2319        assert!(out.contains(" 30"), "right-justified 30");
2320        assert!(out.contains("  7"), "right-justified 7");
2321    }
2322
2323    #[test]
2324    fn thematic_break() {
2325        assert_eq!(
2326            render("a\n\n---\n\nb"),
2327            "a                   \n\n\x1b[2m--------------------\x1b[0m\n\nb                   "
2328        );
2329    }
2330
2331    #[test]
2332    fn thematic_break_at_end_adds_trailing_blank() {
2333        // A document ending with a rule emits one extra trailing blank line
2334        // (upstream's hr element yields a trailing break). Byte-parity is
2335        // guaranteed by the `markdown_hr_end` golden; here we assert the shape.
2336        assert_eq!(
2337            render("a\n\n---"),
2338            "a                   \n\n\x1b[2m--------------------\x1b[0m\n"
2339        );
2340    }
2341}
2342
2343#[cfg(test)]
2344mod container_tests {
2345    use super::*;
2346
2347    fn plain(source: &str, width: usize) -> String {
2348        let console = Console::builder().width(width).color_system(None).build();
2349        console.render_to_string(&Markdown::new(source))
2350    }
2351
2352    /// Every case here lost content before parsing used a container stack: the
2353    /// open list, quote and paragraph lived in flat `Option`s, so a nested block
2354    /// overwrote its parent's pending text and the parent emitted nothing.
2355    fn assert_all_present(source: &str, expected: &[&str]) {
2356        let out = plain(source, 44);
2357        for item in expected {
2358            assert!(out.contains(item), "{item:?} missing from:\n{out}");
2359        }
2360    }
2361
2362    #[test]
2363    fn a_nested_list_keeps_every_item() {
2364        assert_all_present("- one\n- two\n  - nested\n", &["one", "two", "nested"]);
2365    }
2366
2367    #[test]
2368    fn nesting_three_deep_keeps_every_item() {
2369        assert_all_present("- top\n  - mid\n    - deep\n", &["top", "mid", "deep"]);
2370    }
2371
2372    #[test]
2373    fn an_item_following_a_sublist_keeps_its_place() {
2374        let out = plain("- one\n  - nested\n- two\n", 44);
2375        let (a, b, c) = (
2376            out.find("one").expect("one"),
2377            out.find("nested").expect("nested"),
2378            out.find("two").expect("two"),
2379        );
2380        assert!(a < b && b < c, "order was wrong:\n{out}");
2381    }
2382
2383    #[test]
2384    fn each_level_of_an_ordered_list_numbers_independently() {
2385        let out = plain("1. first\n2. second\n   1. sub\n", 44);
2386        for expected in ["1 first", "2 second", "1 sub"] {
2387            assert!(out.contains(expected), "expected {expected:?} in:\n{out}");
2388        }
2389    }
2390
2391    #[test]
2392    fn nested_items_are_indented_under_their_parent() {
2393        let out = plain("- top\n  - child\n", 44);
2394        let indent = |needle: &str| {
2395            let line = out.lines().find(|l| l.contains(needle)).expect(needle);
2396            line.len() - line.trim_start().len()
2397        };
2398        assert!(indent("child") > indent("top"), "not indented:\n{out}");
2399    }
2400
2401    /// A heading inside a list item used to delete the item's own text and take
2402    /// its place in the list.
2403    #[test]
2404    fn a_heading_inside_an_item_keeps_the_item_text() {
2405        assert_all_present(
2406            "- ITEMTEXT\n\n  ## HEADTEXT\n\n- NEXTTEXT\n",
2407            &["ITEMTEXT", "HEADTEXT", "NEXTTEXT"],
2408        );
2409    }
2410
2411    /// A code block inside an item used to be hoisted above the whole list, so
2412    /// the code appeared before the text introducing it.
2413    #[test]
2414    fn a_code_block_inside_an_item_stays_in_the_item() {
2415        let out = plain("- FIRSTITEM\n\n  ```\n  CODETEXT\n  ```\n", 44);
2416        let (item, code) = (
2417            out.find("FIRSTITEM").expect("item"),
2418            out.find("CODETEXT").expect("code"),
2419        );
2420        assert!(item < code, "the code was hoisted above its item:\n{out}");
2421    }
2422
2423    /// A second paragraph used to be fused onto the first with no separator.
2424    #[test]
2425    fn two_paragraphs_in_one_item_stay_separate() {
2426        let out = plain("- AAA\n\n  BBB\n", 44);
2427        assert!(!out.contains("AAABBB"), "paragraphs were fused:\n{out}");
2428        assert!(out.contains("AAA") && out.contains("BBB"), "{out}");
2429    }
2430
2431    /// A nested quote used to delete the outer quote's text entirely.
2432    #[test]
2433    fn a_nested_quote_keeps_the_outer_text() {
2434        assert_all_present(
2435            "> OUTERTEXT\n>\n> > INNERTEXT\n",
2436            &["OUTERTEXT", "INNERTEXT"],
2437        );
2438    }
2439
2440    /// A list inside a quote used to be reordered ahead of the quote's own text
2441    /// and to lose the quote bar.
2442    #[test]
2443    fn a_list_inside_a_quote_stays_quoted_and_in_order() {
2444        let out = plain("> intro\n>\n> - item one\n> - item two\n", 44);
2445        for line in out
2446            .lines()
2447            .filter(|l| l.contains("item one") || l.contains("intro"))
2448        {
2449            assert!(
2450                line.trim_start().starts_with(QUOTE_PREFIX.trim_end()),
2451                "lost the quote bar: {line:?}\n{out}"
2452            );
2453        }
2454        let (intro, one) = (
2455            out.find("intro").expect("intro"),
2456            out.find("item one").expect("item one"),
2457        );
2458        assert!(intro < one, "quote content was reordered:\n{out}");
2459    }
2460
2461    #[test]
2462    fn a_quote_inside_an_item_stays_inside_it() {
2463        let out = plain("- alpha\n\n  > quoted\n", 44);
2464        assert!(!out.contains("alphaquoted"), "fused:\n{out}");
2465        let quoted = out.lines().find(|l| l.contains("quoted")).expect("quoted");
2466        assert!(
2467            quoted.contains(QUOTE_PREFIX.trim_end()),
2468            "lost the quote bar:\n{out}"
2469        );
2470    }
2471
2472    /// In a *tight* list the item's text arrives as bare `Text` events, so any
2473    /// block-level start used to overwrite it: the item's own content vanished
2474    /// and the block took its place.
2475    #[test]
2476    fn a_tight_item_keeps_its_text_before_a_heading() {
2477        assert_all_present(
2478            "- P1_text\n  ## H1_head\n- P2_text\n",
2479            &["P1_text", "H1_head", "P2_text"],
2480        );
2481    }
2482
2483    #[test]
2484    fn a_tight_item_keeps_its_text_before_a_quote() {
2485        assert_all_present("- Q1_text\n  > Q1_quote\n", &["Q1_text", "Q1_quote"]);
2486    }
2487
2488    #[test]
2489    fn a_tight_ordered_item_keeps_its_text_before_a_quote() {
2490        assert_all_present("1. C_num_text\n   > C_quote\n", &["C_num_text", "C_quote"]);
2491    }
2492
2493    #[test]
2494    fn a_nested_tight_item_keeps_its_text_before_a_heading() {
2495        assert_all_present(
2496            "- A\n  - B_inner\n    ## B_head\n",
2497            &["A", "B_inner", "B_head"],
2498        );
2499    }
2500
2501    /// A fenced block tight after the item's text used to render *before* it โ€”
2502    /// #69 stopped hoisting it above the whole list, but it still overtook the
2503    /// paragraph that introduced it.
2504    #[test]
2505    fn a_tight_code_block_renders_after_the_text_that_introduces_it() {
2506        let out = plain("- F1_text\n  ```\n  F1_code\n  ```\n- F2_text\n", 55);
2507        let (text, code) = (
2508            out.find("F1_text").expect("F1_text"),
2509            out.find("F1_code").expect("F1_code"),
2510        );
2511        assert!(text < code, "the code block overtook its paragraph:\n{out}");
2512    }
2513
2514    /// Rendering recurses once per nesting level, so an unbounded document
2515    /// overflowed the stack and killed the process: 400 nested quotes aborted
2516    /// with STATUS_STACK_OVERFLOW after four seconds, no output at all.
2517    #[test]
2518    fn deeply_nested_input_does_not_overflow_the_stack() {
2519        for depth in [50usize, 400, 2000] {
2520            let quotes = ">".repeat(depth) + " x\n";
2521            let _ = plain(&quotes, 80);
2522
2523            let list: String = (0..depth)
2524                .map(|i| format!("{}- L{i}\n", "  ".repeat(i)))
2525                .collect();
2526            let _ = plain(&list, 80);
2527        }
2528        // Reaching here without aborting is the assertion.
2529    }
2530
2531    /// Text after a nested block inside a tight item arrives as a bare `Text`
2532    /// event with no buffer open โ€” the previous one having been flushed by that
2533    /// block's start โ€” and was silently dropped at exit 0.
2534    #[test]
2535    fn a_tight_item_keeps_text_that_follows_a_nested_block() {
2536        assert_all_present(
2537            "- ITEM\n  ```\n  FIRST code\n  ```\n  SECOND para\n",
2538            &["ITEM", "FIRST code", "SECOND para"],
2539        );
2540        assert_all_present(
2541            "- ITEM\n  ## HEAD\n  TAIL para\n",
2542            &["ITEM", "HEAD", "TAIL para"],
2543        );
2544        assert_all_present("- ITEM\n  ---\n  TAIL para\n", &["ITEM", "TAIL para"]);
2545    }
2546
2547    /// A heading inside a quote was flattened to body text: it lost its own
2548    /// style and its centring, keeping only the quote's magenta.
2549    #[test]
2550    fn a_heading_inside_a_quote_keeps_its_alignment() {
2551        let out = plain("> # Heading in quote\n", 50);
2552        let line = out
2553            .lines()
2554            .find(|l| l.contains("Heading in quote"))
2555            .expect("heading line");
2556        // Centred: the text does not start immediately after the quote bar.
2557        let after_bar = line.split(QUOTE_PREFIX.trim_end()).nth(1).expect("bar");
2558        assert!(
2559            after_bar.starts_with("  "),
2560            "heading was left-aligned inside the quote: {line:?}"
2561        );
2562    }
2563
2564    /// Upstream enables strikethrough explicitly; without the parser option the
2565    /// tilde markers leaked into the output and widened table columns.
2566    #[test]
2567    fn strikethrough_is_rendered_rather_than_leaked() {
2568        let out = plain("~~Deprecated~~ text\n", 50);
2569        assert!(!out.contains("~~"), "tildes leaked into output: {out:?}");
2570        assert!(out.contains("Deprecated"), "content lost: {out:?}");
2571    }
2572
2573    /// Blank lines between blocks are a document convention. Applying them
2574    /// inside a container added a stray row per block and per nesting level โ€”
2575    /// upstream emits none there.
2576    #[test]
2577    fn nested_blocks_gain_no_phantom_blank_row() {
2578        let out = plain("- a\n  - b\n  - c\n- d\n", 50);
2579        let rows: Vec<&str> = out
2580            .lines()
2581            .map(str::trim_end)
2582            .filter(|l| !l.is_empty())
2583            .collect();
2584        assert_eq!(
2585            rows.len(),
2586            4,
2587            "expected exactly four content rows, got {rows:?}"
2588        );
2589    }
2590
2591    /// Upstream's `render_lines` pads a child back to the width it was handed
2592    /// (`pad=True`). We never padded, so every nesting level inherited the
2593    /// shortfall: quote rows measured 68, 66, 64, 62 at depths 1โ€“4 where
2594    /// upstream holds a flat 68.
2595    #[test]
2596    fn nesting_does_not_narrow_each_level() {
2597        let source = "> d1\n\n>> d2\n\n>>> d3\n\n>>>> d4\n";
2598        let out = plain(source, 70);
2599        let widths: Vec<usize> = out
2600            .lines()
2601            .filter(|l| {
2602                l.contains("d1") || l.contains("d2") || l.contains("d3") || l.contains("d4")
2603            })
2604            .map(|l| l.chars().count())
2605            .collect();
2606        assert_eq!(widths.len(), 4, "expected one row per depth: {widths:?}");
2607        assert!(
2608            widths.iter().all(|w| *w == widths[0]),
2609            "each nesting level lost width: {widths:?}"
2610        );
2611    }
2612
2613    /// pulldown-cmark accepts a single tilde as a strikethrough delimiter;
2614    /// upstream's markdown-it requires two, so `~struck~` had its tildes deleted
2615    /// and its content restyled where upstream leaves the text alone.
2616    #[test]
2617    fn a_single_tilde_is_literal_text() {
2618        let out = plain("a ~struck~ b and ~~gone~~ here", 60);
2619        assert!(
2620            out.contains("~struck~"),
2621            "single tildes were eaten: {out:?}"
2622        );
2623        assert!(!out.contains("~~gone~~"), "double tildes leaked: {out:?}");
2624        assert!(out.contains("gone"), "struck content lost: {out:?}");
2625    }
2626
2627    /// Upstream renders a fenced block as `Syntax(..., padding=1)`: a blank
2628    /// inset row above and below and a one-column gutter. Without it the code
2629    /// sat flush against the surrounding text.
2630    #[test]
2631    fn a_code_block_is_inset_by_one_cell() {
2632        let out = plain("intro para\n\n```\nCODEWORD\n```\n", 40);
2633        let rows: Vec<&str> = out.lines().collect();
2634        let index = rows
2635            .iter()
2636            .position(|r| r.contains("CODEWORD"))
2637            .expect("code row present");
2638        assert!(
2639            rows[index].starts_with(' '),
2640            "no left gutter on the code row: {:?}",
2641            rows[index]
2642        );
2643        assert!(
2644            rows[index - 1].trim().is_empty(),
2645            "no blank inset row above the code: {:?}",
2646            rows[index - 1]
2647        );
2648        assert!(
2649            rows.get(index + 1).is_some_and(|r| r.trim().is_empty()),
2650            "no blank inset row below the code"
2651        );
2652    }
2653
2654    /// A rule carries its own trailing blank in place of the usual inter-block
2655    /// gap, so a block after it is separated by exactly one blank row โ€” not two,
2656    /// and not none.
2657    #[test]
2658    fn a_rule_is_followed_by_exactly_one_blank_row() {
2659        let out = plain("before\n\n---\n\nafter\n", 40);
2660        let rows: Vec<&str> = out.lines().collect();
2661        let rule = rows
2662            .iter()
2663            .position(|r| r.trim_end().ends_with('-') && r.trim().len() > 3)
2664            .expect("rule row present");
2665        let after = rows
2666            .iter()
2667            .position(|r| r.contains("after"))
2668            .expect("following row present");
2669        assert_eq!(
2670            after - rule,
2671            2,
2672            "expected one blank row between rule and next block: {rows:?}"
2673        );
2674    }
2675
2676    /// Upstream's `ImageItem` renders `๐ŸŒ† <title> ` and yields it *before* the
2677    /// element it was lifted out of, with no line break of its own. We rendered
2678    /// the alt text inline with no marker at all, and `![](url)` โ€” a badge row,
2679    /// which is what most READMEs open with โ€” came out as a blank line.
2680    ///
2681    /// Every expectation captured verbatim from real rich 15.0.0 at width 40:
2682    ///
2683    /// ```text
2684    /// ![alt text](https://example.com/pic.png)  -> '๐ŸŒ† alt text'
2685    /// ![](https://example.com/pic.png)          -> '๐ŸŒ† pic.png'   <- filename
2686    /// ![](img/)                                 -> '๐ŸŒ† img'
2687    /// Before ![alt text](img/pic.png) after.    -> '๐ŸŒ† alt text Before  after.'
2688    /// ![alt *em*](u/v.png)                      -> '๐ŸŒ† alt *em*'  <- raw alt
2689    /// ```
2690    #[test]
2691    fn an_image_is_marked_and_hoisted() {
2692        let row = |source: &str| {
2693            plain(source, 40)
2694                .lines()
2695                .next()
2696                .expect("a row")
2697                .trim_end()
2698                .to_string()
2699        };
2700        assert_eq!(
2701            row("![alt text](https://example.com/pic.png)"),
2702            "๐ŸŒ† alt text"
2703        );
2704        assert_eq!(row("![](https://example.com/pic.png)"), "๐ŸŒ† pic.png");
2705        assert_eq!(row("![](img/)"), "๐ŸŒ† img");
2706        // Hoisted to the front of the paragraph it sat inside, on the same row.
2707        assert_eq!(
2708            row("Before ![alt text](img/pic.png) after."),
2709            "๐ŸŒ† alt text Before  after."
2710        );
2711        // The alt is the raw markdown source, markers included: upstream reads
2712        // markdown-it's `token.content`, which is never inline-parsed.
2713        assert_eq!(row("![alt *em*](u/v.png)"), "๐ŸŒ† alt *em*");
2714    }
2715
2716    /// An image inside a container is lifted clear of it: upstream renders the
2717    /// element the moment its token is reached, while the list or quote holding
2718    /// it is still open and will not render until it closes.
2719    ///
2720    /// Real rich 15.0.0 at width 40 (trailing padding trimmed):
2721    ///
2722    /// ```text
2723    /// '- item with ![pic](a/b.png) inside'
2724    ///     -> ['๐ŸŒ† pic', ' โ€ข item with  inside']
2725    /// '> quoted ![pic](a/b.png) end'
2726    ///     -> ['๐ŸŒ† pic', 'โ–Œ quoted  end']
2727    /// ```
2728    ///
2729    /// Note the absence of the blank row a list or quote normally brings with
2730    /// it: the image asks for no line break after itself.
2731    #[test]
2732    fn an_image_is_lifted_out_of_a_list_or_quote() {
2733        let rows = |source: &str| -> Vec<String> {
2734            plain(source, 40)
2735                .lines()
2736                .map(|line| line.trim_end().to_string())
2737                .collect()
2738        };
2739        assert_eq!(
2740            rows("- item with ![pic](a/b.png) inside"),
2741            vec!["๐ŸŒ† pic", " โ€ข item with  inside"]
2742        );
2743        assert_eq!(
2744            rows("> quoted ![pic](a/b.png) end"),
2745            vec!["๐ŸŒ† pic", "โ–Œ quoted  end"]
2746        );
2747    }
2748
2749    /// Markdown code blocks are `Syntax(..., word_wrap=True)` upstream. Without
2750    /// it a long line was cropped dead at the console width and its tail
2751    /// discarded โ€” a README's install command lost half its flags, silently.
2752    #[test]
2753    fn a_long_code_line_keeps_its_tail() {
2754        let source = "```bash\npip install some-package another-package \
2755yet-another-package --upgrade --no-cache-dir\n```\n";
2756        let out = plain(source, 80);
2757        assert!(
2758            out.contains("no-cache-dir"),
2759            "the tail of the code line was discarded: {out:?}"
2760        );
2761    }
2762
2763    /// A tab in a fenced block reaches the terminal as U+0009, which jumps to
2764    /// the next 8-cell stop while we had counted it as one cell โ€” so the block
2765    /// overran the width it was given. Upstream expands tabs before
2766    /// highlighting; the fenced block inherits that through `Syntax`.
2767    #[test]
2768    fn a_fenced_block_expands_its_tabs() {
2769        // Rows captured from rich 15.0.0 at width 30.
2770        let out = plain("```python\ndef f():\n\tif x:\n\t\treturn 1\n```", 30);
2771        assert_eq!(
2772            out.split('\n').collect::<Vec<_>>(),
2773            [
2774                "                              ",
2775                " def f():                     ",
2776                "     if x:                    ",
2777                "         return 1             ",
2778                "                              ",
2779            ]
2780        );
2781    }
2782}
2783
2784/// `Markdown(hyperlinks=โ€ฆ)`. Every expectation here was captured verbatim from
2785/// real rich 15.0.0 (with its random OSC 8 `id=` field removed, which we
2786/// deliberately do not reproduce โ€” see docs/DIVERGENCES.md).
2787#[cfg(test)]
2788mod hyperlink_tests {
2789    use super::*;
2790    use crate::color::ColorSystem;
2791
2792    fn plain(source: &str, width: usize, hyperlinks: bool) -> String {
2793        Console::builder()
2794            .width(width)
2795            .color_system(None)
2796            .build()
2797            .render_to_string(&Markdown::new(source).hyperlinks(hyperlinks))
2798    }
2799
2800    fn ansi(source: &str, width: usize, hyperlinks: bool) -> String {
2801        Console::builder()
2802            .force_terminal(true)
2803            .color_system(Some(ColorSystem::Truecolor))
2804            .width(width)
2805            .no_color(false)
2806            .build()
2807            .render_to_string(&Markdown::new(source).hyperlinks(hyperlinks))
2808    }
2809
2810    /// THE defect: an OSC 8 escape is only written when the console has a colour
2811    /// system, so with hyperlinks on a piped or `NO_COLOR` render dropped every
2812    /// destination and left nothing to recover it from. `rich -m` passes
2813    /// `hyperlinks=False` precisely so the URL is written out as text.
2814    #[test]
2815    fn hyperlinks_off_writes_the_url_out_after_the_label() {
2816        assert_eq!(
2817            plain("A [link](https://example.com) here.", 40, false),
2818            "A link (https://example.com) here.      "
2819        );
2820    }
2821
2822    #[test]
2823    fn hyperlinks_on_keeps_the_label_alone() {
2824        assert_eq!(
2825            plain("A [link](https://example.com) here.", 40, true),
2826            "A link here.                            "
2827        );
2828    }
2829
2830    /// The knock-on: the URL is part of the cell's *text*, so it drives the
2831    /// column width. Laying the table out against the bare label made it far too
2832    /// narrow and the URL was then wrapped or cropped away.
2833    #[test]
2834    fn hyperlinks_off_widens_a_table_column_to_fit_the_url() {
2835        let source = "| T | W |\n| :-- | --: |\n| r | [repo](https://ex.org/a) |\n";
2836        assert_eq!(
2837            plain(source, 60, false).split('\n').collect::<Vec<_>>(),
2838            [
2839                "",
2840                "                            ",
2841                " T                        W ",
2842                " โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ",
2843                " r  repo (https://ex.org/a) ",
2844                "                            ",
2845            ]
2846        );
2847        // ...and with hyperlinks on the column stays at the label's width.
2848        assert_eq!(
2849            plain(source, 60, true).split('\n').collect::<Vec<_>>(),
2850            [
2851                "",
2852                "         ",
2853                " T     W ",
2854                " โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ",
2855                " r  repo ",
2856                "         "
2857            ]
2858        );
2859    }
2860
2861    /// Upstream buffers the label in a `Link` element and re-emits only
2862    /// `element.text.plain`, so emphasis *inside* the label is lost.
2863    #[test]
2864    fn hyperlinks_off_flattens_the_labels_own_emphasis() {
2865        assert_eq!(
2866            plain("A [**b** and *i* l](https://e.org) t.", 60, false),
2867            "A b and i l (https://e.org) t.                              "
2868        );
2869    }
2870
2871    /// `markdown.link` (bright_blue) paints the label, `markdown.link_url`
2872    /// (underline blue) the URL, and both compose over the heading's own style โ€”
2873    /// h2's magenta loses to each in turn.
2874    #[test]
2875    fn hyperlinks_off_styles_the_label_and_the_url_under_a_heading() {
2876        assert_eq!(
2877            ansi("## H [x](https://e.org)", 40, false),
2878            "\x1b[4;35mH \x1b[0m\x1b[4;94mx\x1b[0m\x1b[4;35m (\x1b[0m\
2879             \x1b[4;34mhttps://e.org\x1b[0m\x1b[4;35m)\x1b[0m                     "
2880        );
2881        assert_eq!(
2882            ansi("## H [x](https://e.org)", 40, true),
2883            "\x1b[4;35mH \x1b[0m\x1b]8;;https://e.org\x1b\\\x1b[4;34mx\x1b[0m\
2884             \x1b]8;;\x1b\\                                     "
2885        );
2886    }
2887
2888    /// Upstream pushes `markdown.link_url` *onto* the open style stack, so a
2889    /// link inside `**bold**` is bold as well. Replacing the stack with the link
2890    /// style alone dropped the bold.
2891    #[test]
2892    fn a_link_inside_bold_stays_bold() {
2893        assert_eq!(
2894            ansi("x **b [l](https://e.org) b** y", 60, true),
2895            "x \x1b[1mb \x1b[0m\x1b]8;;https://e.org\x1b\\\x1b[1;4;34ml\x1b[0m\
2896             \x1b]8;;\x1b\\\x1b[1m b\x1b[0m y                                                   "
2897        );
2898    }
2899
2900    /// `markdown.code` is pushed on top of the link, so a label that is entirely
2901    /// inline code keeps its destination. Applying the code style alone threw the
2902    /// URL away even with hyperlinks *on*.
2903    #[test]
2904    fn a_link_labelled_with_inline_code_keeps_its_destination() {
2905        assert_eq!(
2906            ansi("A [`code`](https://e.org/x) tail.", 60, true),
2907            "A \x1b]8;;https://e.org/x\x1b\\\x1b[1;4;36;40mcode\x1b[0m\x1b]8;;\x1b\\ \
2908             tail.                                                "
2909        );
2910    }
2911
2912    /// CommonMark gives an email autolink a `mailto:` destination, but
2913    /// pulldown-cmark leaves the scheme to the renderer and hands over the bare
2914    /// address โ€” so the URL we printed was not a URL.
2915    #[test]
2916    fn an_email_autolink_keeps_its_mailto_scheme() {
2917        assert_eq!(
2918            plain("Mail <who@where.net> now.", 50, false),
2919            "Mail who@where.net (mailto:who@where.net) now.    "
2920        );
2921        assert_eq!(
2922            ansi("Mail <who@where.net> now.", 50, true),
2923            "Mail \x1b]8;;mailto:who@where.net\x1b\\\x1b[4;34mwho@where.net\x1b[0m\
2924             \x1b]8;;\x1b\\ now.                           "
2925        );
2926    }
2927
2928    /// A badge wrapped in a link: `ImageItem` appends its title with the style
2929    /// open around it, so the alt text carries the link's `markdown.link_url`
2930    /// too, not just the OSC 8 target.
2931    #[test]
2932    fn an_image_inside_a_link_carries_the_links_style() {
2933        assert_eq!(
2934            ansi("[![badge](b.svg)](https://e.org)", 40, true),
2935            "\u{1f306} \x1b]8;;https://e.org\x1b\\\x1b[4;34mbadge\x1b[0m\
2936             \x1b]8;;\x1b\\                                "
2937        );
2938    }
2939
2940    /// A single-tilde span inside a link label put BOTH tildes in front of the
2941    /// label, because the tilde went to the paragraph buffer while the label
2942    /// text accumulated in its own โ€” characters reordered, not restyled.
2943    #[test]
2944    fn a_single_tilde_inside_a_link_label_keeps_its_place() {
2945        let out = plain("A [~a~ label](https://e.com) here.\n", 60, false);
2946        assert!(
2947            out.contains("~a~ label"),
2948            "tilde moved out of the label: {out:?}"
2949        );
2950        assert!(!out.contains("~~a"), "tildes were reordered: {out:?}");
2951    }
2952
2953    /// Outside a link there may be no open buffer yet; routing the tilde
2954    /// through `as_mut()` dropped it and 11 of 102 sweep cases regressed.
2955    #[test]
2956    fn a_single_tilde_survives_with_no_buffer_open() {
2957        let out = plain("~5~10 and ~x~\n", 40, false);
2958        assert!(out.contains("~5~10"), "tilde dropped: {out:?}");
2959        assert!(out.contains("~x~"), "tilde dropped: {out:?}");
2960    }
2961
2962    /// Table cells render unstyled (#9), but their tildes still pair by
2963    /// markdown-it's rules: upstream shows `~c~` with the `c` struck.
2964    #[test]
2965    fn table_cell_tildes_pair_like_markdown_it() {
2966        let out = plain("| h |\n|---|\n| ~~~c~~~ |\n", 20, false);
2967        assert!(out.contains("~c~"), "{out:?}");
2968        assert!(!out.contains("~~"), "{out:?}");
2969    }
2970
2971    /// Tildes in a fenced block are code, not delimiters.
2972    #[test]
2973    fn code_block_tildes_are_untouched() {
2974        let out = plain("```\na ~~~x~~~ b\n```\n", 30, false);
2975        assert!(out.contains("a ~~~x~~~ b"), "{out:?}");
2976    }
2977
2978    /// DIVERGENCES ยง21: when a tilde pair would cross an emphasis span whose
2979    /// opener comes after the tilde opener, upstream dissolves the emphasis
2980    /// (`~~a *b~~ c*` strikes `a *b`); this port keeps pulldown-cmark's emphasis
2981    /// and leaves the tildes literal. Pinned so a fix shows up here.
2982    #[test]
2983    fn tildes_crossing_a_later_emphasis_stay_literal() {
2984        let out = plain("~~a *b~~ c*", 30, false);
2985        assert!(out.contains("~~a b~~ c"), "{out:?}");
2986    }
2987}