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 `` 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: `` 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|   |\n|  |",
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("es, 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 `` — 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'
1680 ///  -> '🌆 pic.png' <- filename
1681 ///  -> '🌆 img'
1682 /// Before  after. -> '🌆 alt text Before after.'
1683 ///  -> '🌆 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(""),
1697 "🌆 alt text"
1698 );
1699 assert_eq!(row(""), "🌆 pic.png");
1700 assert_eq!(row(""), "🌆 img");
1701 // Hoisted to the front of the paragraph it sat inside, on the same row.
1702 assert_eq!(
1703 row("Before  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*");
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  inside'
1719 /// -> ['🌆 pic', ' • item with inside']
1720 /// '> quoted  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  inside"),
1736 vec!["🌆 pic", " • item with inside"]
1737 );
1738 assert_eq!(
1739 rows("> quoted  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("[](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}