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::{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;
12
13/// Render markdown text.
14pub fn render_markdown(md: &str) -> MarkdownRender {
15    MarkdownRender {
16        source: md.to_string(),
17        width: None,
18        code_theme: "default".to_string(),
19        hyperlinks: true,
20    }
21}
22
23#[derive(Debug, Clone)]
24pub struct MarkdownRender {
25    source: String,
26    width: Option<usize>,
27    code_theme: String,
28    hyperlinks: bool,
29}
30
31impl MarkdownRender {
32    pub fn width(mut self, w: usize) -> Self { self.width = Some(w); self }
33
34    fn get_style(name: &str) -> Style {
35        use crate::theme::default_theme;
36        let theme = default_theme();
37        theme.get(name).cloned().unwrap_or(Style::new())
38    }
39}
40
41impl Renderable for MarkdownRender {
42    fn render(&self, options: &ConsoleOptions) -> RenderResult {
43        let width = self.width.unwrap_or(options.max_width);
44        let parser = Parser::new_ext(&self.source, Options::all());
45
46        let mut lines: Vec<Vec<Segment>> = Vec::new();
47        let mut current_line: Vec<Segment> = Vec::new();
48        let mut in_code_block = false;
49        let mut in_heading = false;
50        let mut heading_level = 0u8;
51        let mut in_paragraph = false;
52        let mut list_depth = 0usize;
53        let mut current_link: Option<String> = None;
54        let mut link_text: Option<String> = None;
55
56        for event in parser {
57            match event {
58                Event::Start(Tag::Heading { level, .. }) => {
59                    in_heading = true;
60                    heading_level = level as u8;
61                    let style = match level {
62                        HeadingLevel::H1 => Self::get_style("markdown.h1"),
63                        HeadingLevel::H2 => Self::get_style("markdown.h2"),
64                        _ => Style::new().bold(true),
65                    };
66                    let prefix = "#".repeat(level as usize);
67                    current_line.push(Segment::styled(
68                        format!("{prefix} "),
69                        style.clone(),
70                    ));
71                }
72                Event::End(TagEnd::Heading(_)) => {
73                    in_heading = false;
74                    lines.push(current_line.clone());
75                    current_line.clear();
76                    // Add a rule under H1/H2
77                    if heading_level <= 2 {
78                        let rule_char = if heading_level == 1 { '═' } else { '─' };
79                        let rule_line = rule_char.to_string().repeat(width);
80                        lines.push(vec![Segment::new(rule_line), Segment::line()]);
81                    }
82                }
83                Event::Start(Tag::Paragraph) => {
84                    in_paragraph = true;
85                }
86                Event::End(TagEnd::Paragraph) => {
87                    in_paragraph = false;
88                    if !current_line.is_empty() {
89                        current_line.push(Segment::line());
90                        lines.push(current_line.clone());
91                        current_line.clear();
92                    }
93                    // Add blank line after paragraph
94                    lines.push(vec![Segment::line()]);
95                }
96                Event::Start(Tag::CodeBlock(kind)) => {
97                    in_code_block = true;
98                    let lang = match kind {
99                        CodeBlockKind::Fenced(lang) => {
100                            if lang.is_empty() { String::new() } else { lang.to_string() }
101                        }
102                        CodeBlockKind::Indented => String::new(),
103                    };
104                    let title = if lang.is_empty() {
105                        "Code".to_string()
106                    } else {
107                        format!("Code: {lang}")
108                    };
109                    // Code block opening
110                    let code_style = Self::get_style("markdown.code");
111                    current_line.push(Segment::styled(
112                        format!("┌─ {title} "),
113                        code_style.clone(),
114                    ));
115                    current_line.push(Segment::line());
116                    lines.push(current_line.clone());
117                    current_line.clear();
118                }
119                Event::End(TagEnd::CodeBlock) => {
120                    in_code_block = false;
121                    if !current_line.is_empty() {
122                        lines.push(current_line.clone());
123                        current_line.clear();
124                    }
125                    let code_style = Self::get_style("markdown.code");
126                    lines.push(vec![Segment::styled(
127                        format!("└{}", "─".repeat(width.saturating_sub(2))),
128                        code_style,
129                    ), Segment::line()]);
130                }
131                Event::Start(Tag::List(_)) => {
132                    list_depth += 1;
133                }
134                Event::End(TagEnd::List(_)) => {
135                    list_depth = list_depth.saturating_sub(1);
136                }
137                Event::Start(Tag::Item) => {
138                    let indent = "  ".repeat(list_depth.saturating_sub(1));
139                    let bullet = if list_depth > 1 { "◦" } else { "•" };
140                    current_line.push(Segment::new(format!("{indent}{bullet} ")));
141                }
142                Event::End(TagEnd::Item) => {
143                    lines.push(current_line.clone());
144                    current_line.clear();
145                }
146                Event::Start(Tag::BlockQuote) => {
147                    let quote_style = Self::get_style("markdown.blockquote");
148                    current_line.push(Segment::styled("▌ ", quote_style));
149                }
150                Event::End(TagEnd::BlockQuote) => {
151                    lines.push(current_line.clone());
152                    current_line.clear();
153                }
154                Event::Start(Tag::Emphasis) => {
155                    current_line.push(Segment::styled("", Style::new().italic(true)));
156                }
157                Event::End(TagEnd::Emphasis) => {
158                    // Inline — handled via style stack
159                }
160                Event::Start(Tag::Strong) => {
161                    // handled inline
162                }
163                Event::End(TagEnd::Strong) => {}
164                Event::Start(Tag::Link { dest_url, .. }) => {
165                    current_link = Some(dest_url.to_string());
166                    link_text = Some(String::new());
167                }
168                Event::End(TagEnd::Link) => {
169                    if let (Some(url), Some(text)) = (current_link.take(), link_text.take()) {
170                        let link_style = Self::get_style("markdown.link");
171                        let display = if text.is_empty() { url.clone() } else { text };
172                        current_line.push(Segment::styled(
173                            format!("{display} ({url})"),
174                            link_style,
175                        ));
176                    }
177                }
178                Event::Text(text) | Event::Code(text) => {
179                    let s: &str = &text;
180                    // Collect link text if we're inside a link
181                    if current_link.is_some() {
182                        if let Some(ref mut lt) = link_text {
183                            lt.push_str(s);
184                        }
185                    }
186                    if in_code_block {
187                        // Indent code
188                        for line in s.lines() {
189                            current_line.push(Segment::new(format!("│ {line}")));
190                            current_line.push(Segment::line());
191                            lines.push(current_line.clone());
192                            current_line.clear();
193                        }
194                    } else {
195                        current_line.push(Segment::new(s));
196                    }
197                }
198                Event::SoftBreak => {
199                    current_line.push(Segment::new(" "));
200                }
201                Event::HardBreak => {
202                    current_line.push(Segment::line());
203                    lines.push(current_line.clone());
204                    current_line.clear();
205                }
206                Event::Rule => {
207                    let rule = Rule::new().characters("─");
208                    let res = rule.render(options);
209                    lines.extend(res.lines);
210                }
211                _ => {}
212            }
213        }
214
215        // Flush remaining
216        if !current_line.is_empty() {
217            current_line.push(Segment::line());
218            lines.push(current_line);
219        }
220
221        RenderResult { lines, items: Vec::new() }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_markdown_heading() {
231        let md = render_markdown("# Hello\n\nWorld");
232        let opts = ConsoleOptions::default();
233        let result = md.render(&opts);
234        let ansi = result.to_ansi();
235        assert!(ansi.contains("Hello"));
236    }
237}