1use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, CodeBlockKind, HeadingLevel};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9
10use super::mermaid::MermaidBlock;
11
12#[derive(Debug)]
14pub enum PreviewBlock {
15 Text(Vec<Line<'static>>),
17 Mermaid(MermaidBlock),
20}
21
22pub fn parse_markdown(source: &str, width: u16) -> Vec<PreviewBlock> {
25 let mut opts = Options::empty();
26 opts.insert(Options::ENABLE_TABLES);
27 opts.insert(Options::ENABLE_STRIKETHROUGH);
28 opts.insert(Options::ENABLE_TASKLISTS);
29
30 let parser = Parser::new_ext(source, opts);
31 let events: Vec<Event> = parser.collect();
32
33 let mut blocks: Vec<PreviewBlock> = Vec::new();
34 let mut lines: Vec<Line<'static>> = Vec::new();
35 let mut renderer = MarkdownRenderer::new(width);
36
37 let mut i = 0;
38 while i < events.len() {
39 match &events[i] {
40 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang)))
41 if lang.as_ref() == "mermaid" =>
42 {
43 if !lines.is_empty() {
45 blocks.push(PreviewBlock::Text(std::mem::take(&mut lines)));
46 }
47 let mut mermaid_src = String::new();
49 i += 1;
50 while i < events.len() {
51 match &events[i] {
52 Event::Text(text) => mermaid_src.push_str(text.as_ref()),
53 Event::End(TagEnd::CodeBlock) => break,
54 _ => {}
55 }
56 i += 1;
57 }
58 blocks.push(PreviewBlock::Mermaid(MermaidBlock::new(mermaid_src)));
59 i += 1;
60 continue;
61 }
62 _ => {
63 let new_lines = renderer.render_event(&events, i);
64 lines.extend(new_lines);
65 }
66 }
67 i += 1;
68 }
69
70 if !lines.is_empty() {
71 blocks.push(PreviewBlock::Text(lines));
72 }
73
74 blocks
75}
76
77struct MarkdownRenderer {
79 style_stack: Vec<Style>,
81 current_spans: Vec<Span<'static>>,
83 heading_level: Option<HeadingLevel>,
85 list_stack: Vec<(bool, usize)>,
87 in_blockquote: bool,
89 table_state: Option<TableState>,
91 in_code_block: bool,
93 code_block_lang: String,
94 pane_width: u16,
96}
97
98struct TableState {
99 rows: Vec<Vec<String>>,
100 current_row: Vec<String>,
101 current_cell: String,
102 in_head: bool,
103}
104
105impl MarkdownRenderer {
106 fn new(pane_width: u16) -> Self {
107 Self {
108 style_stack: vec![Style::default()],
109 current_spans: Vec::new(),
110 heading_level: None,
111 list_stack: Vec::new(),
112 in_blockquote: false,
113 table_state: None,
114 in_code_block: false,
115 code_block_lang: String::new(),
116 pane_width,
117 }
118 }
119
120 fn current_style(&self) -> Style {
121 self.style_stack.last().copied().unwrap_or_default()
122 }
123
124 fn push_style(&mut self, modifier: Modifier, fg: Option<Color>) {
125 let mut style = self.current_style().add_modifier(modifier);
126 if let Some(color) = fg {
127 style = style.fg(color);
128 }
129 self.style_stack.push(style);
130 }
131
132 fn pop_style(&mut self) {
133 if self.style_stack.len() > 1 {
134 self.style_stack.pop();
135 }
136 }
137
138 fn flush_line(&mut self) -> Option<Line<'static>> {
139 if self.current_spans.is_empty() {
140 return None;
141 }
142 let spans = std::mem::take(&mut self.current_spans);
143
144 if self.in_blockquote {
146 let mut prefixed = vec![Span::styled(
147 " > ".to_string(),
148 Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM),
149 )];
150 prefixed.extend(spans);
151 Some(Line::from(prefixed))
152 } else {
153 Some(Line::from(spans))
154 }
155 }
156
157 fn render_event(&mut self, events: &[Event], idx: usize) -> Vec<Line<'static>> {
158 let mut lines = Vec::new();
159 let event = &events[idx];
160
161 match event {
162 Event::Start(Tag::Heading { level, .. }) => {
164 self.heading_level = Some(*level);
165 let (prefix, color) = match level {
166 HeadingLevel::H1 => ("# ", Color::Magenta),
167 HeadingLevel::H2 => ("## ", Color::Cyan),
168 HeadingLevel::H3 => ("### ", Color::Green),
169 HeadingLevel::H4 => ("#### ", Color::Yellow),
170 HeadingLevel::H5 => ("##### ", Color::Blue),
171 HeadingLevel::H6 => ("###### ", Color::Red),
172 };
173 self.push_style(Modifier::BOLD, Some(color));
174 self.current_spans.push(Span::styled(
175 prefix.to_string(),
176 self.current_style(),
177 ));
178 }
179 Event::End(TagEnd::Heading(_)) => {
180 if let Some(line) = self.flush_line() {
181 lines.push(line);
182 }
183 self.heading_level = None;
184 self.pop_style();
185 lines.push(Line::raw("")); }
187
188 Event::Start(Tag::Paragraph) => {}
189 Event::End(TagEnd::Paragraph) => {
190 if let Some(line) = self.flush_line() {
191 lines.push(line);
192 }
193 lines.push(Line::raw("")); }
195
196 Event::Start(Tag::Strong) => {
198 self.push_style(Modifier::BOLD, None);
199 }
200 Event::End(TagEnd::Strong) => {
201 self.pop_style();
202 }
203 Event::Start(Tag::Emphasis) => {
204 self.push_style(Modifier::ITALIC, None);
205 }
206 Event::End(TagEnd::Emphasis) => {
207 self.pop_style();
208 }
209 Event::Start(Tag::Strikethrough) => {
210 self.push_style(Modifier::CROSSED_OUT, None);
211 }
212 Event::End(TagEnd::Strikethrough) => {
213 self.pop_style();
214 }
215
216 Event::Code(code) => {
218 self.current_spans.push(Span::styled(
219 format!("`{code}`"),
220 Style::default()
221 .fg(Color::Yellow)
222 .add_modifier(Modifier::BOLD),
223 ));
224 }
225
226 Event::Text(text) => {
228 if self.in_code_block {
229 for line_text in text.as_ref().split('\n') {
231 if !self.current_spans.is_empty() {
232 if let Some(line) = self.flush_line() {
233 lines.push(line);
234 }
235 }
236 self.current_spans.push(Span::styled(
237 format!(" {line_text}"),
238 Style::default().fg(Color::Green),
239 ));
240 }
241 } else if let Some(ref mut table) = self.table_state {
242 table.current_cell.push_str(text.as_ref());
243 } else {
244 self.current_spans.push(Span::styled(
245 text.to_string(),
246 self.current_style(),
247 ));
248 }
249 }
250
251 Event::SoftBreak => {
252 self.current_spans.push(Span::raw(" ".to_string()));
253 }
254 Event::HardBreak => {
255 if let Some(line) = self.flush_line() {
256 lines.push(line);
257 }
258 }
259
260 Event::Start(Tag::Link { dest_url, .. }) => {
262 self.push_style(Modifier::UNDERLINED, Some(Color::Blue));
263 self.current_spans.push(Span::raw(String::new())); let _ = dest_url; }
267 Event::End(TagEnd::Link) => {
268 self.pop_style();
269 }
270
271 Event::Start(Tag::List(start_num)) => {
273 let ordered = start_num.is_some();
274 let start = start_num.unwrap_or(0) as usize;
275 self.list_stack.push((ordered, start));
276 }
277 Event::End(TagEnd::List(_)) => {
278 self.list_stack.pop();
279 if self.list_stack.is_empty() {
280 lines.push(Line::raw("")); }
282 }
283 Event::Start(Tag::Item) => {
284 let indent = " ".repeat(self.list_stack.len().saturating_sub(1));
285 if let Some((ordered, num)) = self.list_stack.last_mut() {
286 let bullet = if *ordered {
287 *num += 1;
288 format!("{indent}{}. ", *num)
289 } else {
290 format!("{indent} - ")
291 };
292 self.current_spans.push(Span::styled(
293 bullet,
294 Style::default().fg(Color::Cyan),
295 ));
296 }
297 }
298 Event::End(TagEnd::Item) => {
299 if let Some(line) = self.flush_line() {
300 lines.push(line);
301 }
302 }
303
304 Event::Start(Tag::BlockQuote(_)) => {
306 self.in_blockquote = true;
307 self.push_style(Modifier::DIM, Some(Color::DarkGray));
308 }
309 Event::End(TagEnd::BlockQuote(_)) => {
310 if let Some(line) = self.flush_line() {
311 lines.push(line);
312 }
313 self.in_blockquote = false;
314 self.pop_style();
315 lines.push(Line::raw(""));
316 }
317
318 Event::Start(Tag::CodeBlock(kind)) => {
320 self.in_code_block = true;
321 match kind {
322 CodeBlockKind::Fenced(lang) => {
323 self.code_block_lang = lang.to_string();
324 lines.push(Line::from(Span::styled(
325 format!(" ```{lang}"),
326 Style::default().fg(Color::DarkGray),
327 )));
328 }
329 CodeBlockKind::Indented => {
330 lines.push(Line::from(Span::styled(
331 " ```".to_string(),
332 Style::default().fg(Color::DarkGray),
333 )));
334 }
335 }
336 }
337 Event::End(TagEnd::CodeBlock) => {
338 if let Some(line) = self.flush_line() {
339 lines.push(line);
340 }
341 self.in_code_block = false;
342 self.code_block_lang.clear();
343 lines.push(Line::from(Span::styled(
344 " ```".to_string(),
345 Style::default().fg(Color::DarkGray),
346 )));
347 lines.push(Line::raw(""));
348 }
349
350 Event::Start(Tag::Table(_)) => {
352 self.table_state = Some(TableState {
353 rows: Vec::new(),
354 current_row: Vec::new(),
355 current_cell: String::new(),
356 in_head: false,
357 });
358 }
359 Event::End(TagEnd::Table) => {
360 if let Some(table) = self.table_state.take() {
361 lines.extend(render_table(&table.rows, self.pane_width));
362 lines.push(Line::raw(""));
363 }
364 }
365 Event::Start(Tag::TableHead) => {
366 if let Some(ref mut t) = self.table_state {
367 t.in_head = true;
368 }
369 }
370 Event::End(TagEnd::TableHead) => {
371 if let Some(ref mut t) = self.table_state {
372 t.rows.push(std::mem::take(&mut t.current_row));
373 t.in_head = false;
374 }
375 }
376 Event::Start(Tag::TableRow) => {}
377 Event::End(TagEnd::TableRow) => {
378 if let Some(ref mut t) = self.table_state {
379 t.rows.push(std::mem::take(&mut t.current_row));
380 }
381 }
382 Event::Start(Tag::TableCell) => {
383 if let Some(ref mut t) = self.table_state {
384 t.current_cell.clear();
385 }
386 }
387 Event::End(TagEnd::TableCell) => {
388 if let Some(ref mut t) = self.table_state {
389 t.current_row.push(std::mem::take(&mut t.current_cell));
390 }
391 }
392
393 Event::Rule => {
395 lines.push(Line::from(Span::styled(
396 "──────────────────────────────────────────".to_string(),
397 Style::default().fg(Color::DarkGray),
398 )));
399 lines.push(Line::raw(""));
400 }
401
402 Event::TaskListMarker(checked) => {
404 let marker = if *checked { "[x] " } else { "[ ] " };
405 if let Some(last) = self.current_spans.last_mut() {
407 let content = last.content.to_string();
408 *last = Span::styled(
409 format!("{content}{marker}"),
410 Style::default().fg(if *checked { Color::Green } else { Color::Yellow }),
411 );
412 }
413 }
414
415 _ => {}
416 }
417
418 lines
419 }
420}
421
422fn render_table(rows: &[Vec<String>], pane_width: u16) -> Vec<Line<'static>> {
425 if rows.is_empty() {
426 return Vec::new();
427 }
428
429 let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
430 if num_cols == 0 {
431 return Vec::new();
432 }
433
434 let mut natural_widths = vec![0usize; num_cols];
436 for row in rows {
437 for (i, cell) in row.iter().enumerate() {
438 if i < num_cols {
439 natural_widths[i] = natural_widths[i].max(cell.len());
440 }
441 }
442 }
443
444 let overhead = 2 + (num_cols + 1) + num_cols * 2;
447 let col_widths = fit_column_widths(&natural_widths, pane_width as usize, overhead);
448
449 let mut lines = Vec::new();
450 let header_style = Style::default()
451 .fg(Color::Cyan)
452 .add_modifier(Modifier::BOLD);
453 let cell_style = Style::default();
454 let border_style = Style::default().fg(Color::DarkGray);
455
456 let top_border: String = col_widths
458 .iter()
459 .map(|w| "─".repeat(w + 2))
460 .collect::<Vec<_>>()
461 .join("┬");
462 lines.push(Line::from(Span::styled(
463 format!(" ┌{top_border}┐"),
464 border_style,
465 )));
466
467 for (ri, row) in rows.iter().enumerate() {
468 let is_header = ri == 0;
469 let style = if is_header { header_style } else { cell_style };
470
471 let mut wrapped_cells: Vec<Vec<String>> = Vec::new();
473 let mut max_lines = 1usize;
474 for (ci, width) in col_widths.iter().enumerate() {
475 let cell = row.get(ci).map(|s| s.as_str()).unwrap_or("");
476 let cell_lines = wrap_text(cell, *width);
477 max_lines = max_lines.max(cell_lines.len());
478 wrapped_cells.push(cell_lines);
479 }
480
481 for line_idx in 0..max_lines {
483 let mut spans = vec![Span::styled(" │".to_string(), border_style)];
484 for (ci, width) in col_widths.iter().enumerate() {
485 let text = wrapped_cells
486 .get(ci)
487 .and_then(|wc| wc.get(line_idx))
488 .map(|s| s.as_str())
489 .unwrap_or("");
490 spans.push(Span::styled(
491 format!(" {text:<width$} ", width = width),
492 style,
493 ));
494 spans.push(Span::styled("│".to_string(), border_style));
495 }
496 lines.push(Line::from(spans));
497 }
498
499 if is_header {
501 let sep: String = col_widths
502 .iter()
503 .map(|w| "─".repeat(w + 2))
504 .collect::<Vec<_>>()
505 .join("┼");
506 lines.push(Line::from(Span::styled(
507 format!(" ├{sep}┤"),
508 border_style,
509 )));
510 }
511 }
512
513 let bot_border: String = col_widths
515 .iter()
516 .map(|w| "─".repeat(w + 2))
517 .collect::<Vec<_>>()
518 .join("┴");
519 lines.push(Line::from(Span::styled(
520 format!(" └{bot_border}┘"),
521 border_style,
522 )));
523
524 lines
525}
526
527fn fit_column_widths(natural: &[usize], total_width: usize, overhead: usize) -> Vec<usize> {
530 let available = total_width.saturating_sub(overhead);
531 let mut widths: Vec<usize> = natural.iter().map(|&w| w.max(1)).collect();
532 let min_col = 4usize;
533
534 let total_natural: usize = widths.iter().sum();
535 if total_natural <= available || available == 0 {
536 return widths;
537 }
538
539 let mut remaining = available;
541 for (i, w) in widths.iter_mut().enumerate() {
542 if i == natural.len() - 1 {
543 *w = remaining.max(min_col);
545 } else {
546 let proportion = (natural[i] as f64) / (total_natural as f64);
547 let alloc = (proportion * available as f64).floor() as usize;
548 *w = alloc.max(min_col);
549 remaining = remaining.saturating_sub(*w);
550 }
551 }
552
553 widths
554}
555
556fn wrap_text(text: &str, width: usize) -> Vec<String> {
558 if width == 0 || text.len() <= width {
559 return vec![text.to_string()];
560 }
561
562 let mut lines = Vec::new();
563 let mut current = String::new();
564
565 for word in text.split_whitespace() {
566 if current.is_empty() {
567 if word.len() > width {
568 let mut remaining = word;
570 while remaining.len() > width {
571 lines.push(remaining[..width].to_string());
572 remaining = &remaining[width..];
573 }
574 current = remaining.to_string();
575 } else {
576 current = word.to_string();
577 }
578 } else if current.len() + 1 + word.len() <= width {
579 current.push(' ');
580 current.push_str(word);
581 } else {
582 lines.push(current);
583 if word.len() > width {
584 let mut remaining = word;
585 while remaining.len() > width {
586 lines.push(remaining[..width].to_string());
587 remaining = &remaining[width..];
588 }
589 current = remaining.to_string();
590 } else {
591 current = word.to_string();
592 }
593 }
594 }
595 if !current.is_empty() {
596 lines.push(current);
597 }
598 if lines.is_empty() {
599 lines.push(String::new());
600 }
601
602 lines
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn test_heading_parsing() {
611 let blocks = parse_markdown("# Hello\n\nSome text", 80);
612 assert!(!blocks.is_empty());
613 }
614
615 #[test]
616 fn test_mermaid_extraction() {
617 let md = "# Diagram\n\n```mermaid\ngraph TD\n A-->B\n```\n\nAfter.";
618 let blocks = parse_markdown(md, 80);
619 let has_mermaid = blocks.iter().any(|b| matches!(b, PreviewBlock::Mermaid(_)));
620 assert!(has_mermaid, "Should extract mermaid block");
621 }
622
623 #[test]
624 fn test_table_rendering() {
625 let md = "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |";
626 let blocks = parse_markdown(md, 80);
627 assert!(!blocks.is_empty());
628 }
629
630 #[test]
631 fn test_table_wraps_in_narrow_width() {
632 let rows = vec![
633 vec!["Name".to_string(), "Description".to_string()],
634 vec!["Alice".to_string(), "A very long description that should wrap".to_string()],
635 vec!["Bob".to_string(), "Short".to_string()],
636 ];
637 let lines = render_table(&rows, 40);
638 for line in &lines {
639 let total: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
641 assert!(total <= 40, "Line width {total} exceeds pane width 40: {:?}",
642 line.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<_>>());
643 }
644 assert!(lines.len() > 5, "Table should have wrapped rows, got {} lines", lines.len());
646 }
647
648 #[test]
649 fn test_wrap_text() {
650 assert_eq!(wrap_text("hello world", 5), vec!["hello", "world"]);
651 assert_eq!(wrap_text("hi", 10), vec!["hi"]);
652 assert_eq!(wrap_text("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
653 }
654}