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