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