Skip to main content

agent_air_tui/
markdown.rs

1//! Markdown parsing utilities for TUI rendering
2//!
3//! Uses pulldown-cmark for CommonMark parsing.
4
5use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
6use ratatui::{
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9};
10
11use super::table::{is_table_line, is_table_separator, render_table};
12use super::themes::Theme;
13
14// Prefixes for message formatting
15const ASSISTANT_PREFIX: &str = "\u{25C6} "; // diamond
16const CONTINUATION: &str = "  ";
17
18/// Parse markdown text into styled ratatui spans
19pub fn parse_to_spans(text: &str, theme: &Theme) -> Vec<Span<'static>> {
20    let mut options = Options::empty();
21    options.insert(Options::ENABLE_STRIKETHROUGH);
22
23    let parser = Parser::new_ext(text, options);
24    let mut spans = Vec::new();
25    let mut style_stack: Vec<Modifier> = Vec::new();
26    let mut color_stack: Vec<Color> = Vec::new();
27    let mut link_url_stack: Vec<String> = Vec::new();
28
29    for event in parser {
30        match event {
31            // Inline formatting start
32            Event::Start(Tag::Strong) => {
33                style_stack.push(theme.bold());
34            }
35            Event::Start(Tag::Emphasis) => {
36                style_stack.push(theme.italic());
37            }
38            Event::Start(Tag::Strikethrough) => {
39                style_stack.push(theme.strikethrough());
40            }
41
42            // Inline formatting end
43            Event::End(TagEnd::Strong)
44            | Event::End(TagEnd::Emphasis)
45            | Event::End(TagEnd::Strikethrough) => {
46                style_stack.pop();
47            }
48
49            // Text content
50            Event::Text(t) => {
51                let style = build_style(&style_stack, &color_stack);
52                spans.push(Span::styled(t.into_string(), style));
53            }
54
55            // Inline code gets special styling
56            Event::Code(code) => {
57                spans.push(Span::styled(code.into_string(), theme.inline_code()));
58            }
59
60            // Soft breaks become spaces
61            Event::SoftBreak => {
62                spans.push(Span::raw(" "));
63            }
64
65            // Hard breaks preserved
66            Event::HardBreak => {
67                spans.push(Span::raw("\n"));
68            }
69
70            // Skip block-level events - we handle text line by line
71            Event::Start(Tag::Paragraph)
72            | Event::End(TagEnd::Paragraph)
73            | Event::Start(Tag::Heading { .. })
74            | Event::End(TagEnd::Heading(_)) => {}
75
76            // Links - render text in link color, then append URL
77            Event::Start(Tag::Link { dest_url, .. }) => {
78                // Extract color from theme link_text style
79                if let Some(color) = theme.link_text().fg {
80                    color_stack.push(color);
81                }
82                // Store URL to append after link text
83                link_url_stack.push(dest_url.into_string());
84            }
85            Event::End(TagEnd::Link) => {
86                color_stack.pop();
87                // Append URL after link text
88                if let Some(url) = link_url_stack.pop()
89                    && !url.is_empty()
90                {
91                    spans.push(Span::styled(format!(" ({})", url), theme.link_url()));
92                }
93            }
94
95            // Other events we don't handle yet
96            _ => {}
97        }
98    }
99
100    spans
101}
102
103/// Build a Style from the current modifier and color stacks
104fn build_style(modifiers: &[Modifier], colors: &[Color]) -> Style {
105    let mut style = Style::default();
106
107    // Apply all modifiers
108    for modifier in modifiers {
109        style = style.add_modifier(*modifier);
110    }
111
112    // Apply the most recent color if any
113    if let Some(&color) = colors.last() {
114        style = style.fg(color);
115    }
116
117    style
118}
119
120/// Parse markdown and split into words with their styles
121///
122/// Useful for word-wrapping while preserving styles
123pub fn parse_to_styled_words(text: &str, theme: &Theme) -> Vec<(String, Style)> {
124    let spans = parse_to_spans(text, theme);
125    let mut words = Vec::new();
126
127    for span in spans {
128        let content = span.content.to_string();
129        let style = span.style;
130
131        // Split each span's content into words
132        for word in content.split_whitespace() {
133            words.push((word.to_string(), style));
134        }
135    }
136
137    words
138}
139
140/// Content segment type for splitting text and tables
141pub enum ContentSegment {
142    Text(String),
143    Table(Vec<String>),
144    /// Code block with optional language hint.
145    CodeBlock {
146        code: String,
147        /// Language hint (e.g., "rust", "python") - parsed from markdown but
148        /// not yet used. Reserved for future syntax highlighting support.
149        #[allow(dead_code)]
150        language: Option<String>,
151    },
152}
153
154/// Wrap text with a first-line prefix and continuation prefix
155///
156/// Uses pulldown-cmark for inline styling (bold, italic, code)
157pub fn wrap_with_prefix(
158    text: &str,
159    first_prefix: &str,
160    first_prefix_style: Style,
161    cont_prefix: &str,
162    max_width: usize,
163    theme: &Theme,
164) -> Vec<Line<'static>> {
165    let mut lines = Vec::new();
166    let text_width = max_width.saturating_sub(first_prefix.chars().count());
167
168    if text_width == 0 || text.is_empty() {
169        // Parse markdown even for short text
170        let spans = parse_to_spans(text, theme);
171        let mut result_spans = vec![Span::styled(first_prefix.to_string(), first_prefix_style)];
172        result_spans.extend(spans);
173        return vec![Line::from(result_spans)];
174    }
175
176    // Parse markdown into styled words for wrapping
177    let styled_words = parse_to_styled_words(text, theme);
178
179    // Word-wrap the styled words
180    let mut current_line_spans: Vec<Span<'static>> = Vec::new();
181    let mut current_line_len = 0usize;
182    let mut is_first_line = true;
183
184    for (word, style) in styled_words {
185        let word_len = word.chars().count();
186        let would_be_len = if current_line_len == 0 {
187            word_len
188        } else {
189            current_line_len + 1 + word_len
190        };
191
192        if would_be_len > text_width && current_line_len > 0 {
193            // Emit current line
194            let prefix = if is_first_line {
195                first_prefix
196            } else {
197                cont_prefix
198            };
199            let prefix_style = if is_first_line {
200                first_prefix_style
201            } else {
202                Style::default()
203            };
204            let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
205            line_spans.append(&mut current_line_spans);
206            lines.push(Line::from(line_spans));
207
208            current_line_spans.push(Span::styled(word, style));
209            current_line_len = word_len;
210            is_first_line = false;
211        } else {
212            if current_line_len > 0 {
213                current_line_spans.push(Span::raw(" "));
214                current_line_len += 1;
215            }
216            current_line_spans.push(Span::styled(word, style));
217            current_line_len += word_len;
218        }
219    }
220
221    // Emit remaining text
222    if !current_line_spans.is_empty() || is_first_line {
223        let prefix = if is_first_line {
224            first_prefix
225        } else {
226            cont_prefix
227        };
228        let prefix_style = if is_first_line {
229            first_prefix_style
230        } else {
231            Style::default()
232        };
233        let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
234        line_spans.extend(current_line_spans);
235        lines.push(Line::from(line_spans));
236    }
237
238    lines
239}
240
241/// Check if text starts with a heading marker using pulldown-cmark
242///
243/// This is more robust than string matching and handles edge cases correctly
244pub fn detect_heading_level(text: &str) -> Option<u8> {
245    let parser = Parser::new(text);
246    for event in parser {
247        if let Event::Start(Tag::Heading { level, .. }) = event {
248            return Some(level as u8);
249        }
250    }
251    None
252}
253
254/// Get style for heading level
255pub fn heading_style(level: u8, theme: &Theme) -> Style {
256    match level {
257        1 => theme.heading_1(),
258        2 => theme.heading_2(),
259        3 => theme.heading_3(),
260        _ => theme.heading_4(),
261    }
262}
263
264/// Check if a line starts a fenced code block (``` or ~~~)
265fn is_code_fence(line: &str) -> Option<&str> {
266    let trimmed = line.trim();
267    if trimmed.starts_with("```") {
268        Some(trimmed.strip_prefix("```").unwrap_or("").trim())
269    } else if trimmed.starts_with("~~~") {
270        Some(trimmed.strip_prefix("~~~").unwrap_or("").trim())
271    } else {
272        None
273    }
274}
275
276/// Check if a line ends a fenced code block
277fn is_code_fence_end(line: &str) -> bool {
278    let trimmed = line.trim();
279    trimmed == "```" || trimmed == "~~~"
280}
281
282/// Split content into text, table, and code block segments
283pub fn split_content_segments(content: &str) -> Vec<ContentSegment> {
284    let lines: Vec<&str> = content.lines().collect();
285    let mut segments = Vec::new();
286    let mut current_text = String::new();
287    let mut i = 0;
288
289    while i < lines.len() {
290        // Check for fenced code block
291        if let Some(lang) = is_code_fence(lines[i]) {
292            // Found a code block! First, save any accumulated text
293            if !current_text.is_empty() {
294                segments.push(ContentSegment::Text(current_text));
295                current_text = String::new();
296            }
297
298            let language = if lang.is_empty() {
299                None
300            } else {
301                Some(lang.to_string())
302            };
303            i += 1; // Skip the opening fence
304
305            // Collect code block content until closing fence
306            let mut code_content = String::new();
307            while i < lines.len() && !is_code_fence_end(lines[i]) {
308                if !code_content.is_empty() {
309                    code_content.push('\n');
310                }
311                code_content.push_str(lines[i]);
312                i += 1;
313            }
314
315            // Skip the closing fence if present
316            if i < lines.len() && is_code_fence_end(lines[i]) {
317                i += 1;
318            }
319
320            segments.push(ContentSegment::CodeBlock {
321                code: code_content,
322                language,
323            });
324        }
325        // Check if this might be a table (line with | and next line is separator)
326        else if is_table_line(lines[i]) && i + 1 < lines.len() && is_table_separator(lines[i + 1])
327        {
328            // Found a table! First, save any accumulated text
329            if !current_text.is_empty() {
330                segments.push(ContentSegment::Text(current_text));
331                current_text = String::new();
332            }
333
334            // Collect all table lines
335            let mut table_lines = Vec::new();
336            while i < lines.len() && is_table_line(lines[i]) {
337                table_lines.push(lines[i].to_string());
338                i += 1;
339            }
340            segments.push(ContentSegment::Table(table_lines));
341        } else {
342            // Regular text line
343            if !current_text.is_empty() {
344                current_text.push('\n');
345            }
346            current_text.push_str(lines[i]);
347            i += 1;
348        }
349    }
350
351    // Don't forget remaining text
352    if !current_text.is_empty() {
353        segments.push(ContentSegment::Text(current_text));
354    }
355
356    segments
357}
358
359/// Render markdown content with diamond prefix and manual wrapping
360pub fn render_markdown_with_prefix(
361    content: &str,
362    max_width: usize,
363    theme: &Theme,
364) -> Vec<Line<'static>> {
365    let segments = split_content_segments(content);
366
367    let mut all_lines = Vec::new();
368    let mut is_first_line = true;
369
370    for segment in segments {
371        match segment {
372            ContentSegment::Text(text) => {
373                // Process each line
374                for line in text.lines() {
375                    let line = line.trim();
376                    if line.is_empty() {
377                        // Add blank line for paragraph breaks
378                        all_lines.push(Line::from(""));
379                        continue;
380                    }
381
382                    // Check for heading
383                    if let Some(level) = detect_heading_level(line) {
384                        let heading_text = line.trim_start_matches('#').trim();
385                        let base_style = heading_style(level, theme);
386                        let prefix = if is_first_line {
387                            ASSISTANT_PREFIX
388                        } else {
389                            CONTINUATION
390                        };
391                        let prefix_style = if is_first_line {
392                            theme.assistant_prefix()
393                        } else {
394                            Style::default()
395                        };
396
397                        // Parse heading text for inline markdown (bold, italic, etc.)
398                        let parsed_spans = parse_to_spans(heading_text, theme);
399                        let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
400
401                        if parsed_spans.is_empty() {
402                            line_spans.push(Span::styled(heading_text.to_string(), base_style));
403                        } else {
404                            for span in parsed_spans {
405                                // Merge base heading style with inline style
406                                let merged_style = base_style.patch(span.style);
407                                line_spans
408                                    .push(Span::styled(span.content.to_string(), merged_style));
409                            }
410                        }
411
412                        all_lines.push(Line::from(line_spans));
413                        is_first_line = false;
414                        continue;
415                    }
416
417                    // Regular line - wrap with prefix
418                    let prefix = if is_first_line {
419                        ASSISTANT_PREFIX
420                    } else {
421                        CONTINUATION
422                    };
423                    let prefix_style = if is_first_line {
424                        theme.assistant_prefix()
425                    } else {
426                        Style::default()
427                    };
428
429                    let lines = wrap_with_prefix(
430                        line,
431                        prefix,
432                        prefix_style,
433                        CONTINUATION,
434                        max_width,
435                        theme,
436                    );
437                    all_lines.extend(lines);
438                    is_first_line = false;
439                }
440            }
441            ContentSegment::Table(table_lines) => {
442                let lines = render_table(&table_lines, theme);
443                all_lines.extend(lines);
444                is_first_line = false;
445            }
446            ContentSegment::CodeBlock { code, language: _ } => {
447                let lines = render_code_block(&code, is_first_line, theme);
448                all_lines.extend(lines);
449                is_first_line = false;
450            }
451        }
452    }
453    all_lines
454}
455
456/// Render a code block with indentation and special styling
457fn render_code_block(code: &str, is_first_line: bool, theme: &Theme) -> Vec<Line<'static>> {
458    const CODE_INDENT: &str = "    "; // 4 spaces for code block indentation
459    let code_style = theme.code_block();
460    let prefix_style = theme.assistant_prefix();
461
462    let mut lines = Vec::new();
463
464    // Add a blank line before code block if not first
465    if !is_first_line {
466        lines.push(Line::from(""));
467    }
468
469    for (i, line) in code.lines().enumerate() {
470        let mut spans = Vec::new();
471
472        // First line of code block gets the assistant prefix, rest get continuation
473        if i == 0 && is_first_line {
474            spans.push(Span::styled(ASSISTANT_PREFIX, prefix_style));
475        } else {
476            spans.push(Span::raw(CONTINUATION));
477        }
478
479        // Add code indentation and the code line
480        spans.push(Span::styled(format!("{}{}", CODE_INDENT, line), code_style));
481
482        lines.push(Line::from(spans));
483    }
484
485    // Add a blank line after code block
486    lines.push(Line::from(""));
487
488    lines
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn test_plain_text() {
497        let theme = Theme::default();
498        let spans = parse_to_spans("hello world", &theme);
499        assert_eq!(spans.len(), 1);
500        assert_eq!(spans[0].content, "hello world");
501    }
502
503    #[test]
504    fn test_bold() {
505        let theme = Theme::default();
506        let spans = parse_to_spans("**bold**", &theme);
507        assert_eq!(spans.len(), 1);
508        assert_eq!(spans[0].content, "bold");
509        assert!(spans[0].style.add_modifier == Modifier::BOLD.into());
510    }
511
512    #[test]
513    fn test_italic() {
514        let theme = Theme::default();
515        let spans = parse_to_spans("*italic*", &theme);
516        assert_eq!(spans.len(), 1);
517        assert_eq!(spans[0].content, "italic");
518    }
519
520    #[test]
521    fn test_mixed_formatting() {
522        let theme = Theme::default();
523        let spans = parse_to_spans("normal **bold** and *italic*", &theme);
524        assert!(spans.len() >= 3);
525    }
526
527    #[test]
528    fn test_inline_code() {
529        let theme = Theme::default();
530        let spans = parse_to_spans("use `code` here", &theme);
531        assert!(spans.iter().any(|s| s.content == "code"));
532    }
533
534    #[test]
535    fn test_styled_words() {
536        let theme = Theme::default();
537        let words = parse_to_styled_words("hello **bold** world", &theme);
538        assert_eq!(words.len(), 3);
539        assert_eq!(words[0].0, "hello");
540        assert_eq!(words[1].0, "bold");
541        assert_eq!(words[2].0, "world");
542    }
543
544    #[test]
545    fn test_entirely_bold_line() {
546        let theme = Theme::default();
547        let input = "**The Midnight Adventure**";
548        let spans = parse_to_spans(input, &theme);
549
550        assert!(!spans.is_empty(), "Should have at least one span");
551        assert!(
552            spans[0].style.add_modifier.contains(Modifier::BOLD),
553            "First span should be bold"
554        );
555    }
556
557    #[test]
558    fn test_link_parsing() {
559        let theme = Theme::default();
560        let input = "[The Rust Book](https://doc.rust-lang.org/book/)";
561        let spans = parse_to_spans(input, &theme);
562
563        assert!(
564            spans.iter().any(|s| s.content.contains("Rust Book")),
565            "Should contain link text"
566        );
567        assert!(
568            spans
569                .iter()
570                .any(|s| s.content.contains("doc.rust-lang.org")),
571            "Should contain URL"
572        );
573    }
574
575    #[test]
576    fn test_heading_detection() {
577        // Valid headings
578        assert_eq!(detect_heading_level("# Heading 1"), Some(1));
579        assert_eq!(detect_heading_level("## Heading 2"), Some(2));
580        assert_eq!(detect_heading_level("### Heading 3"), Some(3));
581        assert_eq!(detect_heading_level("###### Heading 6"), Some(6));
582        assert_eq!(detect_heading_level("# "), Some(1)); // Empty heading
583
584        // Invalid headings
585        assert_eq!(detect_heading_level("Not a heading"), None);
586        assert_eq!(detect_heading_level("#NoSpace"), None); // No space after #
587        assert_eq!(detect_heading_level("####### Too many"), None); // 7 hashes
588    }
589
590    #[test]
591    fn test_render_markdown_with_indented_link() {
592        let theme = Theme::default();
593        let content = "Here is a link:\n    [The Rust Book](https://doc.rust-lang.org/book/)";
594        let lines = render_markdown_with_prefix(content, 80, &theme);
595
596        let all_text: String = lines
597            .iter()
598            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
599            .collect();
600
601        assert!(
602            all_text.contains("The Rust Book"),
603            "Should contain link text"
604        );
605        assert!(
606            !all_text.contains("](https://"),
607            "URL should not appear in literal markdown syntax"
608        );
609    }
610
611    #[test]
612    fn test_styled_words_bold() {
613        let theme = Theme::default();
614        let words = parse_to_styled_words("**The Midnight Adventure**", &theme);
615        assert_eq!(words.len(), 3);
616        // All words should have BOLD
617        for (word, style) in &words {
618            assert!(
619                style.add_modifier.contains(Modifier::BOLD),
620                "Word {:?} should be bold",
621                word
622            );
623        }
624    }
625
626    #[test]
627    fn test_code_block_detection() {
628        let content = "Some text\n```go\nfunc main() {\n    println(\"hello\")\n}\n```\nMore text";
629        let segments = split_content_segments(content);
630
631        assert_eq!(segments.len(), 3);
632
633        // First segment is text
634        match &segments[0] {
635            ContentSegment::Text(t) => assert_eq!(t, "Some text"),
636            _ => panic!("Expected Text segment"),
637        }
638
639        // Second segment is code block
640        match &segments[1] {
641            ContentSegment::CodeBlock { code, language } => {
642                assert_eq!(language.as_deref(), Some("go"));
643                assert!(code.contains("func main()"));
644                assert!(code.contains("println"));
645            }
646            _ => panic!("Expected CodeBlock segment"),
647        }
648
649        // Third segment is text
650        match &segments[2] {
651            ContentSegment::Text(t) => assert_eq!(t, "More text"),
652            _ => panic!("Expected Text segment"),
653        }
654    }
655
656    #[test]
657    fn test_code_block_no_language() {
658        let content = "```\ncode here\n```";
659        let segments = split_content_segments(content);
660
661        assert_eq!(segments.len(), 1);
662        match &segments[0] {
663            ContentSegment::CodeBlock { code, language } => {
664                assert!(language.is_none());
665                assert_eq!(code, "code here");
666            }
667            _ => panic!("Expected CodeBlock segment"),
668        }
669    }
670}