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