wisp/screens/plan_review/
source_markdown.rs1use 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 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}