Skip to main content

semantic_diff/preview/
markdown.rs

1//! Markdown → ratatui Text rendering using pulldown-cmark.
2//!
3//! Renders headings, tables, code blocks, lists, links, blockquotes,
4//! and inline formatting (bold, italic, code) as styled ratatui Lines.
5
6use 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/// Rendered markdown content: interleaved text blocks and mermaid placeholders.
14#[derive(Debug)]
15pub enum PreviewBlock {
16    /// Styled text lines (headings, paragraphs, lists, tables, code blocks, etc.)
17    Text(Vec<Line<'static>>),
18    /// A mermaid code block that should be rendered as an image.
19    /// Contains the raw mermaid source and its blake3 content hash.
20    Mermaid(MermaidBlock),
21}
22
23/// Parse markdown source and return a list of preview blocks.
24/// `width` is the available terminal columns for text wrapping (0 = no limit).
25pub 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                // Flush accumulated text lines
45                if !lines.is_empty() {
46                    blocks.push(PreviewBlock::Text(std::mem::take(&mut lines)));
47                }
48                // Collect mermaid source
49                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
78/// Stateful renderer that tracks nesting context for markdown → ratatui conversion.
79struct MarkdownRenderer<'t> {
80    /// Current inline style stack (bold, italic, etc.)
81    style_stack: Vec<Style>,
82    /// Current inline spans being accumulated for the current line
83    current_spans: Vec<Span<'static>>,
84    /// Whether we're inside a heading (and which level)
85    heading_level: Option<HeadingLevel>,
86    /// List nesting: each entry is (ordered, current_item_number)
87    list_stack: Vec<(bool, usize)>,
88    /// Whether we're inside a blockquote
89    in_blockquote: bool,
90    /// Table state
91    table_state: Option<TableState>,
92    /// Whether we're inside a code block (non-mermaid)
93    in_code_block: bool,
94    code_block_lang: String,
95    /// Available terminal width for table wrapping
96    pane_width: u16,
97    /// Theme for color choices
98    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        // Apply blockquote prefix if needed
149        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            // Block-level starts
167            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("")); // blank line after heading
190            }
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("")); // blank line after paragraph
198            }
199
200            // Inline formatting
201            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            // Inline code
221            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            // Text content
231            Event::Text(text) => {
232                if self.in_code_block {
233                    // Code block: render each line with background
234                    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            // Links
265            Event::Start(Tag::Link { dest_url, .. }) => {
266                self.push_style(Modifier::UNDERLINED, Some(self.theme.md_link_fg));
267                // Store URL for display after link text
268                self.current_spans.push(Span::raw(String::new())); // placeholder
269                let _ = dest_url; // we'll show URL after text ends
270            }
271            Event::End(TagEnd::Link) => {
272                self.pop_style();
273            }
274
275            // Lists
276            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("")); // blank line after top-level list
285                }
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            // Blockquotes
309            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            // Code blocks (non-mermaid)
323            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            // Tables
355            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            // Horizontal rule
398            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            // Task list markers
407            Event::TaskListMarker(checked) => {
408                let marker = if *checked { "[x] " } else { "[ ] " };
409                // Replace the last bullet with checkbox
410                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
426/// Render a table as aligned ratatui Lines with box-drawing characters.
427/// Columns are constrained to fit within `pane_width` and cell text wraps.
428fn 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    // Natural (max content) width per column
439    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    // Compute column widths that fit within pane_width.
449    // Overhead: 2 (indent) + num_cols+1 (border chars │) + num_cols*2 (padding spaces)
450    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    // Top border
461    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        // Word-wrap each cell into its column width
476        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        // Emit one Line per wrapped row
486        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        // Separator after each row (except the last, which gets the bottom border)
504        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    // Bottom border
519    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
532/// Compute column widths that fit within `total_width` (including `overhead`).
533/// Distributes available space proportionally to natural widths. Minimum column width is 4.
534fn 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    // Proportionally distribute available space
545    let mut remaining = available;
546    for (i, w) in widths.iter_mut().enumerate() {
547        if i == natural.len() - 1 {
548            // Last column gets whatever is left
549            *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
561/// Wrap text to fit within `width` characters, breaking on word boundaries.
562fn 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                // Hard-break long words
574                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            // Use char count, not byte count (box-drawing chars are multi-byte)
649            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        // The wrapped table should have more lines than a 3-row table normally would
654        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}