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;
11
12/// Rendered markdown content: interleaved text blocks and mermaid placeholders.
13#[derive(Debug)]
14pub enum PreviewBlock {
15    /// Styled text lines (headings, paragraphs, lists, tables, code blocks, etc.)
16    Text(Vec<Line<'static>>),
17    /// A mermaid code block that should be rendered as an image.
18    /// Contains the raw mermaid source and its blake3 content hash.
19    Mermaid(MermaidBlock),
20}
21
22/// Parse markdown source and return a list of preview blocks.
23/// `width` is the available terminal columns for text wrapping (0 = no limit).
24pub 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                // Flush accumulated text lines
44                if !lines.is_empty() {
45                    blocks.push(PreviewBlock::Text(std::mem::take(&mut lines)));
46                }
47                // Collect mermaid source
48                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
77/// Stateful renderer that tracks nesting context for markdown → ratatui conversion.
78struct MarkdownRenderer {
79    /// Current inline style stack (bold, italic, etc.)
80    style_stack: Vec<Style>,
81    /// Current inline spans being accumulated for the current line
82    current_spans: Vec<Span<'static>>,
83    /// Whether we're inside a heading (and which level)
84    heading_level: Option<HeadingLevel>,
85    /// List nesting: each entry is (ordered, current_item_number)
86    list_stack: Vec<(bool, usize)>,
87    /// Whether we're inside a blockquote
88    in_blockquote: bool,
89    /// Table state
90    table_state: Option<TableState>,
91    /// Whether we're inside a code block (non-mermaid)
92    in_code_block: bool,
93    code_block_lang: String,
94    /// Available terminal width for table wrapping
95    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        // Apply blockquote prefix if needed
145        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            // Block-level starts
163            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("")); // blank line after heading
186            }
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("")); // blank line after paragraph
194            }
195
196            // Inline formatting
197            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            // Inline code
217            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            // Text content
227            Event::Text(text) => {
228                if self.in_code_block {
229                    // Code block: render each line with background
230                    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            // Links
261            Event::Start(Tag::Link { dest_url, .. }) => {
262                self.push_style(Modifier::UNDERLINED, Some(Color::Blue));
263                // Store URL for display after link text
264                self.current_spans.push(Span::raw(String::new())); // placeholder
265                let _ = dest_url; // we'll show URL after text ends
266            }
267            Event::End(TagEnd::Link) => {
268                self.pop_style();
269            }
270
271            // Lists
272            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("")); // blank line after top-level list
281                }
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            // Blockquotes
305            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            // Code blocks (non-mermaid)
319            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            // Tables
351            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            // Horizontal rule
394            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            // Task list markers
403            Event::TaskListMarker(checked) => {
404                let marker = if *checked { "[x] " } else { "[ ] " };
405                // Replace the last bullet with checkbox
406                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
422/// Render a table as aligned ratatui Lines with box-drawing characters.
423/// Columns are constrained to fit within `pane_width` and cell text wraps.
424fn 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    // Natural (max content) width per column
435    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    // Compute column widths that fit within pane_width.
445    // Overhead: 2 (indent) + num_cols+1 (border chars │) + num_cols*2 (padding spaces)
446    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    // Top border
457    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        // Word-wrap each cell into its column width
472        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        // Emit one Line per wrapped row
482        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        // Separator after header row
500        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    // Bottom border
514    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
527/// Compute column widths that fit within `total_width` (including `overhead`).
528/// Distributes available space proportionally to natural widths. Minimum column width is 4.
529fn 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    // Proportionally distribute available space
540    let mut remaining = available;
541    for (i, w) in widths.iter_mut().enumerate() {
542        if i == natural.len() - 1 {
543            // Last column gets whatever is left
544            *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
556/// Wrap text to fit within `width` characters, breaking on word boundaries.
557fn 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                // Hard-break long words
569                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            // Use char count, not byte count (box-drawing chars are multi-byte)
640            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        // The wrapped table should have more lines than a 3-row table normally would
645        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}