Skip to main content

sparrow/tui/formatters/
markdown.rs

1// ─── Markdown → terminal formatting ───────────────────────────────────────────
2// Uses pulldown-cmark to parse markdown and emits ANSI-styled terminal output.
3// Headers become bold, code blocks highlighted, lists indented, links shown as
4// [text](url), bold/italic via ANSI styles.
5
6use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, TagEnd};
7use syntect::highlighting::ThemeSet;
8use syntect::parsing::SyntaxSet;
9use syntect::util::LinesWithEndings;
10
11/// ANSI style constants for markdown elements.
12pub struct MarkdownStyles {
13    pub h1: &'static str,
14    pub h2: &'static str,
15    pub h3: &'static str,
16    pub bold: &'static str,
17    pub italic: &'static str,
18    pub code_inline: &'static str,
19    pub code_block_lang: &'static str,
20    pub link_url: &'static str,
21    pub list_marker: &'static str,
22    pub blockquote: &'static str,
23    pub hr: &'static str,
24    pub text: &'static str,
25    pub reset: &'static str,
26}
27
28impl Default for MarkdownStyles {
29    fn default() -> Self {
30        Self {
31            h1: "\x1b[1;38;2;242;169;60m",      // bold amber
32            h2: "\x1b[1;38;2;111;166;230m",     // bold blue
33            h3: "\x1b[1;38;2;78;201;176m",       // bold teal
34            bold: "\x1b[1m",
35            italic: "\x1b[3m",
36            code_inline: "\x1b[48;2;22;18;13;38;2;242;201;76m", // gold on dark bg
37            code_block_lang: "\x1b[38;2;137;125;108m", // dim
38            link_url: "\x1b[38;2;111;166;230m",  // blue
39            list_marker: "\x1b[38;2;242;169;60m", // amber
40            blockquote: "\x1b[38;2;137;125;108m", // dim
41            hr: "\x1b[38;2;92;83;70m",            // dimmer
42            text: "",                              // default terminal colour
43            reset: "\x1b[0m",
44        }
45    }
46}
47
48/// Default syntax theme for fenced code blocks.
49const DEFAULT_SYNTAX_THEME: &str = "base16-ocean.dark";
50
51/// Convert a markdown string to ANSI-formatted terminal text.
52///
53/// Headers (# → bold), fenced code blocks (``` → syntax highlighted), lists,
54/// bold/italic, inline code, blockquotes, and horizontal rules are all styled.
55pub fn render_markdown(md: &str) -> String {
56    render_markdown_with_theme(md, DEFAULT_SYNTAX_THEME)
57}
58
59/// Render markdown with a specific syntax highlighting theme for code blocks.
60pub fn render_markdown_with_theme(md: &str, syntax_theme: &str) -> String {
61    let styles = MarkdownStyles::default();
62    let mut options = Options::empty();
63    options.insert(Options::ENABLE_STRIKETHROUGH);
64    options.insert(Options::ENABLE_TABLES);
65    options.insert(Options::ENABLE_FOOTNOTES);
66    options.insert(Options::ENABLE_TASKLISTS);
67
68    let parser = Parser::new_ext(md, options);
69
70    let ss = SyntaxSet::load_defaults_newlines();
71    let ts = ThemeSet::load_defaults();
72    let theme = ts
73        .themes
74        .get(syntax_theme)
75        .or_else(|| ts.themes.get(DEFAULT_SYNTAX_THEME))
76        .expect("built-in theme should exist");
77
78    let mut out = String::with_capacity(md.len() * 2);
79    let mut in_code_block = false;
80    let mut code_lang: Option<String> = None;
81    let mut current_link_url: Option<String> = None;
82    let mut code_buf = String::new();
83    let mut list_depth: usize = 0;
84    let mut ordered_index: Option<u64> = None;
85
86    for event in parser {
87        match event {
88            Event::Start(tag) => match tag {
89                Tag::Heading {
90                    level,
91                    id: _,
92                    classes: _,
93                    attrs: _,
94                } => {
95                    let style = match level {
96                        pulldown_cmark::HeadingLevel::H1 => styles.h1,
97                        pulldown_cmark::HeadingLevel::H2 => styles.h2,
98                        _ => styles.h3,
99                    };
100                    out.push_str(style);
101                    // Print # markers
102                    let hashes: String = match level {
103                        pulldown_cmark::HeadingLevel::H1 => "# ".into(),
104                        pulldown_cmark::HeadingLevel::H2 => "## ".into(),
105                        pulldown_cmark::HeadingLevel::H3 => "### ".into(),
106                        pulldown_cmark::HeadingLevel::H4 => "#### ".into(),
107                        pulldown_cmark::HeadingLevel::H5 => "##### ".into(),
108                        pulldown_cmark::HeadingLevel::H6 => "###### ".into(),
109                    };
110                    out.push_str(&hashes);
111                }
112                Tag::CodeBlock(kind) => {
113                    in_code_block = true;
114                    code_buf.clear();
115                    if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind {
116                        let lang_str = lang.to_string();
117                        if !lang_str.is_empty() {
118                            code_lang = Some(lang_str);
119                        } else {
120                            code_lang = None;
121                        }
122                    } else {
123                        code_lang = None;
124                    }
125
126                    // Print language tag
127                    out.push('\n');
128                    if let Some(ref lang) = code_lang {
129                        out.push_str(&format!(
130                            "{style}  ┌─ {lang} ─────────────────────────────{reset}\n",
131                            style = styles.code_block_lang,
132                            reset = styles.reset
133                        ));
134                    } else {
135                        out.push_str(&format!(
136                            "{style}  ┌─ code ───────────────────────────────{reset}\n",
137                            style = styles.code_block_lang,
138                            reset = styles.reset
139                        ));
140                    }
141                }
142                Tag::List(order) => {
143                    list_depth += 1;
144                    if let Some(start) = order {
145                        ordered_index = Some(start);
146                    } else {
147                        ordered_index = None;
148                    }
149                }
150                Tag::Item => {
151                    out.push_str(styles.list_marker);
152                    let indent = "  ".repeat(list_depth.saturating_sub(1));
153                    out.push_str(&indent);
154                    if let Some(idx) = ordered_index.as_mut() {
155                        out.push_str(&format!("{idx}. "));
156                        *idx += 1;
157                    } else {
158                        out.push_str("• ");
159                    }
160                    out.push_str(styles.reset);
161                }
162                Tag::BlockQuote(_) => {
163                    out.push_str(styles.blockquote);
164                }
165                Tag::Strong => {
166                    out.push_str(styles.bold);
167                }
168                Tag::Emphasis => {
169                    out.push_str(styles.italic);
170                }
171                Tag::Strikethrough => {
172                    // Strikethrough via ANSI is not widely supported; use dim
173                    out.push_str(styles.blockquote);
174                }
175                Tag::Link {
176                    link_type: _,
177                    dest_url: _,
178                    title: _,
179                    id: _,
180                } => {
181                    // Links will be rendered when we hit the End tag
182                }
183                Tag::Image {
184                    link_type: _,
185                    dest_url: _,
186                    title: _,
187                    id: _,
188                } => {
189                    out.push_str(styles.blockquote);
190                    out.push_str("[img: ");
191                }
192                _ => {}
193            },
194            Event::End(tag) => match tag {
195                TagEnd::Heading(_) => {
196                    out.push_str(styles.reset);
197                    out.push('\n');
198                    // Underline for h1, h2
199                }
200                TagEnd::CodeBlock => {
201                    in_code_block = false;
202                    // Highlight the code buffer
203                    let lang = code_lang.as_deref().unwrap_or("");
204                    let highlighted = highlight_code_block(&code_buf, lang, theme, &ss);
205                    // Indent each line
206                    for line in highlighted.lines() {
207                        out.push_str("  │ ");
208                        out.push_str(line);
209                        out.push('\n');
210                    }
211                    out.push_str(&format!(
212                        "{style}  └──────────────────────────────────────────{reset}\n",
213                        style = styles.code_block_lang,
214                        reset = styles.reset
215                    ));
216                    code_buf.clear();
217                    code_lang = None;
218                }
219                TagEnd::List(_) => {
220                    list_depth = list_depth.saturating_sub(1);
221                    ordered_index = None;
222                }
223                TagEnd::Item => {
224                    out.push('\n');
225                }
226                TagEnd::BlockQuote(_) => {
227                    out.push_str(styles.reset);
228                }
229                TagEnd::Strong => {
230                    out.push_str(styles.reset);
231                }
232                TagEnd::Emphasis => {
233                    out.push_str(styles.reset);
234                }
235                TagEnd::Strikethrough => {
236                    out.push_str(styles.reset);
237                }
238                TagEnd::Link => {
239                    // URL was captured from start tag, just add closing marker
240                    out.push_str(styles.blockquote);
241                    out.push(' ');
242                    out.push_str(styles.link_url);
243                    if let Some(ref url) = current_link_url {
244                        out.push('(');
245                        out.push_str(url);
246                        out.push(')');
247                    }
248                    out.push_str(styles.reset);
249                    current_link_url = None;
250                }
251                TagEnd::Image => {
252                    out.push_str(styles.blockquote);
253                    out.push(']');
254                    out.push_str(styles.reset);
255                    current_link_url = None;
256                }
257                _ => {}
258            },
259            Event::Text(text) | Event::Code(text) => {
260                if in_code_block {
261                    code_buf.push_str(&text);
262                } else {
263                    out.push_str(&text);
264                }
265            }
266            Event::Html(raw) => {
267                // Strip HTML tags, keep content
268                let stripped = strip_html_tags(&raw);
269                if !stripped.is_empty() {
270                    out.push_str(&stripped);
271                }
272            }
273            Event::InlineHtml(raw) => {
274                out.push_str(styles.blockquote);
275                out.push_str(&raw);
276                out.push_str(styles.reset);
277            }
278            Event::InlineMath(raw) | Event::DisplayMath(raw) => {
279                out.push_str(styles.code_inline);
280                out.push_str(&raw);
281                out.push_str(styles.reset);
282            }
283            Event::FootnoteReference(name) => {
284                out.push_str(styles.link_url);
285                out.push_str(&format!("[^{}]", name));
286                out.push_str(styles.reset);
287            }
288            Event::SoftBreak => {
289                out.push(' ');
290            }
291            Event::HardBreak => {
292                out.push('\n');
293            }
294            Event::Rule => {
295                out.push('\n');
296                out.push_str(styles.hr);
297                out.push_str(&"─".repeat(60));
298                out.push_str(styles.reset);
299                out.push('\n');
300            }
301            Event::TaskListMarker(checked) => {
302                out.push_str(styles.list_marker);
303                if checked {
304                    out.push_str("[x] ");
305                } else {
306                    out.push_str("[ ] ");
307                }
308                out.push_str(styles.reset);
309            }
310        }
311    }
312
313    out
314}
315
316/// Simple HTML tag stripper for inline HTML in markdown.
317fn strip_html_tags(html: &str) -> String {
318    let mut out = String::new();
319    let mut in_tag = false;
320    for ch in html.chars() {
321        if ch == '<' {
322            in_tag = true;
323        } else if ch == '>' {
324            in_tag = false;
325        } else if !in_tag {
326            out.push(ch);
327        }
328    }
329    out
330}
331
332/// Highlight a code block string using syntect with line numbers.
333fn highlight_code_block(
334    code: &str,
335    language: &str,
336    theme: &syntect::highlighting::Theme,
337    ss: &SyntaxSet,
338) -> String {
339    use syntect::easy::HighlightLines;
340
341    let syntax = super::code::language_syntax(ss, language);
342
343    let mut h = HighlightLines::new(syntax, theme);
344    let mut out = String::with_capacity(code.len() * 2);
345
346    for line in LinesWithEndings::from(code) {
347        let Ok(ranges) = h.highlight_line(line, ss) else {
348            out.push_str(line);
349            continue;
350        };
351        let escaped = syntect::util::as_24_bit_terminal_escaped(&ranges[..], false);
352        out.push_str(&escaped);
353    }
354
355    out.trim_end_matches('\n').to_string()
356}