Skip to main content

graphrag_cli/ui/
markdown.rs

1//! Simple markdown renderer for ratatui
2//!
3//! Converts markdown text to ratatui `Line<'static>` values.
4//! Supports: # headers, **bold**, *italic*, `code`, - bullets, > quotes, ``` code blocks.
5
6use ratatui::{
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9};
10
11/// Parse a markdown string into ratatui `Line` values ready for rendering.
12pub fn parse_markdown(text: &str) -> Vec<Line<'static>> {
13    let mut lines = Vec::new();
14    let mut in_code_block = false;
15
16    for raw_line in text.lines() {
17        // Toggle code block on ``` fence
18        if raw_line.trim_start().starts_with("```") {
19            in_code_block = !in_code_block;
20            continue;
21        }
22
23        if in_code_block {
24            lines.push(Line::from(vec![Span::styled(
25                raw_line.to_owned(),
26                Style::default().fg(Color::Yellow),
27            )]));
28            continue;
29        }
30
31        if raw_line.trim().is_empty() {
32            lines.push(Line::from(""));
33            continue;
34        }
35
36        // Horizontal rule (--- or ===)
37        if raw_line.trim().len() >= 3
38            && raw_line
39                .trim()
40                .chars()
41                .all(|c| c == '-' || c == '=' || c == '━')
42        {
43            lines.push(Line::from(vec![Span::styled(
44                "━".repeat(50),
45                Style::default().fg(Color::DarkGray),
46            )]));
47            continue;
48        }
49
50        // Headers: # ## ###
51        let header_level = raw_line.chars().take_while(|&c| c == '#').count();
52        if header_level > 0
53            && header_level <= 3
54            && raw_line.as_bytes().get(header_level) == Some(&b' ')
55        {
56            let content = raw_line[header_level + 1..].to_owned();
57            let style = header_style(header_level);
58            let prefix = match header_level {
59                1 => "▌ ",
60                2 => "  │ ",
61                _ => "    · ",
62            };
63            lines.push(Line::from(vec![
64                Span::styled(prefix.to_owned(), style),
65                Span::styled(content, style),
66            ]));
67            continue;
68        }
69
70        // Bullets: - item or * item
71        if raw_line.starts_with("- ") || raw_line.starts_with("* ") {
72            let content = &raw_line[2..];
73            let mut spans = vec![Span::styled(
74                "  • ".to_owned(),
75                Style::default().fg(Color::Cyan),
76            )];
77            spans.extend(parse_inline(content));
78            lines.push(Line::from(spans));
79            continue;
80        }
81
82        // Indented bullets: "  - item"
83        if (raw_line.starts_with("  - ") || raw_line.starts_with("  * ")) && raw_line.len() > 4 {
84            let content = &raw_line[4..];
85            let mut spans = vec![Span::styled(
86                "    ◦ ".to_owned(),
87                Style::default().fg(Color::DarkGray),
88            )];
89            spans.extend(parse_inline(content));
90            lines.push(Line::from(spans));
91            continue;
92        }
93
94        // Blockquote: > text
95        if let Some(content) = raw_line.strip_prefix("> ") {
96            let style = Style::default()
97                .fg(Color::DarkGray)
98                .add_modifier(Modifier::ITALIC);
99            lines.push(Line::from(vec![
100                Span::styled(" │ ".to_owned(), Style::default().fg(Color::DarkGray)),
101                Span::styled(content.to_owned(), style),
102            ]));
103            continue;
104        }
105
106        // Normal text with inline formatting
107        lines.push(Line::from(parse_inline(raw_line)));
108    }
109
110    lines
111}
112
113fn header_style(level: usize) -> Style {
114    match level {
115        1 => Style::default()
116            .fg(Color::Cyan)
117            .add_modifier(Modifier::BOLD),
118        2 => Style::default()
119            .fg(Color::LightBlue)
120            .add_modifier(Modifier::BOLD),
121        _ => Style::default()
122            .fg(Color::Blue)
123            .add_modifier(Modifier::BOLD),
124    }
125}
126
127/// Parse inline markdown markers (**bold**, *italic*, `code`) from a string.
128/// Returns `Span<'static>` values using owned strings.
129pub fn parse_inline(text: &str) -> Vec<Span<'static>> {
130    let mut result: Vec<Span<'static>> = Vec::new();
131    let mut current = String::new();
132    let chars: Vec<char> = text.chars().collect();
133    let n = chars.len();
134    let mut i = 0;
135
136    while i < n {
137        // **bold**
138        if i + 1 < n && chars[i] == '*' && chars[i + 1] == '*' {
139            if !current.is_empty() {
140                result.push(Span::raw(current.clone()));
141                current.clear();
142            }
143            let start = i + 2;
144            let mut end = None;
145            let mut j = start;
146            while j + 1 < n {
147                if chars[j] == '*' && chars[j + 1] == '*' {
148                    end = Some(j);
149                    break;
150                }
151                j += 1;
152            }
153            if let Some(e) = end {
154                let bold_text: String = chars[start..e].iter().collect();
155                result.push(Span::styled(
156                    bold_text,
157                    Style::default().add_modifier(Modifier::BOLD),
158                ));
159                i = e + 2;
160            } else {
161                current.push('*');
162                current.push('*');
163                i += 2;
164            }
165            continue;
166        }
167
168        // *italic* (but not **)
169        if chars[i] == '*' && (i + 1 >= n || chars[i + 1] != '*') {
170            if !current.is_empty() {
171                result.push(Span::raw(current.clone()));
172                current.clear();
173            }
174            let start = i + 1;
175            let end = chars[start..].iter().position(|&c| c == '*');
176            if let Some(e) = end {
177                let italic_text: String = chars[start..start + e].iter().collect();
178                result.push(Span::styled(
179                    italic_text,
180                    Style::default().add_modifier(Modifier::ITALIC),
181                ));
182                i = start + e + 1;
183            } else {
184                current.push('*');
185                i += 1;
186            }
187            continue;
188        }
189
190        // `inline code`
191        if chars[i] == '`' {
192            if !current.is_empty() {
193                result.push(Span::raw(current.clone()));
194                current.clear();
195            }
196            let start = i + 1;
197            let end = chars[start..].iter().position(|&c| c == '`');
198            if let Some(e) = end {
199                let code_text: String = chars[start..start + e].iter().collect();
200                result.push(Span::styled(
201                    code_text,
202                    Style::default().fg(Color::Yellow).bg(Color::DarkGray),
203                ));
204                i = start + e + 1;
205            } else {
206                current.push('`');
207                i += 1;
208            }
209            continue;
210        }
211
212        current.push(chars[i]);
213        i += 1;
214    }
215
216    if !current.is_empty() {
217        result.push(Span::raw(current));
218    }
219
220    if result.is_empty() {
221        result.push(Span::raw(String::new()));
222    }
223
224    result
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_parse_header() {
233        let lines = parse_markdown("# Hello World");
234        assert_eq!(lines.len(), 1);
235    }
236
237    #[test]
238    fn test_parse_bullet() {
239        let lines = parse_markdown("- item one\n- item two");
240        assert_eq!(lines.len(), 2);
241    }
242
243    #[test]
244    fn test_parse_code_block() {
245        let lines = parse_markdown("```\ncode line\n```");
246        assert_eq!(lines.len(), 1);
247    }
248
249    #[test]
250    fn test_parse_inline_bold() {
251        let spans = parse_inline("Hello **world**!");
252        assert_eq!(spans.len(), 3);
253    }
254
255    #[test]
256    fn test_parse_inline_code() {
257        let spans = parse_inline("Use `cargo build` now");
258        assert_eq!(spans.len(), 3);
259    }
260}