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