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