Skip to main content

intelli_shell/utils/
markdown.rs

1use crossterm::style::{Attribute, Color, ContentStyle, Stylize};
2use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
3
4use crate::config::Theme;
5
6/// Renders a markdown string to an ANSI-styled string using the provided theme.
7///
8/// This implementation provides basic support for:
9/// - Headers (styled based on theme comment style, no hashes, underlined for L3/L4)
10/// - Emphasis (bold, italic)
11/// - Lists (bullet points, task lists)
12/// - Code (inline and blocks, styled based on theme accent and secondary styles)
13/// - Paragraphs
14pub fn render_markdown_to_ansi(markdown: &str, theme: &Theme) -> String {
15    let parser = Parser::new_ext(markdown, Options::all());
16    let mut output = String::new();
17    let mut style_stack: Vec<ContentStyle> = vec![theme.primary];
18    let mut list_depth: usize = 0;
19    let mut code_block_buffer: Option<String> = None;
20
21    fn ensure_newlines(output: &mut String, count: usize) {
22        if output.is_empty() {
23            return;
24        }
25        let current = if output.ends_with("\n\n") {
26            2
27        } else if output.ends_with('\n') {
28            1
29        } else {
30            0
31        };
32        if current < count {
33            output.push_str(&"\n".repeat(count - current));
34        }
35    }
36
37    for event in parser {
38        match event {
39            Event::Start(tag) => match tag {
40                Tag::Heading { level, .. } => {
41                    ensure_newlines(&mut output, 2);
42                    let mut style = theme.comment;
43                    style.attributes.set(Attribute::Bold);
44                    if matches!(level, HeadingLevel::H3 | HeadingLevel::H4) {
45                        style.attributes.set(Attribute::Underlined);
46                    }
47                    style_stack.push(style);
48                }
49                Tag::Paragraph if output.ends_with('\n') || output.is_empty() => {
50                    // Only enforce newlines if the buffer ends in a newline (previous block ended)
51                    // or is empty. If the buffer ends in text/whitespace (like a list bullet),
52                    // we want this paragraph to continue on the same line.
53                    ensure_newlines(&mut output, 2);
54                }
55                Tag::Emphasis => {
56                    let mut style = style_stack.last().cloned().unwrap_or_default();
57                    style.attributes.set(Attribute::Italic);
58                    style_stack.push(style);
59                }
60                Tag::Strong => {
61                    let mut style = style_stack.last().cloned().unwrap_or_default();
62                    style.attributes.set(Attribute::Bold);
63                    style_stack.push(style);
64                }
65                Tag::List(_) => {
66                    list_depth += 1;
67                    if list_depth == 1 {
68                        ensure_newlines(&mut output, 2);
69                    } else {
70                        ensure_newlines(&mut output, 1);
71                    }
72                }
73                Tag::Item => {
74                    ensure_newlines(&mut output, 1);
75                    let indent = " ".repeat(list_depth.saturating_sub(1) * 2);
76                    let bullet = match list_depth {
77                        1 => "• ",
78                        2 => "◦ ",
79                        _ => "▪ ",
80                    };
81                    output.push_str("  "); // Base indentation
82                    output.push_str(&indent);
83                    output.push_str(bullet);
84                }
85                Tag::CodeBlock(kind) => {
86                    ensure_newlines(&mut output, 2);
87                    if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind
88                        && !lang.is_empty()
89                    {
90                        let label_style = ContentStyle::default()
91                            .with(Color::Black)
92                            .on(Color::AnsiValue(244)) // Medium grey
93                            .bold();
94                        output.push_str(&label_style.apply(&format!(" {} ", lang)).to_string());
95                        output.push('\n');
96                    }
97                    let mut style = theme.secondary;
98                    if style.background_color.is_none() {
99                        style.background_color = theme.highlight;
100                    }
101                    style_stack.push(style);
102                    code_block_buffer = Some(String::new());
103                }
104                Tag::Link { .. } => {
105                    let mut style = style_stack.last().cloned().unwrap_or_default();
106                    style.attributes.set(Attribute::Underlined);
107                    style.foreground_color = Some(Color::Blue);
108                    style_stack.push(style);
109                }
110                Tag::Strikethrough => {
111                    let mut style = style_stack.last().cloned().unwrap_or_default();
112                    style.attributes.set(Attribute::CrossedOut);
113                    style_stack.push(style);
114                }
115                _ => {}
116            },
117            Event::End(tag) => match tag {
118                TagEnd::Heading { .. } => {
119                    style_stack.pop();
120                    ensure_newlines(&mut output, 1);
121                }
122                TagEnd::Paragraph => {
123                    ensure_newlines(&mut output, 1);
124                }
125                TagEnd::Emphasis | TagEnd::Strong | TagEnd::Link | TagEnd::Strikethrough => {
126                    style_stack.pop();
127                }
128                TagEnd::CodeBlock => {
129                    let style = style_stack.pop().unwrap_or_default();
130                    if let Some(code) = code_block_buffer.take() {
131                        let lines: Vec<&str> = code.lines().collect();
132                        let max_width = lines.iter().map(|l| l.len()).max().unwrap_or(0);
133                        for line in lines {
134                            let padded = format!("  {:<width$}  ", line, width = max_width);
135                            output.push_str(&style.apply(&padded).to_string());
136                            output.push('\n');
137                        }
138                    }
139                    ensure_newlines(&mut output, 1);
140                }
141                TagEnd::List(_) => {
142                    list_depth = list_depth.saturating_sub(1);
143                    ensure_newlines(&mut output, 1);
144                }
145                TagEnd::Item => {
146                    ensure_newlines(&mut output, 1);
147                }
148                _ => {}
149            },
150            Event::Text(text) => {
151                if let Some(ref mut buffer) = code_block_buffer {
152                    buffer.push_str(&text);
153                } else {
154                    let style = style_stack.last().cloned().unwrap_or_default();
155                    output.push_str(&style.apply(&text).to_string());
156                }
157            }
158            Event::Code(code) => {
159                let mut style = theme.accent;
160                if style.background_color.is_none() {
161                    style.background_color = theme.highlight;
162                }
163                output.push_str(&style.apply(&code).to_string());
164            }
165            Event::TaskListMarker(checked) => {
166                if checked {
167                    output.push_str("[x] ");
168                } else {
169                    output.push_str("[ ] ");
170                }
171            }
172            Event::SoftBreak | Event::HardBreak => {
173                output.push('\n');
174            }
175            Event::Rule => {
176                ensure_newlines(&mut output, 1);
177                output.push_str(&theme.secondary.apply(&"─".repeat(60)).to_string());
178                output.push('\n');
179            }
180            _ => {}
181        }
182    }
183
184    output.trim_end().to_string()
185}