Skip to main content

rich/
markdown.rs

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