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