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