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