Skip to main content

clankerdiff_ratatui/
markdown.rs

1//! Read-only whole-document and append-stream Markdown rendering.
2
3use crate::syntax::highlighted_line;
4use clankerdiff_markdown::{
5    MarkdownBlock, MarkdownBlockKind, MarkdownDocument, MarkdownInline, MarkdownStream,
6    MarkdownStreamIdentity,
7};
8use clankerdiff_syntax::{DocumentHighlights, LanguageHint, SourceSequenceId, SyntaxHighlighter};
9use clankerdiff_theme::{Fingerprint, ReviewTheme, Rgba};
10use ratatui::{
11    style::{Color, Modifier, Style},
12    text::{Line, Span},
13};
14use std::sync::Arc;
15use unicode_width::UnicodeWidthChar;
16
17/// Width and spacing policy for transcript-style Markdown.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct MarkdownRenderOptions {
20    pub width: u16,
21    pub block_spacing: bool,
22}
23
24impl Default for MarkdownRenderOptions {
25    fn default() -> Self {
26        Self {
27            width: 80,
28            block_spacing: true,
29        }
30    }
31}
32
33/// Deterministic work counters for incremental Markdown rendering.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct MarkdownRenderStats {
36    /// Bytes actually supplied to the Markdown parser.
37    pub parsed_bytes: usize,
38    pub parsed_documents: u64,
39    pub rows_generated: usize,
40    pub rows_reused: usize,
41}
42
43/// Renderer-owned cache for one logical streaming Markdown item.
44#[derive(Debug, Clone, Default)]
45pub struct StreamingMarkdownState {
46    parsed: Option<ParsedSource>,
47    options: MarkdownRenderOptions,
48    theme_revision: Fingerprint,
49    lines: Arc<[Line<'static>]>,
50    stats: MarkdownRenderStats,
51}
52
53impl StreamingMarkdownState {
54    pub fn reset(&mut self) {
55        *self = Self::default();
56    }
57
58    /// Returns accumulated renderer work counters and resets them.
59    pub fn take_stats(&mut self) -> MarkdownRenderStats {
60        std::mem::take(&mut self.stats)
61    }
62}
63
64#[derive(Debug, Clone)]
65struct ParsedSource {
66    identity: MarkdownStreamIdentity,
67    revision: u64,
68    document: MarkdownDocument,
69}
70
71impl ParsedSource {
72    fn parse(stream: &MarkdownStream) -> Self {
73        Self {
74            identity: stream.identity(),
75            revision: stream.revision(),
76            document: MarkdownDocument::parse(stream.source()),
77        }
78    }
79
80    fn matches(&self, stream: &MarkdownStream) -> bool {
81        self.identity == stream.identity() && self.revision == stream.revision()
82    }
83}
84
85/// Stateless whole-document renderer plus streaming cache services.
86#[derive(Debug, Default)]
87pub struct MarkdownRenderer;
88
89impl MarkdownRenderer {
90    #[must_use]
91    pub const fn new() -> Self {
92        Self
93    }
94
95    /// Renders a canonical semantic document without review gutters or controls.
96    #[must_use]
97    pub fn render_lines(
98        &self,
99        document: &MarkdownDocument,
100        options: MarkdownRenderOptions,
101        theme: &ReviewTheme,
102        highlighter: &mut SyntaxHighlighter,
103    ) -> Arc<[Line<'static>]> {
104        Arc::from(document_rows(document, options, theme, highlighter))
105    }
106
107    pub fn render_stream_lines(
108        &self,
109        state: &mut StreamingMarkdownState,
110        stream: &MarkdownStream,
111        options: MarkdownRenderOptions,
112        theme: &ReviewTheme,
113        highlighter: &mut SyntaxHighlighter,
114    ) -> Arc<[Line<'static>]> {
115        let theme_revision = theme.revision();
116        let parsed = match state.parsed.take() {
117            Some(parsed) if parsed.matches(stream) => {
118                if state.options == options && state.theme_revision == theme_revision {
119                    state.stats.rows_reused += state.lines.len();
120                    state.parsed = Some(parsed);
121                    return Arc::clone(&state.lines);
122                }
123                parsed
124            }
125            _ => {
126                state.stats.parsed_bytes += stream.source().len();
127                state.stats.parsed_documents += 1;
128                ParsedSource::parse(stream)
129            }
130        };
131        let rows = document_rows(&parsed.document, options, theme, highlighter);
132        state.stats.rows_generated += rows.len();
133        state.parsed = Some(parsed);
134        state.options = options;
135        state.theme_revision = theme_revision;
136        state.lines = Arc::from(rows);
137        Arc::clone(&state.lines)
138    }
139}
140
141fn document_rows(
142    document: &MarkdownDocument,
143    options: MarkdownRenderOptions,
144    theme: &ReviewTheme,
145    highlighter: &mut SyntaxHighlighter,
146) -> Vec<Line<'static>> {
147    let mut output = Vec::new();
148    for (index, block) in document.blocks().iter().enumerate() {
149        render_block(
150            block,
151            options.width.max(1),
152            theme,
153            highlighter,
154            &mut output,
155            "",
156        );
157        if options.block_spacing && index + 1 < document.blocks().len() {
158            output.push(Line::default());
159        }
160    }
161    output
162}
163
164/// Renders highlighted code lines as wrapped rows on the code background.
165fn code_rows(
166    lines: &[&str],
167    highlights: &DocumentHighlights,
168    theme: &ReviewTheme,
169    width: u16,
170    prefix: &str,
171) -> Vec<Line<'static>> {
172    let base = Style::new()
173        .fg(color(theme.markdown.code))
174        .bg(color(theme.diff.background));
175    let mut output = Vec::new();
176    for (index, line) in lines.iter().enumerate() {
177        let spans = highlights.line(index).unwrap_or_default();
178        let mut rendered = highlighted_line(line, spans, base);
179        if !prefix.is_empty() {
180            rendered
181                .spans
182                .insert(0, Span::styled(prefix.to_owned(), base));
183        }
184        push_wrapped_spans(&mut output, rendered.spans, width);
185    }
186    output
187}
188
189fn render_block(
190    block: &MarkdownBlock,
191    width: u16,
192    theme: &ReviewTheme,
193    highlighter: &mut SyntaxHighlighter,
194    output: &mut Vec<Line<'static>>,
195    prefix: &str,
196) {
197    match &block.kind {
198        MarkdownBlockKind::Heading { level, content } => {
199            let marker = format!("{} ", "#".repeat(usize::from(*level)));
200            let base = Style::new()
201                .fg(color(theme.markdown.heading))
202                .add_modifier(Modifier::BOLD);
203            let mut spans = vec![Span::styled(format!("{prefix}{marker}"), base)];
204            spans.extend(inline_spans(content, base, theme));
205            push_wrapped_spans(output, spans, width);
206        }
207        MarkdownBlockKind::Paragraph { content } | MarkdownBlockKind::HtmlFallback { content } => {
208            let base = Style::new().fg(color(theme.diff.foreground));
209            let mut spans = vec![Span::styled(prefix.to_owned(), base)];
210            spans.extend(inline_spans(content, base, theme));
211            push_wrapped_spans(output, spans, width);
212        }
213        MarkdownBlockKind::List {
214            ordered,
215            start,
216            items,
217        } => {
218            for (index, item) in items.iter().enumerate() {
219                let marker = if *ordered {
220                    format!("{}.", start.unwrap_or(1).saturating_add(index as u64))
221                } else {
222                    "•".to_owned()
223                };
224                let base = Style::new().fg(color(theme.diff.foreground));
225                let mut spans = vec![Span::styled(
226                    format!("{prefix}{}{marker} ", "  ".repeat(item.depth)),
227                    base,
228                )];
229                spans.extend(inline_spans(&item.content, base, theme));
230                push_wrapped_spans(output, spans, width);
231                for child in &item.blocks {
232                    render_block(
233                        child,
234                        width,
235                        theme,
236                        highlighter,
237                        output,
238                        &format!("{prefix}  "),
239                    );
240                }
241            }
242        }
243        MarkdownBlockKind::BlockQuote { blocks } => {
244            for child in blocks {
245                render_block(
246                    child,
247                    width,
248                    theme,
249                    highlighter,
250                    output,
251                    &format!("{prefix}│ "),
252                );
253            }
254        }
255        MarkdownBlockKind::CodeBlock(code) => {
256            let lines = code
257                .lines
258                .iter()
259                .map(|line| line.text.as_str())
260                .collect::<Vec<_>>();
261            let highlights = highlighter
262                .with_theme(&theme.syntax)
263                .highlight_document_lines(
264                    SourceSequenceId::from_lines(lines.iter().copied()),
265                    LanguageHint::InfoString(code.highlight_hint()),
266                    lines.iter().copied(),
267                );
268            output.extend(code_rows(&lines, &highlights, theme, width, prefix));
269        }
270        MarkdownBlockKind::Table(table) => {
271            for row in &table.rows {
272                let base = Style::new().fg(color(theme.diff.foreground));
273                let border = Style::new().fg(color(theme.diff.border));
274                let mut spans = vec![Span::styled(format!("{prefix}│ "), border)];
275                for (index, cell) in row.cells.iter().enumerate() {
276                    if index > 0 {
277                        spans.push(Span::styled(" │ ", border));
278                    }
279                    let cell_base = if row.header {
280                        base.add_modifier(Modifier::BOLD)
281                    } else {
282                        base
283                    };
284                    spans.extend(inline_spans(&cell.content, cell_base, theme));
285                }
286                spans.push(Span::styled(" │", border));
287                push_wrapped_spans(output, spans, width);
288            }
289        }
290        MarkdownBlockKind::Rule => output.push(Line::styled(
291            "─".repeat(usize::from(width)),
292            Style::new().fg(color(theme.diff.border)),
293        )),
294    }
295}
296
297fn inline_spans(
298    inlines: &[MarkdownInline],
299    style: Style,
300    theme: &ReviewTheme,
301) -> Vec<Span<'static>> {
302    fn append(
303        inline: &MarkdownInline,
304        style: Style,
305        theme: &ReviewTheme,
306        output: &mut Vec<Span<'static>>,
307    ) {
308        match inline {
309            MarkdownInline::Text(text) => output.push(Span::styled(text.clone(), style)),
310            MarkdownInline::Code(text) => output.push(Span::styled(
311                text.clone(),
312                style
313                    .fg(color(theme.markdown.code))
314                    .bg(color(theme.diff.border)),
315            )),
316            MarkdownInline::Strong(children) => children.iter().for_each(|child| {
317                append(child, style.add_modifier(Modifier::BOLD), theme, output);
318            }),
319            MarkdownInline::Emphasis(children) => children.iter().for_each(|child| {
320                append(child, style.add_modifier(Modifier::ITALIC), theme, output);
321            }),
322            MarkdownInline::Strikethrough(children) => children.iter().for_each(|child| {
323                append(
324                    child,
325                    style.add_modifier(Modifier::CROSSED_OUT),
326                    theme,
327                    output,
328                );
329            }),
330            MarkdownInline::Link { content, .. } => content.iter().for_each(|child| {
331                append(
332                    child,
333                    style
334                        .fg(color(theme.markdown.link))
335                        .add_modifier(Modifier::UNDERLINED),
336                    theme,
337                    output,
338                );
339            }),
340            MarkdownInline::SoftBreak => output.push(Span::styled(" ", style)),
341            MarkdownInline::HardBreak => output.push(Span::styled("\n", style)),
342            MarkdownInline::ImageAlt(text) => output.push(Span::styled(
343                format!("Image: {text}"),
344                style
345                    .fg(color(theme.markdown.link))
346                    .add_modifier(Modifier::ITALIC),
347            )),
348        }
349    }
350
351    let mut output = Vec::new();
352    for inline in inlines {
353        append(inline, style, theme, &mut output);
354    }
355    output
356}
357
358fn push_wrapped_spans(output: &mut Vec<Line<'static>>, spans: Vec<Span<'static>>, width: u16) {
359    let width = usize::from(width.max(1));
360    let mut rows = vec![Line::default()];
361    let mut used = 0usize;
362    for span in spans {
363        let style = span.style;
364        for character in span.content.chars() {
365            if character == '\n' {
366                rows.push(Line::default());
367                used = 0;
368                continue;
369            }
370            let character_width = character.width().unwrap_or(0);
371            if used > 0 && used.saturating_add(character_width) > width {
372                rows.push(Line::default());
373                used = 0;
374            }
375            let row = rows.last_mut().expect("wrapping always retains one row");
376            if let Some(last) = row.spans.last_mut().filter(|last| last.style == style) {
377                last.content.to_mut().push(character);
378            } else {
379                row.spans.push(Span::styled(character.to_string(), style));
380            }
381            used = used.saturating_add(character_width);
382        }
383    }
384    output.extend(rows);
385}
386
387const fn color(value: Rgba) -> Color {
388    Color::Rgb(value.r, value.g, value.b)
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn finished_stream_matches_one_shot_for_every_utf8_chunk_boundary() {
397        let source = "# Héading\n\nText with 世界.\n\n```rust\n/* open\nstill comment */\n```\n";
398        let options = MarkdownRenderOptions {
399            width: 36,
400            block_spacing: true,
401        };
402        let theme = ReviewTheme::default();
403        let expected = MarkdownRenderer::new().render_lines(
404            &MarkdownDocument::parse(source),
405            options,
406            &theme,
407            &mut SyntaxHighlighter::default(),
408        );
409
410        for split in source
411            .char_indices()
412            .map(|(offset, _)| offset)
413            .chain(std::iter::once(source.len()))
414        {
415            let mut stream = MarkdownStream::new();
416            stream.push(&source[..split]);
417            stream.push(&source[split..]);
418            stream.finish();
419            let actual = MarkdownRenderer::new().render_stream_lines(
420                &mut StreamingMarkdownState::default(),
421                &stream,
422                options,
423                &theme,
424                &mut SyntaxHighlighter::default(),
425            );
426            assert_eq!(actual, expected, "split at byte {split}");
427        }
428    }
429
430    #[test]
431    fn open_fence_stream_matches_the_current_one_shot_snapshot() {
432        let source = "Settled prose.\n\n```rust\n/* open\nstill open";
433        let mut stream = MarkdownStream::new();
434        for chunk in ["Settled prose.\n\n```rust\n", "/* open\n", "still open"] {
435            stream.push(chunk);
436        }
437        assert!(!stream.is_finished());
438        let options = MarkdownRenderOptions {
439            width: 48,
440            block_spacing: true,
441        };
442        let theme = ReviewTheme::default();
443        let expected = MarkdownRenderer::new().render_lines(
444            &MarkdownDocument::parse(source),
445            options,
446            &theme,
447            &mut SyntaxHighlighter::default(),
448        );
449        let actual = MarkdownRenderer::new().render_stream_lines(
450            &mut StreamingMarkdownState::default(),
451            &stream,
452            options,
453            &theme,
454            &mut SyntaxHighlighter::default(),
455        );
456        assert_eq!(actual, expected);
457    }
458
459    #[test]
460    fn finishing_an_open_fence_commits_its_partial_final_line() {
461        let source = "```rust\n/* open\nstill open";
462        let mut stream = MarkdownStream::new();
463        stream.push(source);
464        let mut state = StreamingMarkdownState::default();
465        let renderer = MarkdownRenderer::new();
466        let options = MarkdownRenderOptions::default();
467        let theme = ReviewTheme::default();
468        let mut highlighter = SyntaxHighlighter::default();
469        renderer.render_stream_lines(&mut state, &stream, options, &theme, &mut highlighter);
470        stream.finish();
471        let actual =
472            renderer.render_stream_lines(&mut state, &stream, options, &theme, &mut highlighter);
473        let expected = renderer.render_lines(
474            &MarkdownDocument::parse(source),
475            options,
476            &theme,
477            &mut SyntaxHighlighter::default(),
478        );
479        assert_eq!(actual, expected);
480    }
481
482    #[test]
483    fn unchanged_stream_reuses_rendered_lines() {
484        let mut stream = MarkdownStream::new();
485        stream.push("settled\n\n");
486        let mut state = StreamingMarkdownState::default();
487        let renderer = MarkdownRenderer::new();
488        let mut highlighter = SyntaxHighlighter::default();
489        let theme = ReviewTheme::default();
490        let first = renderer.render_stream_lines(
491            &mut state,
492            &stream,
493            MarkdownRenderOptions::default(),
494            &theme,
495            &mut highlighter,
496        );
497        let second = renderer.render_stream_lines(
498            &mut state,
499            &stream,
500            MarkdownRenderOptions::default(),
501            &theme,
502            &mut highlighter,
503        );
504        assert!(Arc::ptr_eq(&first, &second));
505    }
506
507    #[test]
508    fn read_only_rendering_preserves_nested_inline_styles() {
509        let source = "**bold and *both***, ~~gone~~, [`link`](https://example.com), `code`.";
510        let lines = MarkdownRenderer::new().render_lines(
511            &MarkdownDocument::parse(source),
512            MarkdownRenderOptions::default(),
513            &ReviewTheme::default(),
514            &mut SyntaxHighlighter::default(),
515        );
516        let spans = &lines[0].spans;
517        let both = spans
518            .iter()
519            .find(|span| span.content.contains("both"))
520            .unwrap();
521        assert!(both.style.add_modifier.contains(Modifier::BOLD));
522        assert!(both.style.add_modifier.contains(Modifier::ITALIC));
523        let gone = spans
524            .iter()
525            .find(|span| span.content.contains("gone"))
526            .unwrap();
527        assert!(gone.style.add_modifier.contains(Modifier::CROSSED_OUT));
528        let link = spans
529            .iter()
530            .find(|span| span.content.contains("link"))
531            .unwrap();
532        assert!(link.style.add_modifier.contains(Modifier::UNDERLINED));
533        let code = spans
534            .iter()
535            .find(|span| span.content.contains("code"))
536            .unwrap();
537        assert!(code.style.bg.is_some());
538    }
539
540    #[test]
541    fn stream_stats_reset_and_changed_snapshots_parse_complete_context() {
542        let renderer = MarkdownRenderer::new();
543        let theme = ReviewTheme::default();
544        let options = MarkdownRenderOptions::default();
545        let mut stream = MarkdownStream::new();
546        let mut state = StreamingMarkdownState::default();
547        let mut highlighter = SyntaxHighlighter::default();
548        stream.push("first paragraph\n\n");
549        renderer.render_stream_lines(&mut state, &stream, options, &theme, &mut highlighter);
550        let first = state.take_stats();
551        assert_eq!(first.parsed_bytes, "first paragraph\n\n".len());
552        stream.push("second paragraph\n\n");
553        renderer.render_stream_lines(&mut state, &stream, options, &theme, &mut highlighter);
554        let second = state.take_stats();
555        assert_eq!(second.parsed_bytes, stream.source().len());
556        assert_eq!(state.take_stats(), MarkdownRenderStats::default());
557
558        stream.replace("replacement is longer than both prior paragraphs\n\n");
559        let replaced =
560            renderer.render_stream_lines(&mut state, &stream, options, &theme, &mut highlighter);
561        let expected = renderer.render_lines(
562            &MarkdownDocument::parse(stream.source()),
563            options,
564            &theme,
565            &mut SyntaxHighlighter::default(),
566        );
567        assert_eq!(replaced, expected);
568        assert_eq!(state.take_stats().parsed_bytes, stream.source().len());
569    }
570
571    #[test]
572    fn stream_cache_invalidates_for_spacing_and_markdown_palette_changes() {
573        let mut stream = MarkdownStream::new();
574        stream.push("# Heading\n\nParagraph\n\n");
575        let mut state = StreamingMarkdownState::default();
576        let renderer = MarkdownRenderer::new();
577        let mut highlighter = SyntaxHighlighter::default();
578        let theme = ReviewTheme::default();
579        let spaced = renderer.render_stream_lines(
580            &mut state,
581            &stream,
582            MarkdownRenderOptions {
583                width: 80,
584                block_spacing: true,
585            },
586            &theme,
587            &mut highlighter,
588        );
589        let compact = renderer.render_stream_lines(
590            &mut state,
591            &stream,
592            MarkdownRenderOptions {
593                width: 80,
594                block_spacing: false,
595            },
596            &theme,
597            &mut highlighter,
598        );
599        assert_ne!(spaced, compact);
600
601        let mut changed = theme.clone();
602        changed.markdown.heading = Rgba::new(1, 2, 3, 255);
603        let recolored = renderer.render_stream_lines(
604            &mut state,
605            &stream,
606            MarkdownRenderOptions {
607                width: 80,
608                block_spacing: false,
609            },
610            &changed,
611            &mut highlighter,
612        );
613        assert_ne!(compact, recolored);
614    }
615}