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