Skip to main content

clankerdiff_ratatui/
markdown.rs

1//! Read-only whole-document and append-stream Markdown rendering.
2
3use crate::{
4    color::{layered_style, page_color},
5    markdown_layout::{
6        MarkdownLayout, MarkdownLayoutOptions, MarkdownPresentation, MarkdownRow,
7        MarkdownRowUpdate, RowChunk, RowStore,
8    },
9    syntax::highlighted_line,
10    text::{FitOptions, fit_spans},
11};
12use clankerdiff_markdown::{
13    MarkdownBlock, MarkdownBlockKind, MarkdownCodeBlock, MarkdownDocument, MarkdownInline,
14    MarkdownLineRange, MarkdownListItem, MarkdownSourceRole, MarkdownSourceStyle, MarkdownStream,
15    MarkdownStreamIdentity, MarkdownTable, MarkdownTableAlignment, MarkdownTargetId, SourceRange,
16    rendered_text,
17};
18use clankerdiff_syntax::{
19    DocumentHighlights, HighlightSpan, LanguageHint, SourceSequenceId, SyntaxHighlighter,
20};
21use clankerdiff_theme::{Fingerprint, ReviewTheme, Rgba};
22use ratatui::{
23    style::{Modifier, Style},
24    text::{Line, Span},
25};
26use std::{collections::HashMap, sync::Arc};
27use unicode_segmentation::UnicodeSegmentation;
28use unicode_width::UnicodeWidthStr;
29
30/// Deterministic work counters for incremental Markdown rendering.
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
32pub struct MarkdownRenderStats {
33    /// Bytes actually supplied to the Markdown parser.
34    pub parsed_bytes: usize,
35    pub parsed_documents: u64,
36    pub rows_generated: usize,
37    pub rows_reused: usize,
38    pub rows_materialized: usize,
39    pub highlighted_bytes: usize,
40}
41
42/// Renderer-owned cache for one logical streaming Markdown item.
43#[derive(Debug, Clone, Default)]
44pub struct StreamingMarkdownState {
45    layout: MarkdownLayout,
46    cache: LayoutCache,
47    revision: u64,
48    /// Last revision handed out; survives `reset` so hosts never see a reuse.
49    next_revision: u64,
50    update: Option<MarkdownRowUpdate>,
51    stats: MarkdownRenderStats,
52}
53
54impl StreamingMarkdownState {
55    pub fn reset(&mut self) {
56        *self = Self {
57            next_revision: self.next_revision,
58            ..Self::default()
59        };
60    }
61
62    /// Returns accumulated renderer work counters and resets them.
63    pub fn take_stats(&mut self) -> MarkdownRenderStats {
64        std::mem::take(&mut self.stats)
65    }
66
67    #[must_use]
68    pub const fn revision(&self) -> u64 {
69        self.revision
70    }
71
72    #[must_use]
73    pub fn update_since(&self, base_revision: u64) -> MarkdownRowUpdate {
74        let rows = self.layout.rows();
75        if base_revision == self.revision {
76            return MarkdownRowUpdate {
77                base_revision,
78                revision: self.revision,
79                first_changed_row: rows.len(),
80                replacement: rows.slice(rows.len()..rows.len()),
81                reset: false,
82            };
83        }
84        if let Some(update) = &self.update
85            && update.base_revision == base_revision
86        {
87            return update.clone();
88        }
89        MarkdownRowUpdate {
90            base_revision,
91            revision: self.revision,
92            first_changed_row: 0,
93            replacement: rows.clone(),
94            reset: true,
95        }
96    }
97}
98
99#[derive(Debug, Clone)]
100struct ParsedSource {
101    identity: MarkdownStreamIdentity,
102    revision: u64,
103    document: Arc<MarkdownDocument>,
104}
105
106impl ParsedSource {
107    fn parse(stream: &MarkdownStream) -> Self {
108        Self {
109            identity: stream.identity(),
110            revision: stream.revision(),
111            document: Arc::new(MarkdownDocument::parse(stream.source())),
112        }
113    }
114
115    fn matches(&self, stream: &MarkdownStream) -> bool {
116        self.identity == stream.identity() && self.revision == stream.revision()
117    }
118}
119
120/// Stateless whole-document renderer plus streaming cache services.
121#[derive(Debug, Default)]
122pub struct MarkdownRenderer;
123
124impl MarkdownRenderer {
125    #[must_use]
126    pub const fn new() -> Self {
127        Self
128    }
129
130    /// Renders a canonical semantic document without review gutters or controls.
131    #[must_use]
132    pub fn render_lines(
133        &self,
134        document: &MarkdownDocument,
135        options: MarkdownLayoutOptions,
136        theme: &ReviewTheme,
137        highlighter: &mut SyntaxHighlighter,
138    ) -> Arc<[Line<'static>]> {
139        self.render_layout(document, options, theme, highlighter)
140            .materialize()
141    }
142
143    pub fn render_stream_lines(
144        &self,
145        state: &mut StreamingMarkdownState,
146        stream: &MarkdownStream,
147        options: MarkdownLayoutOptions,
148        theme: &ReviewTheme,
149        highlighter: &mut SyntaxHighlighter,
150    ) -> Arc<[Line<'static>]> {
151        let layout = self.render_stream_layout(state, stream, options, theme, highlighter);
152        if !layout.is_materialized() {
153            state.stats.rows_materialized += layout.row_count();
154        }
155        layout.materialize()
156    }
157
158    #[must_use]
159    pub fn render_layout(
160        &self,
161        document: &MarkdownDocument,
162        options: MarkdownLayoutOptions,
163        theme: &ReviewTheme,
164        highlighter: &mut SyntaxHighlighter,
165    ) -> MarkdownLayout {
166        layout_document(document, options, theme, highlighter, None)
167            .0
168            .finish(document)
169            .layout
170    }
171
172    pub fn render_stream_layout(
173        &self,
174        state: &mut StreamingMarkdownState,
175        stream: &MarkdownStream,
176        options: MarkdownLayoutOptions,
177        theme: &ReviewTheme,
178        highlighter: &mut SyntaxHighlighter,
179    ) -> MarkdownLayout {
180        let revision = theme.revision();
181        if state.cache.is_current(stream, options, revision) {
182            state.stats.rows_reused += state.layout.row_count();
183            return state.layout.clone();
184        }
185        let reset = state.cache.resets_for(stream, options, revision);
186        let highlighted_before = highlighter.stats().bytes;
187        let built = state.cache.render(
188            stream,
189            options,
190            theme,
191            revision,
192            highlighter,
193            &mut state.stats,
194        );
195        state.stats.highlighted_bytes +=
196            highlighter.stats().bytes.saturating_sub(highlighted_before);
197        let first_changed_row = if reset {
198            0
199        } else {
200            state
201                .layout
202                .rows()
203                .iter()
204                .zip(built.layout.rows().iter())
205                .take_while(|(left, right)| Arc::ptr_eq(left, right) || left == right)
206                .count()
207        };
208        state.next_revision += 1;
209        let revision = state.next_revision;
210        state.update = Some(MarkdownRowUpdate {
211            base_revision: state.revision,
212            revision,
213            first_changed_row,
214            replacement: built
215                .layout
216                .rows()
217                .slice(first_changed_row..built.layout.row_count()),
218            reset,
219        });
220        state.revision = revision;
221        state.stats.rows_generated += built.generated;
222        state.stats.rows_reused += built.reused;
223        state.layout = built.layout;
224        state.layout.clone()
225    }
226}
227
228struct LayoutBuild {
229    layout: MarkdownLayout,
230    generated: usize,
231    reused: usize,
232}
233
234#[derive(Default)]
235struct RowBuilder {
236    store: RowStore,
237    generated: usize,
238    reused: usize,
239}
240
241impl RowBuilder {
242    fn generated(&mut self, rows: RowChunk) {
243        self.generated += rows.len();
244        self.store.push(rows);
245    }
246
247    fn reused(&mut self, rows: RowChunk) {
248        self.reused += rows.len();
249        self.store.push(rows);
250    }
251
252    fn finish(self, document: &MarkdownDocument) -> LayoutBuild {
253        LayoutBuild {
254            layout: MarkdownLayout::new(self.store, document),
255            generated: self.generated,
256            reused: self.reused,
257        }
258    }
259}
260
261/// Per-presentation reuse entries for the previously laid-out document.
262#[derive(Debug, Clone)]
263enum CacheEntries {
264    Blocks(Vec<RowChunk>),
265    SourceLines(Vec<CachedSourceLine>),
266}
267
268impl Default for CacheEntries {
269    fn default() -> Self {
270        Self::Blocks(Vec::new())
271    }
272}
273
274/// The last parsed stream plus the reuse entries built from it.
275#[derive(Debug, Clone, Default)]
276struct LayoutCache {
277    parsed: Option<ParsedSource>,
278    entries: CacheEntries,
279    options: Option<MarkdownLayoutOptions>,
280    theme: Option<Fingerprint>,
281}
282
283impl LayoutCache {
284    fn matches(&self, options: MarkdownLayoutOptions, theme: Fingerprint) -> bool {
285        self.options == Some(options) && self.theme == Some(theme)
286    }
287
288    /// True when the cached layout already reflects `stream` under `options`
289    /// and `theme`, so no rendering is required.
290    fn is_current(
291        &self,
292        stream: &MarkdownStream,
293        options: MarkdownLayoutOptions,
294        theme: Fingerprint,
295    ) -> bool {
296        self.matches(options, theme) && self.parsed.as_ref().is_some_and(|p| p.matches(stream))
297    }
298
299    /// True when no row of the previous layout can survive: a different
300    /// stream identity, or different options or theme.
301    fn resets_for(
302        &self,
303        stream: &MarkdownStream,
304        options: MarkdownLayoutOptions,
305        theme: Fingerprint,
306    ) -> bool {
307        !self.matches(options, theme)
308            || self
309                .parsed
310                .as_ref()
311                .is_none_or(|p| p.identity != stream.identity())
312    }
313
314    fn render(
315        &mut self,
316        stream: &MarkdownStream,
317        options: MarkdownLayoutOptions,
318        theme: &ReviewTheme,
319        revision: Fingerprint,
320        highlighter: &mut SyntaxHighlighter,
321        stats: &mut MarkdownRenderStats,
322    ) -> LayoutBuild {
323        let reusable = self.matches(options, revision);
324        let (parsed, previous) = match self.parsed.take() {
325            // Same source under different options or theme: nothing to reuse.
326            Some(parsed) if parsed.matches(stream) => (parsed, None),
327            previous => {
328                stats.parsed_bytes += stream.source().len();
329                stats.parsed_documents += 1;
330                (ParsedSource::parse(stream), previous.filter(|_| reusable))
331            }
332        };
333        let cached = previous
334            .as_ref()
335            .map(|previous| (&*previous.document, &self.entries));
336        let (rows, entries) =
337            layout_document(&parsed.document, options, theme, highlighter, cached);
338        let built = rows.finish(&parsed.document);
339        self.parsed = Some(parsed);
340        self.entries = entries;
341        self.options = Some(options);
342        self.theme = Some(revision);
343        built
344    }
345}
346
347fn layout_document(
348    document: &MarkdownDocument,
349    options: MarkdownLayoutOptions,
350    theme: &ReviewTheme,
351    highlighter: &mut SyntaxHighlighter,
352    previous: Option<(&MarkdownDocument, &CacheEntries)>,
353) -> (RowBuilder, CacheEntries) {
354    let mut rows = RowBuilder::default();
355    let entries = match options.presentation {
356        MarkdownPresentation::SourceLines => {
357            let cached = match previous {
358                Some((_, CacheEntries::SourceLines(lines))) => lines.as_slice(),
359                _ => &[],
360            };
361            CacheEntries::SourceLines(source_rows(
362                document,
363                options,
364                theme,
365                highlighter,
366                cached,
367                &mut rows,
368            ))
369        }
370        MarkdownPresentation::Rendered => {
371            let cached = match previous {
372                Some((document, CacheEntries::Blocks(blocks))) => {
373                    Some((document, blocks.as_slice()))
374                }
375                _ => None,
376            };
377            CacheEntries::Blocks(rendered_rows(
378                document,
379                options,
380                theme,
381                highlighter,
382                cached,
383                &mut rows,
384            ))
385        }
386    };
387    (rows, entries)
388}
389
390fn rendered_rows(
391    document: &MarkdownDocument,
392    options: MarkdownLayoutOptions,
393    theme: &ReviewTheme,
394    highlighter: &mut SyntaxHighlighter,
395    previous: Option<(&MarkdownDocument, &[RowChunk])>,
396    rows: &mut RowBuilder,
397) -> Vec<RowChunk> {
398    let mut blocks = Vec::with_capacity(document.blocks().len());
399    let mut next_source_line = 1;
400    let source_ranges = options
401        .preserve_source_gaps
402        .then(|| source_line_ranges(document.source()));
403    for (index, block) in document.blocks().iter().enumerate() {
404        if let Some(ranges) = &source_ranges {
405            for line in next_source_line..block.source.lines.start {
406                rows.generated(Arc::from([Arc::new(MarkdownRow {
407                    line: Line::default(),
408                    source: ranges.get(line - 1).cloned(),
409                    target: None,
410                })]));
411            }
412        } else if index > 0 && options.block_spacing {
413            rows.generated(Arc::from([Arc::new(MarkdownRow {
414                line: Line::default(),
415                source: None,
416                target: None,
417            })]));
418        }
419        let cached = previous
420            .filter(|(document, _)| document.blocks().get(index) == Some(block))
421            .and_then(|(_, chunks)| chunks.get(index));
422        let block_rows = if let Some(cached) = cached {
423            rows.reused(Arc::clone(cached));
424            Arc::clone(cached)
425        } else {
426            let mut output = RowOutput::new(options);
427            render_block(
428                block,
429                theme,
430                highlighter,
431                &mut output,
432                BlockContext {
433                    target: None,
434                    foreground: theme.diff.foreground,
435                    prefix: "",
436                },
437            );
438            let chunk: RowChunk = Arc::from(output.rows);
439            rows.generated(Arc::clone(&chunk));
440            chunk
441        };
442        blocks.push(block_rows);
443        next_source_line = block.source.lines.end.saturating_add(1);
444    }
445    blocks
446}
447
448/// Foreground and background for fenced code, composited over the page.
449fn fenced_code_style(theme: &ReviewTheme) -> Style {
450    layered_style(
451        theme.markdown.code,
452        theme.markdown.code_background,
453        theme.diff.background,
454    )
455}
456
457/// Applies the inline-code role on top of `style`.
458fn inline_code_style(style: Style, theme: &ReviewTheme) -> Style {
459    style.patch(layered_style(
460        theme.markdown.inline_code,
461        theme.markdown.inline_code_background,
462        theme.diff.background,
463    ))
464}
465
466/// Highlights a fenced block with its complete content as parser context.
467fn highlight_code_block(
468    code: &MarkdownCodeBlock,
469    theme: &ReviewTheme,
470    highlighter: &mut SyntaxHighlighter,
471) -> Arc<DocumentHighlights> {
472    let lines = code
473        .lines
474        .iter()
475        .map(|line| line.text.as_str())
476        .collect::<Vec<_>>();
477    highlighter
478        .with_theme(&theme.syntax)
479        .highlight_document_lines(
480            SourceSequenceId::from_lines(lines.iter().copied()),
481            LanguageHint::InfoString(code.highlight_hint()),
482            lines.iter().copied(),
483        )
484}
485
486/// Ownership and styling a block inherits from its enclosing blocks.
487#[derive(Clone, Copy)]
488struct BlockContext<'a> {
489    target: Option<MarkdownTargetId>,
490    foreground: Rgba,
491    prefix: &'a str,
492}
493
494/// Where the rows produced for one block element come from.
495#[derive(Clone, Copy)]
496struct RowOrigin<'a> {
497    source: &'a SourceRange,
498    target: Option<MarkdownTargetId>,
499}
500
501struct RowOutput {
502    rows: Vec<Arc<MarkdownRow>>,
503    options: MarkdownLayoutOptions,
504}
505
506impl RowOutput {
507    const fn new(options: MarkdownLayoutOptions) -> Self {
508        Self {
509            rows: Vec::new(),
510            options,
511        }
512    }
513
514    fn push(&mut self, line: Line<'static>, origin: RowOrigin<'_>) {
515        self.rows.push(Arc::new(MarkdownRow {
516            line,
517            source: Some(origin.source.clone()),
518            target: origin.target,
519        }));
520    }
521
522    fn push_wrapped(
523        &mut self,
524        spans: Vec<Span<'static>>,
525        continuation: &str,
526        origin: RowOrigin<'_>,
527    ) {
528        for line in fit_spans(spans, self.fit_options(self.options.width, continuation)) {
529            self.push(line, origin);
530        }
531    }
532
533    fn fit_options<'a>(&self, width: u16, continuation: &'a str) -> FitOptions<'a> {
534        FitOptions {
535            width: usize::from(width),
536            wrap: self.options.wrap,
537            tab_width: usize::from(self.options.tab_width),
538            continuation,
539        }
540    }
541}
542
543fn render_block(
544    block: &MarkdownBlock,
545    theme: &ReviewTheme,
546    highlighter: &mut SyntaxHighlighter,
547    output: &mut RowOutput,
548    context: BlockContext<'_>,
549) {
550    let prefix = context.prefix;
551    let width = output.options.width;
552    let origin = RowOrigin {
553        source: &block.source,
554        target: block.target_id.or(context.target),
555    };
556    match &block.kind {
557        MarkdownBlockKind::Heading { level, content } => {
558            let marker = if output.options.heading_markers {
559                format!("{} ", "#".repeat(usize::from(*level)))
560            } else {
561                String::new()
562            };
563            let base = Style::new()
564                .fg(page_color(theme, theme.markdown.heading))
565                .add_modifier(Modifier::BOLD);
566            let mut spans = vec![Span::styled(format!("{prefix}{marker}"), base)];
567            spans.extend(inline_spans(content, base, theme));
568            output.push_wrapped(spans, prefix, origin);
569        }
570        MarkdownBlockKind::Paragraph { content } | MarkdownBlockKind::HtmlFallback { content } => {
571            let base = Style::new().fg(page_color(theme, context.foreground));
572            let mut spans = vec![Span::styled(prefix.to_owned(), base)];
573            spans.extend(inline_spans(content, base, theme));
574            output.push_wrapped(spans, prefix, origin);
575        }
576        MarkdownBlockKind::List {
577            ordered,
578            start,
579            items,
580        } => render_list(
581            items,
582            (*ordered, *start),
583            theme,
584            highlighter,
585            output,
586            BlockContext {
587                target: origin.target,
588                ..context
589            },
590        ),
591        MarkdownBlockKind::BlockQuote { blocks } => {
592            let quote_prefix = format!("{prefix}│ ");
593            for child in blocks {
594                render_block(
595                    child,
596                    theme,
597                    highlighter,
598                    output,
599                    BlockContext {
600                        target: origin.target,
601                        foreground: theme.markdown.quote,
602                        prefix: &quote_prefix,
603                    },
604                );
605            }
606        }
607        MarkdownBlockKind::CodeBlock(code) => {
608            render_code(code, theme, highlighter, output, origin.target, prefix);
609        }
610        MarkdownBlockKind::Table(table) => {
611            render_table(
612                table,
613                theme,
614                output,
615                context.foreground,
616                origin.target,
617                prefix,
618            );
619        }
620        MarkdownBlockKind::Rule => output.push(
621            Line::styled(
622                "─".repeat(usize::from(width)),
623                Style::new().fg(page_color(theme, theme.diff.border)),
624            ),
625            origin,
626        ),
627    }
628}
629
630fn render_list(
631    items: &[MarkdownListItem],
632    (ordered, start): (bool, Option<u64>),
633    theme: &ReviewTheme,
634    highlighter: &mut SyntaxHighlighter,
635    output: &mut RowOutput,
636    context: BlockContext<'_>,
637) {
638    let prefix = context.prefix;
639    let base = Style::new().fg(page_color(theme, context.foreground));
640    for (index, item) in items.iter().enumerate() {
641        let item_target = item.target_id.or(context.target);
642        let marker = if ordered {
643            format!("{}.", start.unwrap_or(1).saturating_add(index as u64))
644        } else {
645            "•".to_owned()
646        };
647        let mut spans = vec![Span::styled(
648            format!("{prefix}{}{marker} ", "  ".repeat(item.depth)),
649            base,
650        )];
651        spans.extend(inline_spans(&item.content, base, theme));
652        let continuation = format!("{prefix}{}", " ".repeat(marker.width() + 1));
653        output.push_wrapped(
654            spans,
655            &continuation,
656            RowOrigin {
657                source: &item.source,
658                target: item_target,
659            },
660        );
661        let child_prefix = format!("{prefix}  ");
662        for child in &item.blocks {
663            render_block(
664                child,
665                theme,
666                highlighter,
667                output,
668                BlockContext {
669                    target: item_target,
670                    prefix: &child_prefix,
671                    ..context
672                },
673            );
674        }
675    }
676}
677
678fn render_code(
679    code: &MarkdownCodeBlock,
680    theme: &ReviewTheme,
681    highlighter: &mut SyntaxHighlighter,
682    output: &mut RowOutput,
683    target: Option<MarkdownTargetId>,
684    prefix: &str,
685) {
686    let highlights = highlight_code_block(code, theme, highlighter);
687    let base = fenced_code_style(theme);
688    for (index, line) in code.lines.iter().enumerate() {
689        let mut rendered =
690            highlighted_line(&line.text, highlights.line(index).unwrap_or_default(), base);
691        rendered
692            .spans
693            .insert(0, Span::styled(prefix.to_owned(), base));
694        output.push_wrapped(
695            rendered.spans,
696            prefix,
697            RowOrigin {
698                source: &line.source,
699                target: line.target_id.or(target),
700            },
701        );
702    }
703}
704
705/// Column widths for `table`, or `None` when the columns cannot fit side by
706/// side and cells must stack.
707fn table_column_widths(table: &MarkdownTable, available: usize, wrap: bool) -> Option<Vec<usize>> {
708    let columns = table_columns(table);
709    let mut natural = vec![1; columns];
710    for row in &table.rows {
711        for (index, cell) in row.cells.iter().enumerate() {
712            natural[index] = natural[index].max(rendered_text(&cell.content).width());
713        }
714    }
715    if !wrap || natural.iter().sum::<usize>() <= available {
716        return Some(natural);
717    }
718    if available < columns {
719        return None;
720    }
721    let mut order = (0..columns).collect::<Vec<_>>();
722    order.sort_by_key(|index| natural[*index]);
723    let mut widths = vec![0; columns];
724    let mut budget = available;
725    for (rank, index) in order.into_iter().enumerate() {
726        let share = budget / (columns - rank);
727        widths[index] = natural[index].min(share);
728        budget -= widths[index];
729    }
730    Some(widths)
731}
732
733fn table_columns(table: &MarkdownTable) -> usize {
734    table
735        .rows
736        .iter()
737        .map(|row| row.cells.len())
738        .max()
739        .unwrap_or(0)
740}
741
742fn render_table(
743    table: &MarkdownTable,
744    theme: &ReviewTheme,
745    output: &mut RowOutput,
746    foreground: Rgba,
747    target: Option<MarkdownTargetId>,
748    prefix: &str,
749) {
750    let text = Style::new().fg(page_color(theme, foreground));
751    let border = Style::new().fg(page_color(theme, theme.diff.border));
752    let columns = table_columns(table);
753    let available =
754        usize::from(output.options.width).saturating_sub(prefix.width() + columns * 3 + 1);
755    let widths = table_column_widths(table, available, output.options.wrap);
756    for row in &table.rows {
757        let base = if row.header {
758            text.add_modifier(Modifier::BOLD)
759        } else {
760            text
761        };
762        let origin = RowOrigin {
763            source: &row.source,
764            target: row.target_id.or(target),
765        };
766        let Some(widths) = &widths else {
767            for cell in &row.cells {
768                output.push_wrapped(inline_spans(&cell.content, base, theme), prefix, origin);
769            }
770            continue;
771        };
772        if widths.is_empty() {
773            continue;
774        }
775        let cells = row
776            .cells
777            .iter()
778            .enumerate()
779            .map(|(index, cell)| {
780                fit_spans(
781                    inline_spans(&cell.content, base, theme),
782                    output.fit_options(u16::try_from(widths[index]).unwrap_or(u16::MAX), ""),
783                )
784            })
785            .collect::<Vec<_>>();
786        let height = cells.iter().map(Vec::len).max().unwrap_or(1);
787        for line in 0..height {
788            let mut spans = vec![Span::styled(format!("{prefix}│ "), border)];
789            for (index, cell_width) in widths.iter().copied().enumerate() {
790                if index > 0 {
791                    spans.push(Span::styled(" │ ", border));
792                }
793                let cell = cells.get(index).and_then(|rows| rows.get(line));
794                let padding = cell_width.saturating_sub(cell.map_or(0, Line::width));
795                let left = match table.alignments.get(index) {
796                    Some(MarkdownTableAlignment::Right) => padding,
797                    Some(MarkdownTableAlignment::Center) => padding / 2,
798                    _ => 0,
799                };
800                spans.push(Span::styled(" ".repeat(left), base));
801                if let Some(cell) = cell {
802                    spans.extend(cell.spans.iter().cloned());
803                }
804                spans.push(Span::styled(" ".repeat(padding - left), base));
805            }
806            spans.push(Span::styled(" │", border));
807            output.push_wrapped(spans, prefix, origin);
808        }
809    }
810}
811
812fn inline_spans(
813    inlines: &[MarkdownInline],
814    style: Style,
815    theme: &ReviewTheme,
816) -> Vec<Span<'static>> {
817    fn append(
818        inline: &MarkdownInline,
819        style: Style,
820        theme: &ReviewTheme,
821        output: &mut Vec<Span<'static>>,
822    ) {
823        match inline {
824            MarkdownInline::Text(text) => output.push(Span::styled(text.clone(), style)),
825            MarkdownInline::Code(text) => {
826                output.push(Span::styled(text.clone(), inline_code_style(style, theme)));
827            }
828            MarkdownInline::Strong(children) => children.iter().for_each(|child| {
829                append(child, style.add_modifier(Modifier::BOLD), theme, output);
830            }),
831            MarkdownInline::Emphasis(children) => children.iter().for_each(|child| {
832                append(child, style.add_modifier(Modifier::ITALIC), theme, output);
833            }),
834            MarkdownInline::Strikethrough(children) => children.iter().for_each(|child| {
835                append(
836                    child,
837                    style.add_modifier(Modifier::CROSSED_OUT),
838                    theme,
839                    output,
840                );
841            }),
842            MarkdownInline::Link { content, .. } => content.iter().for_each(|child| {
843                append(
844                    child,
845                    style
846                        .fg(page_color(theme, theme.markdown.link))
847                        .add_modifier(Modifier::UNDERLINED),
848                    theme,
849                    output,
850                );
851            }),
852            MarkdownInline::SoftBreak => output.push(Span::styled(" ", style)),
853            MarkdownInline::HardBreak => output.push(Span::styled("\n", style)),
854            MarkdownInline::ImageAlt(text) => output.push(Span::styled(
855                format!("Image: {text}"),
856                style
857                    .fg(page_color(theme, theme.markdown.link))
858                    .add_modifier(Modifier::ITALIC),
859            )),
860        }
861    }
862
863    let mut output = Vec::new();
864    for inline in inlines {
865        append(inline, style, theme, &mut output);
866    }
867    output
868}
869
870#[derive(Debug, Clone, PartialEq, Eq)]
871struct SourceLineKey {
872    text: String,
873    source: SourceRange,
874    target: Option<MarkdownTargetId>,
875    styles: Vec<MarkdownSourceStyle>,
876    code: Option<(String, Arc<[HighlightSpan]>)>,
877}
878
879impl SourceLineKey {
880    fn matches(
881        &self,
882        text: &str,
883        source: &SourceRange,
884        target: Option<MarkdownTargetId>,
885        styles: &[&MarkdownSourceStyle],
886        code: Option<&(&str, Arc<[HighlightSpan]>)>,
887    ) -> bool {
888        self.text == text
889            && self.source == *source
890            && self.target == target
891            && self.styles.iter().eq(styles.iter().copied())
892            && self
893                .code
894                .as_ref()
895                .map(|(text, spans)| (text.as_str(), spans))
896                == code.map(|(text, spans)| (*text, spans))
897    }
898}
899
900#[derive(Debug, Clone)]
901struct CachedSourceLine {
902    key: Arc<SourceLineKey>,
903    rows: RowChunk,
904}
905
906fn source_rows(
907    document: &MarkdownDocument,
908    options: MarkdownLayoutOptions,
909    theme: &ReviewTheme,
910    highlighter: &mut SyntaxHighlighter,
911    cached: &[CachedSourceLine],
912    rows: &mut RowBuilder,
913) -> Vec<CachedSourceLine> {
914    let source = document.source();
915    let lines = source_line_ranges(source);
916    let code_style = fenced_code_style(theme);
917    let code_lines = source_code_lines(document, theme, highlighter);
918    let mut styles_by_line: Vec<Vec<&MarkdownSourceStyle>> = vec![Vec::new(); lines.len()];
919    for style in document.source_styles() {
920        for line in style.source.lines.start..=style.source.lines.end {
921            if let Some(bucket) = styles_by_line.get_mut(line.wrapping_sub(1)) {
922                bucket.push(style);
923            }
924        }
925    }
926    let mut target_by_line: Vec<Option<(usize, MarkdownTargetId)>> = vec![None; lines.len()];
927    for target in document.targets() {
928        for line in target.source.lines.start..=target.source.lines.end {
929            if let Some(slot) = target_by_line.get_mut(line.wrapping_sub(1)) {
930                let candidate = (target.source.bytes.len(), target.id);
931                if slot.is_none_or(|current| candidate.0 < current.0) {
932                    *slot = Some(candidate);
933                }
934            }
935        }
936    }
937    let base = Style::new().fg(page_color(theme, theme.diff.foreground));
938    let mut result = Vec::with_capacity(lines.len());
939    for (index, range) in lines.iter().enumerate() {
940        let raw = &source[range.bytes.clone()];
941        let text = raw.strip_suffix('\n').unwrap_or(raw);
942        let text = text.strip_suffix('\r').unwrap_or(text);
943        let target = target_by_line[index].map(|(_, id)| id);
944        let code_input = code_lines.get(&(index + 1));
945        let styles = &styles_by_line[index];
946        if let Some(cached) = cached
947            .get(index)
948            .filter(|cached| cached.key.matches(text, range, target, styles, code_input))
949        {
950            rows.reused(Arc::clone(&cached.rows));
951            result.push(cached.clone());
952            continue;
953        }
954        let mut output = RowOutput::new(options);
955        let code = code_input.and_then(|(code, spans)| {
956            Some((
957                text.strip_suffix(code)?,
958                highlighted_line(code, spans, code_style),
959            ))
960        });
961        let spans = match code {
962            Some((prefix, line)) => {
963                let mut spans = vec![Span::styled(prefix.to_owned(), code_style)];
964                spans.extend(line.spans.iter().cloned());
965                spans
966            }
967            None => text
968                .grapheme_indices(true)
969                .map(|(offset, grapheme)| {
970                    let position = range.bytes.start + offset;
971                    let style = styles
972                        .iter()
973                        .filter(|style| style.source.bytes.contains(&position))
974                        .fold(base, |style, role| {
975                            source_role_style(role.role, style, theme)
976                        });
977                    Span::styled(grapheme.to_owned(), style)
978                })
979                .collect(),
980        };
981        output.push_wrapped(
982            spans,
983            "",
984            RowOrigin {
985                source: range,
986                target,
987            },
988        );
989        let chunk: RowChunk = output.rows.into();
990        rows.generated(Arc::clone(&chunk));
991        result.push(CachedSourceLine {
992            key: Arc::new(SourceLineKey {
993                text: text.to_owned(),
994                source: range.clone(),
995                target,
996                styles: styles.iter().copied().cloned().collect(),
997                code: code_input.map(|(text, spans)| ((*text).to_owned(), Arc::clone(spans))),
998            }),
999            rows: chunk,
1000        });
1001    }
1002    result
1003}
1004
1005/// Byte and line ranges of every source line, including its line ending.
1006fn source_line_ranges(source: &str) -> Vec<SourceRange> {
1007    let mut start = 0;
1008    source
1009        .split('\n')
1010        .enumerate()
1011        .map(|(index, text)| {
1012            let end = (start + text.len() + 1).min(source.len());
1013            let range = SourceRange {
1014                bytes: start..end,
1015                lines: MarkdownLineRange {
1016                    start: index + 1,
1017                    end: index + 1,
1018                },
1019            };
1020            start = end;
1021            range
1022        })
1023        .collect()
1024}
1025
1026fn source_code_lines<'a>(
1027    document: &'a MarkdownDocument,
1028    theme: &ReviewTheme,
1029    highlighter: &mut SyntaxHighlighter,
1030) -> HashMap<usize, (&'a str, Arc<[HighlightSpan]>)> {
1031    let mut lines = HashMap::new();
1032    for code in document.code_blocks() {
1033        let highlights = highlight_code_block(code, theme, highlighter);
1034        for (index, line) in code.lines.iter().enumerate() {
1035            if let Some(source_line) = line.source_line {
1036                lines.insert(
1037                    source_line,
1038                    (
1039                        line.text.as_str(),
1040                        highlights.line_shared(index).unwrap_or_default(),
1041                    ),
1042                );
1043            }
1044        }
1045    }
1046    lines
1047}
1048
1049fn source_role_style(role: MarkdownSourceRole, style: Style, theme: &ReviewTheme) -> Style {
1050    match role {
1051        MarkdownSourceRole::Heading => style
1052            .fg(page_color(theme, theme.markdown.heading))
1053            .add_modifier(Modifier::BOLD),
1054        MarkdownSourceRole::Link => style
1055            .fg(page_color(theme, theme.markdown.link))
1056            .add_modifier(Modifier::UNDERLINED),
1057        MarkdownSourceRole::Quote => style.fg(page_color(theme, theme.markdown.quote)),
1058        MarkdownSourceRole::Code => inline_code_style(style, theme),
1059        MarkdownSourceRole::Strong => style.add_modifier(Modifier::BOLD),
1060        MarkdownSourceRole::Emphasis => style.add_modifier(Modifier::ITALIC),
1061        MarkdownSourceRole::Strikethrough => style.add_modifier(Modifier::CROSSED_OUT),
1062    }
1063}