Skip to main content

rusty_rich/
markdown.rs

1//! Markdown rendering — equivalent to Rich's `markdown.py`.
2//!
3//! Uses `pulldown-cmark` for parsing and renders headings, code blocks,
4//! lists, tables, blockquotes, and inline formatting.
5
6use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
7
8use crate::console::{ConsoleOptions, RenderResult, Renderable};
9use crate::rule::Rule;
10use crate::segment::Segment;
11use crate::style::Style;
12use crate::align::AlignMethod;
13use crate::table::{Cell, Column, Table};
14
15/// Render markdown text.
16pub fn render_markdown(md: &str) -> MarkdownRender {
17    MarkdownRender {
18        source: md.to_string(),
19        width: None,
20        code_theme: "default".to_string(),
21        hyperlinks: true,
22    }
23}
24
25/// Renders markdown text to styled terminal output via [`Renderable`].
26#[derive(Debug, Clone)]
27pub struct MarkdownRender {
28    source: String,
29    width: Option<usize>,
30    code_theme: String,
31    hyperlinks: bool,
32}
33
34impl MarkdownRender {
35    /// Set a fixed rendering width (defaults to the console width).
36    pub fn width(mut self, w: usize) -> Self { self.width = Some(w); self }
37
38    /// Set the code syntax-highlighting theme (default: `"default"`).
39    pub fn code_theme(mut self, theme: impl Into<String>) -> Self {
40        self.code_theme = theme.into();
41        self
42    }
43
44    /// Enable or disable hyperlink rendering (default: true).
45    pub fn hyperlinks(mut self, enabled: bool) -> Self {
46        self.hyperlinks = enabled;
47        self
48    }
49
50    fn get_style(name: &str) -> Style {
51        use crate::theme::default_theme;
52        let theme = default_theme();
53        theme.get(name).cloned().unwrap_or(Style::new())
54    }
55
56    /// Look up a code-block style that respects `self.code_theme`.
57    fn code_style(&self) -> Style {
58        use crate::theme::default_theme;
59        let theme = default_theme();
60        let key = format!("markdown.code.{}", self.code_theme);
61        theme
62            .get(&key)
63            .cloned()
64            .unwrap_or_else(|| Self::get_style("markdown.code"))
65    }
66}
67
68impl Renderable for MarkdownRender {
69    fn render(&self, options: &ConsoleOptions) -> RenderResult {
70        let width = self.width.unwrap_or(options.max_width);
71        let parser = Parser::new_ext(&self.source, Options::all());
72
73        let mut lines: Vec<Vec<Segment>> = Vec::new();
74        let mut current_line: Vec<Segment> = Vec::new();
75        let mut in_code_block = false;
76        let mut heading_level = 0u8;
77        let mut list_depth = 0usize;
78        let mut current_link: Option<String> = None;
79        let mut link_text: Option<String> = None;
80        let mut in_table = false;
81        let mut table_alignments: Vec<Alignment> = Vec::new();
82        let mut table_rows: Vec<Vec<String>> = Vec::new();
83        let mut _table_is_header = false;
84        let mut current_row: Vec<String> = Vec::new();
85        let mut current_cell_text = String::new();
86
87        for event in parser {
88            match event {
89                Event::Start(Tag::Heading { level, .. }) => {
90                    heading_level = level as u8;
91                    let style = match level {
92                        HeadingLevel::H1 => Self::get_style("markdown.h1"),
93                        HeadingLevel::H2 => Self::get_style("markdown.h2"),
94                        _ => Style::new().bold(true),
95                    };
96                    let prefix = "#".repeat(level as usize);
97                    current_line.push(Segment::styled(
98                        format!("{prefix} "),
99                        style.clone(),
100                    ));
101                }
102                Event::End(TagEnd::Heading(_)) => {
103                    lines.push(current_line.clone());
104                    current_line.clear();
105                    // Add a rule under H1/H2
106                    if heading_level <= 2 {
107                        let rule_char = if heading_level == 1 { '═' } else { '─' };
108                        let rule_line = rule_char.to_string().repeat(width);
109                        lines.push(vec![Segment::new(rule_line), Segment::line()]);
110                    }
111                }
112                Event::Start(Tag::Paragraph) => {}
113                Event::End(TagEnd::Paragraph) => {
114                    if !current_line.is_empty() {
115                        current_line.push(Segment::line());
116                        lines.push(current_line.clone());
117                        current_line.clear();
118                    }
119                    // Add blank line after paragraph
120                    lines.push(vec![Segment::line()]);
121                }
122                Event::Start(Tag::CodeBlock(kind)) => {
123                    in_code_block = true;
124                    let lang = match kind {
125                        CodeBlockKind::Fenced(lang) => {
126                            if lang.is_empty() { String::new() } else { lang.to_string() }
127                        }
128                        CodeBlockKind::Indented => String::new(),
129                    };
130                    let title = if lang.is_empty() {
131                        "Code".to_string()
132                    } else {
133                        format!("Code: {lang}")
134                    };
135                    // Code block opening
136                    let code_style = self.code_style();
137                    current_line.push(Segment::styled(
138                        format!("┌─ {title} "),
139                        code_style.clone(),
140                    ));
141                    current_line.push(Segment::line());
142                    lines.push(current_line.clone());
143                    current_line.clear();
144                }
145                Event::End(TagEnd::CodeBlock) => {
146                    in_code_block = false;
147                    if !current_line.is_empty() {
148                        lines.push(current_line.clone());
149                        current_line.clear();
150                    }
151                    let code_style = self.code_style();
152                    lines.push(vec![Segment::styled(
153                        format!("└{}", "─".repeat(width.saturating_sub(2))),
154                        code_style,
155                    ), Segment::line()]);
156                }
157                Event::Start(Tag::List(_)) => {
158                    list_depth += 1;
159                }
160                Event::End(TagEnd::List(_)) => {
161                    list_depth = list_depth.saturating_sub(1);
162                }
163                Event::Start(Tag::Item) => {
164                    let indent = "  ".repeat(list_depth.saturating_sub(1));
165                    let bullet = if list_depth > 1 { "◦" } else { "•" };
166                    current_line.push(Segment::new(format!("{indent}{bullet} ")));
167                }
168                Event::End(TagEnd::Item) => {
169                    lines.push(current_line.clone());
170                    current_line.clear();
171                }
172                Event::Start(Tag::BlockQuote) => {
173                    let quote_style = Self::get_style("markdown.blockquote");
174                    current_line.push(Segment::styled("▌ ", quote_style));
175                }
176                Event::End(TagEnd::BlockQuote) => {
177                    lines.push(current_line.clone());
178                    current_line.clear();
179                }
180                Event::Start(Tag::Emphasis) => {
181                    current_line.push(Segment::styled("", Style::new().italic(true)));
182                }
183                Event::End(TagEnd::Emphasis) => {
184                    // Inline — handled via style stack
185                }
186                Event::Start(Tag::Strong) => {
187                    // handled inline
188                }
189                Event::End(TagEnd::Strong) => {}
190                Event::Start(Tag::Link { dest_url, .. }) => {
191                    current_link = Some(dest_url.to_string());
192                    link_text = Some(String::new());
193                }
194                Event::End(TagEnd::Link) => {
195                    if let (Some(url), Some(text)) = (current_link.take(), link_text.take()) {
196                        let link_style = Self::get_style("markdown.link");
197                        let display = if text.is_empty() {
198                            url.clone()
199                        } else if self.hyperlinks {
200                            format!("{text} ({url})")
201                        } else {
202                            text
203                        };
204                        current_line.push(Segment::styled(display, link_style));
205                    }
206                }
207                Event::Text(text) | Event::Code(text) => {
208                    let s: &str = &text;
209                    if in_table {
210                        current_cell_text.push_str(s);
211                        // Also handle link text collection inside table cells
212                        if current_link.is_some() {
213                            if let Some(ref mut lt) = link_text {
214                                lt.push_str(s);
215                            }
216                        }
217                    } else {
218                        // Collect link text if we're inside a link
219                        if current_link.is_some() {
220                            if let Some(ref mut lt) = link_text {
221                                lt.push_str(s);
222                            }
223                        }
224                        if in_code_block {
225                            // Indent code
226                            for line in s.lines() {
227                                current_line.push(Segment::new(format!("│ {line}")));
228                                current_line.push(Segment::line());
229                                lines.push(current_line.clone());
230                                current_line.clear();
231                            }
232                        } else {
233                            current_line.push(Segment::new(s));
234                        }
235                    }
236                }
237                Event::SoftBreak => {
238                    current_line.push(Segment::new(" "));
239                }
240                Event::HardBreak => {
241                    current_line.push(Segment::line());
242                    lines.push(current_line.clone());
243                    current_line.clear();
244                }
245                Event::Rule => {
246                    let rule = Rule::new().characters("─");
247                    let res = rule.render(options);
248                    lines.extend(res.lines);
249                }
250                Event::Start(Tag::Table(alignments)) => {
251                    in_table = true;
252                    table_alignments = alignments;
253                    table_rows = Vec::new();
254                }
255                Event::End(TagEnd::Table) => {
256                    in_table = false;
257                    if !table_rows.is_empty() {
258                        let mut table = Table::new();
259                        table.show_header = false;
260                        table.show_edge = true;
261                        for align in &table_alignments {
262                            let justify = match align {
263                                Alignment::Left => AlignMethod::Left,
264                                Alignment::Right => AlignMethod::Right,
265                                Alignment::Center => AlignMethod::Center,
266                                Alignment::None => AlignMethod::Left,
267                            };
268                            table.add_column(Column::new("").justify(justify));
269                        }
270                        for (i, row) in table_rows.iter().enumerate() {
271                            let cells: Vec<Cell> = row
272                                .iter()
273                                .enumerate()
274                                .map(|(_, c)| {
275                                    if i == 0 {
276                                        Cell::new(c.clone()).style(Style::new().bold(true))
277                                    } else {
278                                        Cell::new(c.clone())
279                                    }
280                                })
281                                .collect();
282                            table.add_row(cells);
283                        }
284                        let result = table.render(options);
285                        lines.extend(result.lines);
286                    }
287                }
288                Event::Start(Tag::TableHead) => {
289                    _table_is_header = true;
290                }
291                Event::End(TagEnd::TableHead) => {
292                    _table_is_header = false;
293                }
294                Event::Start(Tag::TableRow) => {
295                    current_row = Vec::new();
296                }
297                Event::End(TagEnd::TableRow) => {
298                    table_rows.push(current_row.clone());
299                    current_row.clear();
300                }
301                Event::Start(Tag::Image { dest_url, title, .. }) => {
302                    // Render image as a styled placeholder with alt text and URL
303                    let image_style = Self::get_style("markdown.image");
304                    let title_str = if title.is_empty() {
305                        String::new()
306                    } else {
307                        format!(" \"{title}\"")
308                    };
309                    let image_text = format!("🖼 [Image: {dest_url}{title_str}]");
310                    current_line.push(Segment::styled(image_text, image_style));
311                    current_line.push(Segment::line());
312                    lines.push(current_line.clone());
313                    current_line.clear();
314                }
315                Event::End(TagEnd::Image) => {
316                    // Image is self-contained; handled on Start
317                }
318                Event::Start(Tag::TableCell) => {
319                    current_cell_text = String::new();
320                }
321                Event::End(TagEnd::TableCell) => {
322                    current_row.push(current_cell_text.clone());
323                    current_cell_text.clear();
324                }
325                _ => {}
326            }
327        }
328
329        // Flush remaining
330        if !current_line.is_empty() {
331            current_line.push(Segment::line());
332            lines.push(current_line);
333        }
334
335        RenderResult { lines, items: Vec::new() }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn test_markdown_heading() {
345        let md = render_markdown("# Hello\n\nWorld");
346        let opts = ConsoleOptions::default();
347        let result = md.render(&opts);
348        let ansi = result.to_ansi();
349        assert!(ansi.contains("Hello"));
350    }
351}