1use crate::highlight::CodeHighlighter;
10use ratatui::{
11 style::{Color, Modifier, Style},
12 text::{Line, Span},
13};
14
15const INDENT: &str = " ";
16
17const 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
25pub struct MarkdownRenderer {
29 in_code_block: bool,
31 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 pub fn render_line(&mut self, raw: &str) -> Line<'static> {
51 if raw.starts_with("```") {
53 if self.in_code_block {
54 self.in_code_block = false;
56 self.highlighter = None;
57 return Line::from(vec![Span::raw(INDENT), Span::styled("```", DIM_STYLE)]);
58 } else {
59 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 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 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 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 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 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 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 let mut spans = vec![Span::raw(INDENT.to_string())];
137 spans.extend(render_inline(raw, Style::default()));
138 Line::from(spans)
139 }
140}
141
142fn 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 if i > plain_start {
155 spans.push(Span::styled(text[plain_start..i].to_string(), base));
156 }
157 chars.next();
158 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 spans.push(Span::styled(text[i..].to_string(), base));
173 return spans;
174 }
175 }
176 '*' => {
177 let next_char = text.get(i + 1..i + 2);
179 if next_char == Some("*") {
180 if i > plain_start {
182 spans.push(Span::styled(text[plain_start..i].to_string(), base));
183 }
184 chars.next(); chars.next(); 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 plain_start = end_abs + 2;
195 while let Some(&(j, _)) = chars.peek() {
197 if j >= plain_start {
198 break;
199 }
200 chars.next();
201 }
202 } else {
203 spans.push(Span::styled(text[i..].to_string(), base));
205 return spans;
206 }
207 } else {
208 if i > plain_start {
210 spans.push(Span::styled(text[plain_start..i].to_string(), base));
211 }
212 chars.next(); 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 if plain_start < text.len() {
241 spans.push(Span::styled(text[plain_start..].to_string(), base));
242 }
243
244 spans
245}
246
247fn 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 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 assert!(!line.spans.is_empty());
447 }
448}