Skip to main content

rich/
markdown.rs

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