Skip to main content

wisp/screens/plan_review/
source_markdown.rs

1use crate::view::markdown::{Fence, InlineMarkdownSpanBuilder};
2use crate::view::syntax::SyntaxHighlighter;
3use crate::theme::Theme;
4use pulldown_cmark::{Event, Options, Parser};
5use ratatui::style::Style;
6use ratatui::text::{Line, Span};
7
8pub struct SourceMarkdownLine {
9    pub line: Line<'static>,
10}
11
12pub fn render_markdown_source_lines(
13    text: &str,
14    theme: &Theme,
15    highlighter: &mut SyntaxHighlighter,
16) -> Vec<SourceMarkdownLine> {
17    let raw_lines: Vec<&str> = text.split('\n').collect();
18    let mut rendered = Vec::with_capacity(raw_lines.len());
19    let mut index = 0usize;
20
21    while index < raw_lines.len() {
22        let raw = raw_lines[index];
23        if let Some(opening) = Fence::parse(raw) {
24            rendered.push(SourceMarkdownLine { line: Line::styled(raw.to_string(), Style::new().fg(theme.muted)) });
25            index += 1;
26
27            let code_start = index;
28            // Only a run at least as long as the opening one closes the block,
29            // so a fence nested inside it does not end it early.
30            while index < raw_lines.len()
31                && !Fence::parse(raw_lines[index]).is_some_and(|closing| closing.closes(&opening))
32            {
33                index += 1;
34            }
35            let code_end = index;
36            let code_text: String = raw_lines[code_start..code_end].join("\n");
37            if !code_text.is_empty() {
38                let highlighted_lines = highlighter.highlight(&code_text, opening.language(), theme);
39                for line in highlighted_lines.iter() {
40                    let styled_line = Line::from(
41                        line.spans
42                            .iter()
43                            .map(|span| {
44                                Span::styled(span.content.to_string(), span.style.patch(Style::new().bg(theme.code_bg)))
45                            })
46                            .collect::<Vec<_>>(),
47                    );
48                    rendered.push(SourceMarkdownLine { line: styled_line });
49                }
50            }
51
52            if index < raw_lines.len() {
53                rendered.push(SourceMarkdownLine {
54                    line: Line::styled(raw_lines[index].to_string(), Style::new().fg(theme.muted)),
55                });
56                index += 1;
57            }
58        } else {
59            rendered.push(SourceMarkdownLine { line: render_single_markdown_line(raw, theme) });
60            index += 1;
61        }
62    }
63
64    rendered
65}
66
67fn render_single_markdown_line(raw: &str, theme: &Theme) -> Line<'static> {
68    let trimmed = raw.trim();
69    if trimmed.is_empty() {
70        return Line::default();
71    }
72
73    if trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.matches('|').count() >= 2 {
74        return Line::raw(raw.to_string());
75    }
76
77    let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
78    let mut inline = InlineMarkdownSpanBuilder::new(theme);
79
80    for event in Parser::new_ext(raw, options) {
81        match event {
82            Event::Start(tag) => inline.start(&tag),
83            Event::End(tag) => inline.end(tag),
84            Event::Text(text) => inline.push_text(text.into_string()),
85            Event::Code(code) => inline.push_code(code.into_string()),
86            Event::SoftBreak => inline.push_soft_break(),
87            _ => {}
88        }
89    }
90
91    if inline.is_empty() { Line::raw(raw.to_string()) } else { Line::from(inline.take_spans()) }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::view::syntax::SyntaxHighlighter;
98    use crate::theme::Theme;
99    use ratatui::style::Modifier;
100
101    #[test]
102    fn renders_plain_text() {
103        let theme = Theme::default();
104        let mut highlighter = SyntaxHighlighter::new();
105        let result = render_markdown_source_lines("hello world", &theme, &mut highlighter);
106        assert_eq!(result.len(), 1);
107        assert!(!result[0].line.spans.is_empty());
108    }
109
110    #[test]
111    fn preserves_source_line_mapping() {
112        let theme = Theme::default();
113        let mut highlighter = SyntaxHighlighter::new();
114        let result = render_markdown_source_lines("line1\nline2\nline3", &theme, &mut highlighter);
115        assert_eq!(result.len(), 3);
116    }
117
118    #[test]
119    fn handles_empty_lines() {
120        let theme = Theme::default();
121        let mut highlighter = SyntaxHighlighter::new();
122        let result = render_markdown_source_lines("text\n\nmore", &theme, &mut highlighter);
123        assert_eq!(result.len(), 3);
124        assert!(result[1].line.spans.iter().all(|s| s.content.is_empty()));
125    }
126
127    #[test]
128    fn renders_headings_with_style() {
129        let theme = Theme::default();
130        let mut highlighter = SyntaxHighlighter::new();
131        let result = render_markdown_source_lines("# Hello", &theme, &mut highlighter);
132        assert_eq!(result.len(), 1);
133        let text: String = result[0].line.spans.iter().map(|s| s.content.as_ref()).collect();
134        assert!(text.contains("Hello"));
135    }
136
137    #[test]
138    fn renders_bold_and_italic_with_nested_styles() {
139        let theme = Theme::default();
140        let mut highlighter = SyntaxHighlighter::new();
141        let result = render_markdown_source_lines("**bold and *italic***", &theme, &mut highlighter);
142
143        let spans = &result[0].line.spans;
144        assert_eq!(spans.iter().map(|span| span.content.as_ref()).collect::<String>(), "bold and italic");
145        assert!(spans[0].style.add_modifier.contains(Modifier::BOLD));
146        assert!(spans[1].style.add_modifier.contains(Modifier::BOLD | Modifier::ITALIC));
147    }
148
149    #[test]
150    fn renders_inline_code() {
151        let theme = Theme::default();
152        let mut highlighter = SyntaxHighlighter::new();
153        let result = render_markdown_source_lines("use `code` here", &theme, &mut highlighter);
154        assert_eq!(result.len(), 1);
155    }
156
157    #[test]
158    fn handles_fenced_code_blocks() {
159        let theme = Theme::default();
160        let mut highlighter = SyntaxHighlighter::new();
161        let result = render_markdown_source_lines("```rust\nfn main() {}\n```", &theme, &mut highlighter);
162        assert_eq!(result.len(), 3);
163    }
164
165    #[test]
166    fn a_shorter_fence_does_not_close_a_longer_one() {
167        let theme = Theme::default();
168        let mut highlighter = SyntaxHighlighter::new();
169        let source = "````markdown\n```rust\nfn main() {}\n```\n````\nafter";
170        let result = render_markdown_source_lines(source, &theme, &mut highlighter);
171
172        assert_eq!(result.len(), 6, "every source line keeps its own row");
173        let last: String = result[5].line.spans.iter().map(|span| span.content.as_ref()).collect();
174        assert_eq!(last, "after", "the text past the outer fence must not be swallowed into it");
175    }
176
177    #[test]
178    fn passes_table_lines_through() {
179        let theme = Theme::default();
180        let mut highlighter = SyntaxHighlighter::new();
181        let result = render_markdown_source_lines("| a | b |", &theme, &mut highlighter);
182        assert_eq!(result.len(), 1);
183        let text: String = result[0].line.spans.iter().map(|s| s.content.as_ref()).collect();
184        assert!(text.contains("| a | b |"));
185    }
186}