Skip to main content

rich/
markdown.rs

1//! Markdown rendering.
2//!
3//! Port of upstream `rich/markdown.py` (core block/inline elements). Parses
4//! CommonMark with `pulldown-cmark` and renders each block as justified,
5//! full-width lines separated by blank lines.
6//!
7//! Scope: paragraphs, ATX headings (h1–h6), bullet + ordered lists, block quotes,
8//! thematic breaks, fenced/indented **code blocks** (syntax-highlighted via
9//! [`Syntax`]), **links** (OSC 8 hyperlinks), inline strong/emphasis/code, and
10//! **GFM tables** (rendered via [`Table`]). Inline styling *within* a table cell
11//! is a documented follow-up (see the Markdown issue).
12
13use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
14
15use crate::cells::cell_len;
16use crate::console::{Console, ConsoleOptions, Justify};
17use crate::protocol::Renderable;
18use crate::r#box::SIMPLE;
19use crate::segment::Segment;
20use crate::style::Style;
21use crate::syntax::Syntax;
22use crate::table::Table;
23use crate::text::Text;
24
25const CODE_STYLE: &str = "bold cyan on black"; // markdown.code
26const BULLET: &str = " \u{2022} "; // " • ", markdown.item.bullet = bold
27const QUOTE_PREFIX: &str = "\u{258c} "; // "▌ ", markdown.block_quote = magenta
28const LINK_STYLE: &str = "underline blue"; // markdown.link_url
29const TABLE_BORDER_STYLE: &str = "cyan"; // markdown.table.border
30const TABLE_HEADER_STYLE: &str = "not bold cyan"; // markdown.table.header
31
32/// A parsed Markdown block.
33enum Block {
34    /// A paragraph or heading (its `Text` carries justify + any heading span).
35    Text(Text),
36    /// A bullet or ordered list; each item is a left-justified `Text`.
37    List {
38        ordered: bool,
39        start: u64,
40        items: Vec<Text>,
41    },
42    /// A block quote; each paragraph is a magenta, left-justified `Text`.
43    Quote(Vec<Text>),
44    /// A fenced/indented code block, syntax-highlighted via [`Syntax`].
45    Code { language: String, code: String },
46    /// A thematic break (horizontal rule).
47    Rule,
48    /// A GFM table: per-column justify (from the alignment row), header cells,
49    /// and body rows. Rendered via [`Table`], matching upstream's construction.
50    Table {
51        alignments: Vec<Justify>,
52        headers: Vec<String>,
53        rows: Vec<Vec<String>>,
54    },
55}
56
57/// Accumulates a GFM table across `pulldown-cmark`'s table events.
58#[derive(Default)]
59struct TableAccum {
60    alignments: Vec<Justify>,
61    headers: Vec<String>,
62    rows: Vec<Vec<String>>,
63    in_head: bool,
64    in_cell: bool,
65    cur_row: Vec<String>,
66    cur_cell: String,
67}
68
69fn alignment_justify(alignment: Alignment) -> Justify {
70    match alignment {
71        Alignment::Right => Justify::Right,
72        Alignment::Center => Justify::Center,
73        // `None` has no explicit marker; upstream leaves it default (left).
74        Alignment::Left | Alignment::None => Justify::Left,
75    }
76}
77
78/// A rendered Markdown document. Mirrors `rich.markdown.Markdown`.
79pub struct Markdown {
80    blocks: Vec<Block>,
81}
82
83impl Markdown {
84    /// Parse CommonMark `source` into renderable blocks.
85    pub fn new(source: &str) -> Self {
86        Markdown {
87            blocks: parse(source),
88        }
89    }
90}
91
92fn heading_level(level: HeadingLevel) -> usize {
93    match level {
94        HeadingLevel::H1 => 1,
95        HeadingLevel::H2 => 2,
96        HeadingLevel::H3 => 3,
97        HeadingLevel::H4 => 4,
98        HeadingLevel::H5 => 5,
99        HeadingLevel::H6 => 6,
100    }
101}
102
103/// `(base style, justify)` for a heading level (`default_styles.py` +
104/// `Heading.LEVEL_ALIGN`).
105fn heading_format(level: usize) -> (Style, Justify) {
106    let (spec, justify) = match level {
107        1 => ("bold underline", Justify::Center),
108        2 => ("underline magenta", Justify::Left),
109        3 => ("bold magenta", Justify::Left),
110        4 => ("italic magenta", Justify::Left),
111        5 => ("italic", Justify::Left),
112        _ => ("dim", Justify::Left),
113    };
114    (Style::parse(spec).unwrap_or_default(), justify)
115}
116
117fn inline_style(strong: usize, emphasis: usize) -> Option<Style> {
118    if strong == 0 && emphasis == 0 {
119        return None;
120    }
121    let mut style = Style::new();
122    if strong > 0 {
123        style = style.combine(&Style::parse("bold").expect("valid style"));
124    }
125    if emphasis > 0 {
126        style = style.combine(&Style::parse("italic").expect("valid style"));
127    }
128    Some(style)
129}
130
131fn parse(source: &str) -> Vec<Block> {
132    let mut blocks: Vec<Block> = Vec::new();
133    let mut current: Option<Text> = None;
134    let mut heading_style: Option<Style> = None;
135    let mut justify = Justify::Left;
136    let mut strong = 0usize;
137    let mut emphasis = 0usize;
138    // (ordered, start_number, items) while inside a list.
139    let mut list: Option<(bool, u64, Vec<Text>)> = None;
140    // Collected quote paragraphs while inside a block quote.
141    let mut quote: Option<Vec<Text>> = None;
142    // (language, accumulated source) while inside a code block.
143    let mut code: Option<(String, String)> = None;
144    // The destination URL while inside a link.
145    let mut link: Option<String> = None;
146    // The table being assembled while inside a GFM table.
147    let mut table: Option<TableAccum> = None;
148
149    for event in Parser::new_ext(source, Options::ENABLE_TABLES) {
150        match event {
151            Event::Rule => blocks.push(Block::Rule),
152            Event::Start(Tag::Link { dest_url, .. }) => link = Some(dest_url.to_string()),
153            Event::End(TagEnd::Link) => link = None,
154            Event::Start(Tag::CodeBlock(kind)) => {
155                let language = match kind {
156                    CodeBlockKind::Fenced(info) => {
157                        // The info string is `lang` (possibly with extra tokens).
158                        info.split_whitespace().next().unwrap_or("").to_string()
159                    }
160                    CodeBlockKind::Indented => String::new(),
161                };
162                code = Some((language, String::new()));
163            }
164            Event::End(TagEnd::CodeBlock) => {
165                if let Some((language, mut source)) = code.take() {
166                    // Drop the single trailing newline the parser appends.
167                    if source.ends_with('\n') {
168                        source.pop();
169                    }
170                    blocks.push(Block::Code {
171                        language,
172                        code: source,
173                    });
174                }
175            }
176            Event::Start(Tag::Table(aligns)) => {
177                table = Some(TableAccum {
178                    alignments: aligns.into_iter().map(alignment_justify).collect(),
179                    ..TableAccum::default()
180                });
181            }
182            Event::End(TagEnd::Table) => {
183                if let Some(acc) = table.take() {
184                    blocks.push(Block::Table {
185                        alignments: acc.alignments,
186                        headers: acc.headers,
187                        rows: acc.rows,
188                    });
189                }
190            }
191            Event::Start(Tag::TableHead) => {
192                if let Some(acc) = table.as_mut() {
193                    acc.in_head = true;
194                    acc.cur_row = Vec::new();
195                }
196            }
197            Event::End(TagEnd::TableHead) => {
198                if let Some(acc) = table.as_mut() {
199                    acc.headers = std::mem::take(&mut acc.cur_row);
200                    acc.in_head = false;
201                }
202            }
203            Event::Start(Tag::TableRow) => {
204                if let Some(acc) = table.as_mut() {
205                    acc.cur_row = Vec::new();
206                }
207            }
208            Event::End(TagEnd::TableRow) => {
209                if let Some(acc) = table.as_mut() {
210                    let row = std::mem::take(&mut acc.cur_row);
211                    acc.rows.push(row);
212                }
213            }
214            Event::Start(Tag::TableCell) => {
215                if let Some(acc) = table.as_mut() {
216                    acc.in_cell = true;
217                    acc.cur_cell = String::new();
218                }
219            }
220            Event::End(TagEnd::TableCell) => {
221                if let Some(acc) = table.as_mut() {
222                    let cell = std::mem::take(&mut acc.cur_cell);
223                    acc.cur_row.push(cell);
224                    acc.in_cell = false;
225                }
226            }
227            Event::Start(Tag::BlockQuote(_)) => quote = Some(Vec::new()),
228            Event::End(TagEnd::BlockQuote(_)) => {
229                if let Some(paragraphs) = quote.take() {
230                    blocks.push(Block::Quote(paragraphs));
231                }
232            }
233            Event::Start(Tag::List(first)) => {
234                list = Some((first.is_some(), first.unwrap_or(1), Vec::new()))
235            }
236            Event::End(TagEnd::List(_)) => {
237                if let Some((ordered, start, items)) = list.take() {
238                    blocks.push(Block::List {
239                        ordered,
240                        start,
241                        items,
242                    });
243                }
244            }
245            Event::Start(Tag::Item) => {
246                current = Some(Text::new(""));
247                heading_style = None;
248                justify = Justify::Left;
249            }
250            Event::End(TagEnd::Item) => {
251                if let (Some(mut text), Some((_, _, items))) = (current.take(), list.as_mut()) {
252                    text.set_justify(Justify::Left);
253                    items.push(text);
254                }
255            }
256            // Don't reset the active text if we're inside a list item.
257            Event::Start(Tag::Paragraph) if current.is_none() => {
258                current = Some(Text::new(""));
259                heading_style = None;
260                justify = Justify::Left;
261            }
262            Event::Start(Tag::Heading { level, .. }) => {
263                let (style, heading_justify) = heading_format(heading_level(level));
264                current = Some(Text::new(""));
265                heading_style = Some(style);
266                justify = heading_justify;
267            }
268            // In a list, the item text is finalized at End(Item) instead.
269            Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Heading(_)) if list.is_none() => {
270                if let Some(mut text) = current.take() {
271                    if let Some(paragraphs) = quote.as_mut() {
272                        // Quote paragraph: magenta base so its padding is magenta too.
273                        text.set_base_style(Style::parse("magenta").expect("valid style"));
274                        text.set_justify(Justify::Left);
275                        paragraphs.push(text);
276                    } else {
277                        if let Some(style) = &heading_style {
278                            let end = text.plain().len();
279                            text.stylize(style.clone(), 0, end);
280                        }
281                        text.set_justify(justify);
282                        blocks.push(Block::Text(text));
283                    }
284                }
285                strong = 0;
286                emphasis = 0;
287            }
288            Event::Start(Tag::Strong) => strong += 1,
289            Event::End(TagEnd::Strong) => strong = strong.saturating_sub(1),
290            Event::Start(Tag::Emphasis) => emphasis += 1,
291            Event::End(TagEnd::Emphasis) => emphasis = emphasis.saturating_sub(1),
292            Event::Text(text) => {
293                if let Some(acc) = table.as_mut().filter(|a| a.in_cell) {
294                    // Table cells collect plain text; inline styling within a cell
295                    // is a documented follow-up (see the Markdown issue).
296                    acc.cur_cell.push_str(&text);
297                } else if let Some((_, source)) = code.as_mut() {
298                    source.push_str(&text);
299                } else if let Some(block) = current.as_mut() {
300                    // Inside a link, use the markdown.link_url style + an OSC 8
301                    // hyperlink; otherwise the inline strong/emphasis style.
302                    let style = match &link {
303                        Some(url) => Style::parse(LINK_STYLE)
304                            .ok()
305                            .map(|s| s.with_link(url.clone())),
306                        None => inline_style(strong, emphasis),
307                    };
308                    block.append(&text, style.map(Into::into));
309                }
310            }
311            Event::Code(text) => {
312                if let Some(acc) = table.as_mut().filter(|a| a.in_cell) {
313                    acc.cur_cell.push_str(&text);
314                } else if let Some(block) = current.as_mut() {
315                    block.append(&text, Style::parse(CODE_STYLE).ok().map(Into::into));
316                }
317            }
318            Event::SoftBreak => {
319                if let Some(block) = current.as_mut() {
320                    block.append(" ", None);
321                }
322            }
323            Event::HardBreak => {
324                if let Some(block) = current.as_mut() {
325                    block.append("\n", None);
326                }
327            }
328            _ => {}
329        }
330    }
331    blocks
332}
333
334impl Renderable for Markdown {
335    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
336        let width = options.max_width;
337        let base = console.base_style();
338        let mut lines: Vec<Vec<Segment>> = Vec::new();
339
340        for (index, block) in self.blocks.iter().enumerate() {
341            // A blank line precedes every non-first block, and every
342            // list/quote/table (which upstream renders with a leading gap).
343            if index > 0
344                || matches!(
345                    block,
346                    Block::List { .. } | Block::Quote(_) | Block::Table { .. }
347                )
348            {
349                lines.push(Vec::new());
350            }
351            match block {
352                Block::Text(text) => {
353                    lines.extend(text.render_lines(console.theme(), base, Some(width)))
354                }
355                Block::List {
356                    ordered,
357                    start,
358                    items,
359                } => {
360                    for (number, item) in (*start..).zip(items.iter()) {
361                        let (prefix, prefix_style) = if *ordered {
362                            (
363                                format!(" {number} "),
364                                Style::parse("cyan").expect("valid style"),
365                            )
366                        } else {
367                            (
368                                BULLET.to_string(),
369                                Style::parse("bold").expect("valid style"),
370                            )
371                        };
372                        let prefix_width = cell_len(&prefix);
373                        let item_lines = item.render_lines(
374                            console.theme(),
375                            base,
376                            Some(width.saturating_sub(prefix_width)),
377                        );
378                        for (line_index, line) in item_lines.into_iter().enumerate() {
379                            let mut row = Vec::new();
380                            if line_index == 0 {
381                                row.push(Segment::new(prefix.clone(), Some(prefix_style.clone())));
382                            } else {
383                                row.push(Segment::new(" ".repeat(prefix_width), None));
384                            }
385                            row.extend(line);
386                            lines.push(row);
387                        }
388                    }
389                }
390                Block::Quote(paragraphs) => {
391                    let prefix_style = Style::parse("magenta").expect("valid style");
392                    // Upstream renders quote content at `max_width - 4`.
393                    let content_width = width.saturating_sub(4);
394                    for paragraph in paragraphs {
395                        let quote_lines =
396                            paragraph.render_lines(console.theme(), base, Some(content_width));
397                        for line in quote_lines {
398                            let mut row = vec![Segment::new(
399                                QUOTE_PREFIX.to_string(),
400                                Some(prefix_style.clone()),
401                            )];
402                            row.extend(line);
403                            lines.push(row);
404                        }
405                    }
406                }
407                Block::Code { language, code } => {
408                    // Render the code block via the Syntax renderable (functional,
409                    // not byte-parity — see DIVERGENCES). Split its segment stream
410                    // back into per-line rows for the shared join below.
411                    let syntax = Syntax::new(code.as_str(), language.as_str());
412                    let segments = syntax.rich_render(console, options);
413                    lines.extend(Segment::split_lines(&segments));
414                }
415                Block::Rule => {
416                    let style = Style::parse("dim").expect("valid style");
417                    lines.push(vec![Segment::new("-".repeat(width), Some(style))]);
418                }
419                Block::Table {
420                    alignments,
421                    headers,
422                    rows,
423                } => {
424                    // Build the Table exactly as upstream's TableElement does:
425                    // box=SIMPLE, pad_edge=False, collapse_padding=True, and the
426                    // markdown.table.border/header styles. Per-column justify comes
427                    // from the alignment row.
428                    let mut table = Table::new()
429                        .box_set(SIMPLE)
430                        .pad_edge(false)
431                        .collapse_padding(true)
432                        .style(Style::parse(TABLE_BORDER_STYLE).expect("valid style"));
433                    let header_style = Style::parse(TABLE_HEADER_STYLE).expect("valid style");
434                    for (col, header) in headers.iter().enumerate() {
435                        let justify = alignments.get(col).copied().unwrap_or(Justify::Left);
436                        table.add_column_justify(header.as_str(), justify);
437                        table.column_header_style(header_style.clone());
438                    }
439                    for row in rows {
440                        let refs: Vec<&str> = row.iter().map(String::as_str).collect();
441                        table.add_row(&refs);
442                    }
443                    let segments = table.rich_render(console, options);
444                    lines.extend(Segment::split_lines(&segments));
445                }
446            }
447        }
448
449        // Upstream's thematic-break element emits a trailing line break, which is
450        // only observable when the rule is the document's last block: it adds one
451        // extra blank line there (a mid-document rule merges with the normal block
452        // separator). Match that.
453        if matches!(self.blocks.last(), Some(Block::Rule)) {
454            lines.push(Vec::new());
455        }
456
457        let mut segments = Vec::new();
458        let last = lines.len().saturating_sub(1);
459        for (index, line) in lines.into_iter().enumerate() {
460            segments.extend(line);
461            if index != last {
462                segments.push(Segment::line());
463            }
464        }
465        segments
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::color::ColorSystem;
473
474    fn render(source: &str) -> String {
475        let console = Console::builder()
476            .force_terminal(true)
477            .color_system(Some(ColorSystem::Truecolor))
478            .width(20)
479            .build();
480        console.render_to_string(&Markdown::new(source))
481    }
482
483    #[test]
484    fn paragraph_inline_styles() {
485        assert_eq!(
486            render("a `x` b"),
487            "a \x1b[1;36;40mx\x1b[0m b               "
488        );
489    }
490
491    #[test]
492    fn link_renders_osc8_hyperlink() {
493        // Matches real rich 15.0.0 exactly except upstream's random `id=` field,
494        // which we omit for determinism (DIVERGENCES). markdown.link_url styling
495        // is "underline blue" (4;34).
496        let out = render("See [the site](https://example.com) now.");
497        assert!(
498            out.contains(
499                "\x1b]8;;https://example.com\x1b\\\x1b[4;34mthe site\x1b[0m\x1b]8;;\x1b\\"
500            ),
501            "got {out:?}"
502        );
503        assert!(!out.contains("id="), "we omit the random link id");
504    }
505
506    #[test]
507    fn fenced_code_block_is_highlighted() {
508        // Functional (not byte-parity): the fenced code renders via Syntax, so
509        // its text survives and it's colored.
510        let console = Console::builder()
511            .force_terminal(true)
512            .color_system(Some(ColorSystem::Truecolor))
513            .width(24)
514            .no_color(false)
515            .build();
516        let out = console.render_to_string(&Markdown::new("```rust\nfn main() {}\n```"));
517        assert!(out.contains("fn"), "got {out:?}");
518        assert!(out.contains("main"));
519        assert!(out.contains('\x1b'), "code block should be colored");
520    }
521
522    #[test]
523    fn headings() {
524        assert_eq!(render("# Head"), "        \x1b[1;4mHead\x1b[0m        ");
525        assert_eq!(render("## Sub"), "\x1b[4;35mSub\x1b[0m                 ");
526    }
527
528    #[test]
529    fn two_paragraphs_separated_by_blank_line() {
530        assert_eq!(
531            render("First para.\n\nSecond para."),
532            "First para.         \n\nSecond para.        "
533        );
534    }
535
536    #[test]
537    fn bullet_list() {
538        assert_eq!(
539            render("- one\n- two"),
540            "\n\x1b[1m \u{2022} \x1b[0mone              \n\x1b[1m \u{2022} \x1b[0mtwo              "
541        );
542    }
543
544    #[test]
545    fn ordered_list() {
546        assert_eq!(
547            render("1. first\n2. second"),
548            "\n\x1b[36m 1 \x1b[0mfirst            \n\x1b[36m 2 \x1b[0msecond           "
549        );
550    }
551
552    #[test]
553    fn block_quote() {
554        assert_eq!(
555            render("> quoted text"),
556            "\n\x1b[35m\u{258c} \x1b[0m\x1b[35mquoted text\x1b[0m\x1b[35m     \x1b[0m"
557        );
558    }
559
560    #[test]
561    fn gfm_table() {
562        // Byte-parity is guaranteed by the `markdown_table` golden; this guards
563        // the parser wiring (tables enabled, cells + alignment collected).
564        let console = Console::builder()
565            .force_terminal(true)
566            .color_system(Some(ColorSystem::Truecolor))
567            .width(40)
568            .no_color(false)
569            .build();
570        let md = "| Name | Age |\n| :--- | ---: |\n| Alice | 30 |\n| Bob | 7 |\n";
571        let out = console.render_to_string(&Markdown::new(md));
572        assert!(out.contains("Name"), "header present: {out:?}");
573        assert!(out.contains("Alice"), "body cell present");
574        assert!(out.contains('\u{2500}'), "SIMPLE box head rule present");
575        // Right-justified Age column: "30" padded on the left, "7" further.
576        assert!(out.contains(" 30"), "right-justified 30");
577        assert!(out.contains("  7"), "right-justified 7");
578    }
579
580    #[test]
581    fn thematic_break() {
582        assert_eq!(
583            render("a\n\n---\n\nb"),
584            "a                   \n\n\x1b[2m--------------------\x1b[0m\n\nb                   "
585        );
586    }
587
588    #[test]
589    fn thematic_break_at_end_adds_trailing_blank() {
590        // A document ending with a rule emits one extra trailing blank line
591        // (upstream's hr element yields a trailing break). Byte-parity is
592        // guaranteed by the `markdown_hr_end` golden; here we assert the shape.
593        assert_eq!(
594            render("a\n\n---"),
595            "a                   \n\n\x1b[2m--------------------\x1b[0m\n"
596        );
597    }
598}