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.
23pub fn parse_markdown(source: &str) -> Vec<PreviewBlock> {
24    let mut opts = Options::empty();
25    opts.insert(Options::ENABLE_TABLES);
26    opts.insert(Options::ENABLE_STRIKETHROUGH);
27    opts.insert(Options::ENABLE_TASKLISTS);
28
29    let parser = Parser::new_ext(source, opts);
30    let events: Vec<Event> = parser.collect();
31
32    let mut blocks: Vec<PreviewBlock> = Vec::new();
33    let mut lines: Vec<Line<'static>> = Vec::new();
34    let mut renderer = MarkdownRenderer::new();
35
36    let mut i = 0;
37    while i < events.len() {
38        match &events[i] {
39            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang)))
40                if lang.as_ref() == "mermaid" =>
41            {
42                // Flush accumulated text lines
43                if !lines.is_empty() {
44                    blocks.push(PreviewBlock::Text(std::mem::take(&mut lines)));
45                }
46                // Collect mermaid source
47                let mut mermaid_src = String::new();
48                i += 1;
49                while i < events.len() {
50                    match &events[i] {
51                        Event::Text(text) => mermaid_src.push_str(text.as_ref()),
52                        Event::End(TagEnd::CodeBlock) => break,
53                        _ => {}
54                    }
55                    i += 1;
56                }
57                blocks.push(PreviewBlock::Mermaid(MermaidBlock::new(mermaid_src)));
58                i += 1;
59                continue;
60            }
61            _ => {
62                let new_lines = renderer.render_event(&events, i);
63                lines.extend(new_lines);
64            }
65        }
66        i += 1;
67    }
68
69    if !lines.is_empty() {
70        blocks.push(PreviewBlock::Text(lines));
71    }
72
73    blocks
74}
75
76/// Stateful renderer that tracks nesting context for markdown → ratatui conversion.
77struct MarkdownRenderer {
78    /// Current inline style stack (bold, italic, etc.)
79    style_stack: Vec<Style>,
80    /// Current inline spans being accumulated for the current line
81    current_spans: Vec<Span<'static>>,
82    /// Whether we're inside a heading (and which level)
83    heading_level: Option<HeadingLevel>,
84    /// List nesting: each entry is (ordered, current_item_number)
85    list_stack: Vec<(bool, usize)>,
86    /// Whether we're inside a blockquote
87    in_blockquote: bool,
88    /// Table state
89    table_state: Option<TableState>,
90    /// Whether we're inside a code block (non-mermaid)
91    in_code_block: bool,
92    code_block_lang: String,
93}
94
95struct TableState {
96    rows: Vec<Vec<String>>,
97    current_row: Vec<String>,
98    current_cell: String,
99    in_head: bool,
100}
101
102impl MarkdownRenderer {
103    fn new() -> Self {
104        Self {
105            style_stack: vec![Style::default()],
106            current_spans: Vec::new(),
107            heading_level: None,
108            list_stack: Vec::new(),
109            in_blockquote: false,
110            table_state: None,
111            in_code_block: false,
112            code_block_lang: String::new(),
113        }
114    }
115
116    fn current_style(&self) -> Style {
117        self.style_stack.last().copied().unwrap_or_default()
118    }
119
120    fn push_style(&mut self, modifier: Modifier, fg: Option<Color>) {
121        let mut style = self.current_style().add_modifier(modifier);
122        if let Some(color) = fg {
123            style = style.fg(color);
124        }
125        self.style_stack.push(style);
126    }
127
128    fn pop_style(&mut self) {
129        if self.style_stack.len() > 1 {
130            self.style_stack.pop();
131        }
132    }
133
134    fn flush_line(&mut self) -> Option<Line<'static>> {
135        if self.current_spans.is_empty() {
136            return None;
137        }
138        let spans = std::mem::take(&mut self.current_spans);
139
140        // Apply blockquote prefix if needed
141        if self.in_blockquote {
142            let mut prefixed = vec![Span::styled(
143                "  > ".to_string(),
144                Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM),
145            )];
146            prefixed.extend(spans);
147            Some(Line::from(prefixed))
148        } else {
149            Some(Line::from(spans))
150        }
151    }
152
153    fn render_event(&mut self, events: &[Event], idx: usize) -> Vec<Line<'static>> {
154        let mut lines = Vec::new();
155        let event = &events[idx];
156
157        match event {
158            // Block-level starts
159            Event::Start(Tag::Heading { level, .. }) => {
160                self.heading_level = Some(*level);
161                let (prefix, color) = match level {
162                    HeadingLevel::H1 => ("# ", Color::Magenta),
163                    HeadingLevel::H2 => ("## ", Color::Cyan),
164                    HeadingLevel::H3 => ("### ", Color::Green),
165                    HeadingLevel::H4 => ("#### ", Color::Yellow),
166                    HeadingLevel::H5 => ("##### ", Color::Blue),
167                    HeadingLevel::H6 => ("###### ", Color::Red),
168                };
169                self.push_style(Modifier::BOLD, Some(color));
170                self.current_spans.push(Span::styled(
171                    prefix.to_string(),
172                    self.current_style(),
173                ));
174            }
175            Event::End(TagEnd::Heading(_)) => {
176                if let Some(line) = self.flush_line() {
177                    lines.push(line);
178                }
179                self.heading_level = None;
180                self.pop_style();
181                lines.push(Line::raw("")); // blank line after heading
182            }
183
184            Event::Start(Tag::Paragraph) => {}
185            Event::End(TagEnd::Paragraph) => {
186                if let Some(line) = self.flush_line() {
187                    lines.push(line);
188                }
189                lines.push(Line::raw("")); // blank line after paragraph
190            }
191
192            // Inline formatting
193            Event::Start(Tag::Strong) => {
194                self.push_style(Modifier::BOLD, None);
195            }
196            Event::End(TagEnd::Strong) => {
197                self.pop_style();
198            }
199            Event::Start(Tag::Emphasis) => {
200                self.push_style(Modifier::ITALIC, None);
201            }
202            Event::End(TagEnd::Emphasis) => {
203                self.pop_style();
204            }
205            Event::Start(Tag::Strikethrough) => {
206                self.push_style(Modifier::CROSSED_OUT, None);
207            }
208            Event::End(TagEnd::Strikethrough) => {
209                self.pop_style();
210            }
211
212            // Inline code
213            Event::Code(code) => {
214                self.current_spans.push(Span::styled(
215                    format!("`{code}`"),
216                    Style::default()
217                        .fg(Color::Yellow)
218                        .add_modifier(Modifier::BOLD),
219                ));
220            }
221
222            // Text content
223            Event::Text(text) => {
224                if self.in_code_block {
225                    // Code block: render each line with background
226                    for line_text in text.as_ref().split('\n') {
227                        if !self.current_spans.is_empty() {
228                            if let Some(line) = self.flush_line() {
229                                lines.push(line);
230                            }
231                        }
232                        self.current_spans.push(Span::styled(
233                            format!("  {line_text}"),
234                            Style::default().fg(Color::Green),
235                        ));
236                    }
237                } else if let Some(ref mut table) = self.table_state {
238                    table.current_cell.push_str(text.as_ref());
239                } else {
240                    self.current_spans.push(Span::styled(
241                        text.to_string(),
242                        self.current_style(),
243                    ));
244                }
245            }
246
247            Event::SoftBreak => {
248                self.current_spans.push(Span::raw(" ".to_string()));
249            }
250            Event::HardBreak => {
251                if let Some(line) = self.flush_line() {
252                    lines.push(line);
253                }
254            }
255
256            // Links
257            Event::Start(Tag::Link { dest_url, .. }) => {
258                self.push_style(Modifier::UNDERLINED, Some(Color::Blue));
259                // Store URL for display after link text
260                self.current_spans.push(Span::raw(String::new())); // placeholder
261                let _ = dest_url; // we'll show URL after text ends
262            }
263            Event::End(TagEnd::Link) => {
264                self.pop_style();
265            }
266
267            // Lists
268            Event::Start(Tag::List(start_num)) => {
269                let ordered = start_num.is_some();
270                let start = start_num.unwrap_or(0) as usize;
271                self.list_stack.push((ordered, start));
272            }
273            Event::End(TagEnd::List(_)) => {
274                self.list_stack.pop();
275                if self.list_stack.is_empty() {
276                    lines.push(Line::raw("")); // blank line after top-level list
277                }
278            }
279            Event::Start(Tag::Item) => {
280                let indent = "  ".repeat(self.list_stack.len().saturating_sub(1));
281                if let Some((ordered, num)) = self.list_stack.last_mut() {
282                    let bullet = if *ordered {
283                        *num += 1;
284                        format!("{indent}{}. ", *num)
285                    } else {
286                        format!("{indent}  - ")
287                    };
288                    self.current_spans.push(Span::styled(
289                        bullet,
290                        Style::default().fg(Color::Cyan),
291                    ));
292                }
293            }
294            Event::End(TagEnd::Item) => {
295                if let Some(line) = self.flush_line() {
296                    lines.push(line);
297                }
298            }
299
300            // Blockquotes
301            Event::Start(Tag::BlockQuote(_)) => {
302                self.in_blockquote = true;
303                self.push_style(Modifier::DIM, Some(Color::DarkGray));
304            }
305            Event::End(TagEnd::BlockQuote(_)) => {
306                if let Some(line) = self.flush_line() {
307                    lines.push(line);
308                }
309                self.in_blockquote = false;
310                self.pop_style();
311                lines.push(Line::raw(""));
312            }
313
314            // Code blocks (non-mermaid)
315            Event::Start(Tag::CodeBlock(kind)) => {
316                self.in_code_block = true;
317                match kind {
318                    CodeBlockKind::Fenced(lang) => {
319                        self.code_block_lang = lang.to_string();
320                        lines.push(Line::from(Span::styled(
321                            format!("  ```{lang}"),
322                            Style::default().fg(Color::DarkGray),
323                        )));
324                    }
325                    CodeBlockKind::Indented => {
326                        lines.push(Line::from(Span::styled(
327                            "  ```".to_string(),
328                            Style::default().fg(Color::DarkGray),
329                        )));
330                    }
331                }
332            }
333            Event::End(TagEnd::CodeBlock) => {
334                if let Some(line) = self.flush_line() {
335                    lines.push(line);
336                }
337                self.in_code_block = false;
338                self.code_block_lang.clear();
339                lines.push(Line::from(Span::styled(
340                    "  ```".to_string(),
341                    Style::default().fg(Color::DarkGray),
342                )));
343                lines.push(Line::raw(""));
344            }
345
346            // Tables
347            Event::Start(Tag::Table(_)) => {
348                self.table_state = Some(TableState {
349                    rows: Vec::new(),
350                    current_row: Vec::new(),
351                    current_cell: String::new(),
352                    in_head: false,
353                });
354            }
355            Event::End(TagEnd::Table) => {
356                if let Some(table) = self.table_state.take() {
357                    lines.extend(render_table(&table.rows));
358                    lines.push(Line::raw(""));
359                }
360            }
361            Event::Start(Tag::TableHead) => {
362                if let Some(ref mut t) = self.table_state {
363                    t.in_head = true;
364                }
365            }
366            Event::End(TagEnd::TableHead) => {
367                if let Some(ref mut t) = self.table_state {
368                    t.rows.push(std::mem::take(&mut t.current_row));
369                    t.in_head = false;
370                }
371            }
372            Event::Start(Tag::TableRow) => {}
373            Event::End(TagEnd::TableRow) => {
374                if let Some(ref mut t) = self.table_state {
375                    t.rows.push(std::mem::take(&mut t.current_row));
376                }
377            }
378            Event::Start(Tag::TableCell) => {
379                if let Some(ref mut t) = self.table_state {
380                    t.current_cell.clear();
381                }
382            }
383            Event::End(TagEnd::TableCell) => {
384                if let Some(ref mut t) = self.table_state {
385                    t.current_row.push(std::mem::take(&mut t.current_cell));
386                }
387            }
388
389            // Horizontal rule
390            Event::Rule => {
391                lines.push(Line::from(Span::styled(
392                    "──────────────────────────────────────────".to_string(),
393                    Style::default().fg(Color::DarkGray),
394                )));
395                lines.push(Line::raw(""));
396            }
397
398            // Task list markers
399            Event::TaskListMarker(checked) => {
400                let marker = if *checked { "[x] " } else { "[ ] " };
401                // Replace the last bullet with checkbox
402                if let Some(last) = self.current_spans.last_mut() {
403                    let content = last.content.to_string();
404                    *last = Span::styled(
405                        format!("{content}{marker}"),
406                        Style::default().fg(if *checked { Color::Green } else { Color::Yellow }),
407                    );
408                }
409            }
410
411            _ => {}
412        }
413
414        lines
415    }
416}
417
418/// Render a table as aligned ratatui Lines with box-drawing characters.
419fn render_table(rows: &[Vec<String>]) -> Vec<Line<'static>> {
420    if rows.is_empty() {
421        return Vec::new();
422    }
423
424    // Calculate column widths
425    let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
426    let mut col_widths = vec![0usize; num_cols];
427    for row in rows {
428        for (i, cell) in row.iter().enumerate() {
429            if i < num_cols {
430                col_widths[i] = col_widths[i].max(cell.len());
431            }
432        }
433    }
434
435    let mut lines = Vec::new();
436    let header_style = Style::default()
437        .fg(Color::Cyan)
438        .add_modifier(Modifier::BOLD);
439    let cell_style = Style::default();
440    let border_style = Style::default().fg(Color::DarkGray);
441
442    // Top border
443    let top_border: String = col_widths
444        .iter()
445        .map(|w| "─".repeat(w + 2))
446        .collect::<Vec<_>>()
447        .join("┬");
448    lines.push(Line::from(Span::styled(
449        format!("  ┌{top_border}┐"),
450        border_style,
451    )));
452
453    for (ri, row) in rows.iter().enumerate() {
454        let is_header = ri == 0;
455        let style = if is_header { header_style } else { cell_style };
456
457        let mut spans = vec![Span::styled("  │".to_string(), border_style)];
458        for (ci, width) in col_widths.iter().enumerate() {
459            let cell = row.get(ci).map(|s| s.as_str()).unwrap_or("");
460            spans.push(Span::styled(format!(" {cell:<width$} ", width = width), style));
461            spans.push(Span::styled("│".to_string(), border_style));
462        }
463        lines.push(Line::from(spans));
464
465        // Separator after header row
466        if is_header {
467            let sep: String = col_widths
468                .iter()
469                .map(|w| "─".repeat(w + 2))
470                .collect::<Vec<_>>()
471                .join("┼");
472            lines.push(Line::from(Span::styled(
473                format!("  ├{sep}┤"),
474                border_style,
475            )));
476        }
477    }
478
479    // Bottom border
480    let bot_border: String = col_widths
481        .iter()
482        .map(|w| "─".repeat(w + 2))
483        .collect::<Vec<_>>()
484        .join("┴");
485    lines.push(Line::from(Span::styled(
486        format!("  └{bot_border}┘"),
487        border_style,
488    )));
489
490    lines
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn test_heading_parsing() {
499        let blocks = parse_markdown("# Hello\n\nSome text");
500        assert!(!blocks.is_empty());
501    }
502
503    #[test]
504    fn test_mermaid_extraction() {
505        let md = "# Diagram\n\n```mermaid\ngraph TD\n    A-->B\n```\n\nAfter.";
506        let blocks = parse_markdown(md);
507        let has_mermaid = blocks.iter().any(|b| matches!(b, PreviewBlock::Mermaid(_)));
508        assert!(has_mermaid, "Should extract mermaid block");
509    }
510
511    #[test]
512    fn test_table_rendering() {
513        let md = "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |";
514        let blocks = parse_markdown(md);
515        assert!(!blocks.is_empty());
516    }
517}