Skip to main content

koda_cli/
md_render.rs

1//! Streaming markdown → ratatui `Line` renderer.
2//!
3//! Converts raw markdown text (line by line) into styled ratatui
4//! `Line`s with headers, bold, italic, inline code, fenced code
5//! blocks (with syntax highlighting), lists, blockquotes, and HRs.
6//!
7//! This replaces the old ANSI-based `markdown.rs` with native ratatui types.
8
9use crate::highlight::CodeHighlighter;
10use ratatui::{
11    style::{Color, Modifier, Style},
12    text::{Line, Span},
13};
14
15const INDENT: &str = "  ";
16
17// ── Styles ──────────────────────────────────────────────────
18
19const HEADING_STYLE: Style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD);
20const CODE_STYLE: Style = Style::new().fg(Color::Yellow);
21const DIM_STYLE: Style = Style::new().fg(Color::DarkGray);
22const BLOCKQUOTE_STYLE: Style = Style::new().fg(Color::DarkGray);
23const HR_STYLE: Style = Style::new().fg(Color::DarkGray);
24
25// ── State machine ───────────────────────────────────────────
26
27/// Streaming markdown renderer that tracks fenced code block state.
28pub struct MarkdownRenderer {
29    /// Inside a fenced code block?
30    in_code_block: bool,
31    /// Syntax highlighter for the current code block.
32    highlighter: Option<CodeHighlighter>,
33}
34
35impl Default for MarkdownRenderer {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl MarkdownRenderer {
42    pub fn new() -> Self {
43        Self {
44            in_code_block: false,
45            highlighter: None,
46        }
47    }
48
49    /// Render a single raw markdown line into a styled `Line`.
50    pub fn render_line(&mut self, raw: &str) -> Line<'static> {
51        // ── Code block fence ────────────────────────────────
52        if raw.starts_with("```") {
53            if self.in_code_block {
54                // Closing fence
55                self.in_code_block = false;
56                self.highlighter = None;
57                return Line::from(vec![Span::raw(INDENT), Span::styled("```", DIM_STYLE)]);
58            } else {
59                // Opening fence — extract lang hint
60                let lang = raw.trim_start_matches('`').trim();
61                self.in_code_block = true;
62                self.highlighter = if lang.is_empty() {
63                    None
64                } else {
65                    Some(CodeHighlighter::new(lang))
66                };
67                return Line::from(vec![
68                    Span::raw(INDENT),
69                    Span::styled(raw.to_string(), DIM_STYLE),
70                ]);
71            }
72        }
73
74        // ── Inside code block: syntax highlight ─────────────
75        if self.in_code_block {
76            let spans = match &mut self.highlighter {
77                Some(h) => {
78                    let mut s = vec![Span::raw(format!("{INDENT}  "))];
79                    s.extend(h.highlight_spans(raw));
80                    s
81                }
82                None => vec![
83                    Span::raw(format!("{INDENT}  ")),
84                    Span::styled(raw.to_string(), CODE_STYLE),
85                ],
86            };
87            return Line::from(spans);
88        }
89
90        // ── Horizontal rule ─────────────────────────────────
91        if is_horizontal_rule(raw) {
92            return Line::from(vec![
93                Span::raw(INDENT),
94                Span::styled("─".repeat(60), HR_STYLE),
95            ]);
96        }
97
98        // ── Heading ─────────────────────────────────────────
99        if let Some((level, text)) = parse_heading(raw) {
100            let prefix = match level {
101                1 => "■ ",
102                2 => "▸ ",
103                3 => "• ",
104                _ => "  ",
105            };
106            return Line::from(vec![
107                Span::raw(INDENT),
108                Span::styled(format!("{prefix}{text}"), HEADING_STYLE),
109            ]);
110        }
111
112        // ── Blockquote ──────────────────────────────────────
113        if let Some(text) = raw.strip_prefix('>') {
114            let text = text.strip_prefix(' ').unwrap_or(text);
115            let mut spans = vec![Span::raw(INDENT), Span::styled("│ ", BLOCKQUOTE_STYLE)];
116            spans.extend(render_inline(text, BLOCKQUOTE_STYLE));
117            return Line::from(spans);
118        }
119
120        // ── Unordered list ──────────────────────────────────
121        if let Some((indent_level, text)) = parse_list_item(raw) {
122            let bullet_indent = " ".repeat(indent_level * 2);
123            let mut spans = vec![Span::raw(format!("{INDENT}{bullet_indent}• "))];
124            spans.extend(render_inline(text, Style::default()));
125            return Line::from(spans);
126        }
127
128        // ── Ordered list ────────────────────────────────────
129        if let Some((num, text)) = parse_ordered_item(raw) {
130            let mut spans = vec![Span::raw(format!("{INDENT}{num}. "))];
131            spans.extend(render_inline(text, Style::default()));
132            return Line::from(spans);
133        }
134
135        // ── Regular prose ───────────────────────────────────
136        let mut spans = vec![Span::raw(INDENT.to_string())];
137        spans.extend(render_inline(raw, Style::default()));
138        Line::from(spans)
139    }
140}
141
142// ── Inline formatting parser ────────────────────────────────
143
144/// Parse inline markdown: **bold**, *italic*, `code`, and plain text.
145fn render_inline(text: &str, base: Style) -> Vec<Span<'static>> {
146    let mut spans = Vec::new();
147    let mut chars = text.char_indices().peekable();
148    let mut plain_start = 0;
149
150    while let Some(&(i, c)) = chars.peek() {
151        match c {
152            '`' => {
153                // Flush plain text before this marker
154                if i > plain_start {
155                    spans.push(Span::styled(text[plain_start..i].to_string(), base));
156                }
157                chars.next();
158                // Find closing backtick
159                let code_start = i + 1;
160                let mut found = false;
161                while let Some(&(j, c2)) = chars.peek() {
162                    chars.next();
163                    if c2 == '`' {
164                        spans.push(Span::styled(text[code_start..j].to_string(), CODE_STYLE));
165                        plain_start = j + 1;
166                        found = true;
167                        break;
168                    }
169                }
170                if !found {
171                    // No closing backtick — treat as plain
172                    spans.push(Span::styled(text[i..].to_string(), base));
173                    return spans;
174                }
175            }
176            '*' => {
177                // Check for ** (bold) or * (italic)
178                let next_char = text.get(i + 1..i + 2);
179                if next_char == Some("*") {
180                    // Bold: **text**
181                    if i > plain_start {
182                        spans.push(Span::styled(text[plain_start..i].to_string(), base));
183                    }
184                    chars.next(); // consume first *
185                    chars.next(); // consume second *
186                    let bold_start = i + 2;
187                    if let Some(end) = text[bold_start..].find("**") {
188                        let end_abs = bold_start + end;
189                        spans.push(Span::styled(
190                            text[bold_start..end_abs].to_string(),
191                            base.add_modifier(Modifier::BOLD),
192                        ));
193                        // Skip past closing **
194                        plain_start = end_abs + 2;
195                        // Advance chars iterator past the closing **
196                        while let Some(&(j, _)) = chars.peek() {
197                            if j >= plain_start {
198                                break;
199                            }
200                            chars.next();
201                        }
202                    } else {
203                        // No closing ** — treat as plain
204                        spans.push(Span::styled(text[i..].to_string(), base));
205                        return spans;
206                    }
207                } else {
208                    // Italic: *text*
209                    if i > plain_start {
210                        spans.push(Span::styled(text[plain_start..i].to_string(), base));
211                    }
212                    chars.next(); // consume *
213                    let italic_start = i + 1;
214                    if let Some(end) = text[italic_start..].find('*') {
215                        let end_abs = italic_start + end;
216                        spans.push(Span::styled(
217                            text[italic_start..end_abs].to_string(),
218                            base.add_modifier(Modifier::ITALIC),
219                        ));
220                        plain_start = end_abs + 1;
221                        while let Some(&(j, _)) = chars.peek() {
222                            if j >= plain_start {
223                                break;
224                            }
225                            chars.next();
226                        }
227                    } else {
228                        spans.push(Span::styled(text[i..].to_string(), base));
229                        return spans;
230                    }
231                }
232            }
233            _ => {
234                chars.next();
235            }
236        }
237    }
238
239    // Flush remaining plain text
240    if plain_start < text.len() {
241        spans.push(Span::styled(text[plain_start..].to_string(), base));
242    }
243
244    spans
245}
246
247// ── Helpers ─────────────────────────────────────────────────
248
249fn parse_heading(line: &str) -> Option<(usize, &str)> {
250    let trimmed = line.trim_start();
251    let level = trimmed.bytes().take_while(|&b| b == b'#').count();
252    if (1..=6).contains(&level) {
253        let rest = trimmed[level..].strip_prefix(' ')?;
254        Some((level, rest))
255    } else {
256        None
257    }
258}
259
260fn parse_list_item(line: &str) -> Option<(usize, &str)> {
261    let indent = line.bytes().take_while(|&b| b == b' ').count();
262    let after_indent = &line[indent..];
263    if let Some(rest) = after_indent
264        .strip_prefix("- ")
265        .or_else(|| after_indent.strip_prefix("* "))
266        .or_else(|| after_indent.strip_prefix("+ "))
267    {
268        Some((indent / 2, rest))
269    } else {
270        None
271    }
272}
273
274fn parse_ordered_item(line: &str) -> Option<(&str, &str)> {
275    let trimmed = line.trim_start();
276    let num_end = trimmed.bytes().take_while(|b| b.is_ascii_digit()).count();
277    if num_end > 0 {
278        let rest = &trimmed[num_end..];
279        if let Some(text) = rest.strip_prefix(". ") {
280            return Some((&trimmed[..num_end], text));
281        }
282    }
283    None
284}
285
286fn is_horizontal_rule(line: &str) -> bool {
287    let trimmed = line.trim();
288    (trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-' || c == ' '))
289        || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*' || c == ' '))
290        || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_' || c == ' '))
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_heading_parsing() {
299        assert_eq!(parse_heading("# Hello"), Some((1, "Hello")));
300        assert_eq!(parse_heading("## Sub"), Some((2, "Sub")));
301        assert_eq!(parse_heading("### Third"), Some((3, "Third")));
302        assert_eq!(parse_heading("Not a heading"), None);
303    }
304
305    #[test]
306    fn test_list_parsing() {
307        assert_eq!(parse_list_item("- item"), Some((0, "item")));
308        assert_eq!(parse_list_item("  - nested"), Some((1, "nested")));
309        assert_eq!(parse_list_item("    - deep"), Some((2, "deep")));
310        assert_eq!(parse_list_item("* star"), Some((0, "star")));
311    }
312
313    #[test]
314    fn test_ordered_list() {
315        assert_eq!(parse_ordered_item("1. First"), Some(("1", "First")));
316        assert_eq!(parse_ordered_item("42. Answer"), Some(("42", "Answer")));
317        assert_eq!(parse_ordered_item("Not ordered"), None);
318    }
319
320    #[test]
321    fn test_horizontal_rule() {
322        assert!(is_horizontal_rule("---"));
323        assert!(is_horizontal_rule("***"));
324        assert!(is_horizontal_rule("___"));
325        assert!(!is_horizontal_rule("--"));
326    }
327
328    #[test]
329    fn test_inline_bold() {
330        let spans = render_inline("hello **world** end", Style::default());
331        assert_eq!(spans.len(), 3);
332        assert_eq!(spans[0].content, "hello ");
333        assert_eq!(spans[1].content, "world");
334        assert!(spans[1].style.add_modifier.contains(Modifier::BOLD));
335        assert_eq!(spans[2].content, " end");
336    }
337
338    #[test]
339    fn test_inline_code() {
340        let spans = render_inline("use `foo` here", Style::default());
341        assert_eq!(spans.len(), 3);
342        assert_eq!(spans[1].content, "foo");
343        assert_eq!(spans[1].style.fg, Some(Color::Yellow));
344    }
345
346    #[test]
347    fn test_inline_italic() {
348        let spans = render_inline("hello *world* end", Style::default());
349        assert_eq!(spans.len(), 3);
350        assert_eq!(spans[1].content, "world");
351        assert!(spans[1].style.add_modifier.contains(Modifier::ITALIC));
352    }
353
354    #[test]
355    fn test_code_block_toggle() {
356        let mut r = MarkdownRenderer::new();
357        assert!(!r.in_code_block);
358        r.render_line("```rust");
359        assert!(r.in_code_block);
360        r.render_line("fn main() {}");
361        assert!(r.in_code_block);
362        r.render_line("```");
363        assert!(!r.in_code_block);
364    }
365
366    #[test]
367    fn test_unclosed_bold() {
368        let spans = render_inline("**unclosed bold", Style::default());
369        // Should fall back to plain text, not panic
370        assert_eq!(spans.len(), 1);
371        assert_eq!(spans[0].content, "**unclosed bold");
372    }
373
374    #[test]
375    fn test_unclosed_backtick() {
376        let spans = render_inline("`unclosed code", Style::default());
377        assert_eq!(spans.len(), 1);
378        assert_eq!(spans[0].content, "`unclosed code");
379    }
380
381    #[test]
382    fn test_unclosed_italic() {
383        let spans = render_inline("*unclosed italic", Style::default());
384        assert_eq!(spans.len(), 1);
385        assert_eq!(spans[0].content, "*unclosed italic");
386    }
387
388    #[test]
389    fn test_empty_line() {
390        let mut r = MarkdownRenderer::new();
391        let line = r.render_line("");
392        assert!(!line.spans.is_empty());
393    }
394
395    #[test]
396    fn test_heading_is_bold() {
397        let mut r = MarkdownRenderer::new();
398        let line = r.render_line("# Hello World");
399        assert!(
400            line.spans
401                .iter()
402                .any(|s| s.style.add_modifier.contains(Modifier::BOLD)),
403            "Heading should have bold span"
404        );
405    }
406
407    #[test]
408    fn test_heading_levels() {
409        let mut r = MarkdownRenderer::new();
410        for h in ["# H1", "## H2", "### H3"] {
411            let line = r.render_line(h);
412            let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
413            assert!(!text.is_empty(), "Heading '{h}' should render");
414        }
415    }
416
417    #[test]
418    fn test_list_item_renders() {
419        let mut r = MarkdownRenderer::new();
420        let line = r.render_line("- item one");
421        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
422        assert!(text.contains("item one"));
423    }
424
425    #[test]
426    fn test_blockquote_renders() {
427        let mut r = MarkdownRenderer::new();
428        let line = r.render_line("> quoted text");
429        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
430        assert!(text.contains("quoted text"));
431    }
432
433    #[test]
434    fn test_plain_text_passthrough() {
435        let mut r = MarkdownRenderer::new();
436        let line = r.render_line("Just plain text here");
437        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
438        assert!(text.contains("Just plain text here"));
439    }
440
441    #[test]
442    fn test_hr_renders() {
443        let mut r = MarkdownRenderer::new();
444        let line = r.render_line("---");
445        // HR should produce a styled line
446        assert!(!line.spans.is_empty());
447    }
448}