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, RowCheckpoint, RowChunk, RowStore, TargetIndex,
8    },
9    syntax::highlighted_line,
10    text::{FitOptions, FitPosition, fit_spans, fit_spans_from},
11};
12use clankerdiff_core::SourceSequenceId;
13use clankerdiff_markdown::{
14    MarkdownBlock, MarkdownBlockKind, MarkdownCodeBlock, MarkdownDocument, MarkdownInline,
15    MarkdownLineRange, MarkdownListItem, MarkdownParseStats, MarkdownSourceRole,
16    MarkdownSourceStyle, MarkdownStream, MarkdownStreamIdentity, MarkdownTable,
17    MarkdownTableAlignment, MarkdownTargetId, SourceRange, rendered_text,
18};
19use clankerdiff_syntax::{
20    DocumentHighlights, HighlightSpan, LanguageHint, SyntaxHighlighter, SyntaxStream,
21};
22use clankerdiff_theme::{Fingerprint, ReviewTheme, Rgba};
23use ratatui::{
24    style::{Modifier, Style},
25    text::{Line, Span},
26};
27use similar::{ChangeTag, TextDiff};
28use std::{collections::HashMap, ops::Range, sync::Arc};
29use thiserror::Error;
30use unicode_segmentation::UnicodeSegmentation;
31use unicode_width::UnicodeWidthStr;
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 scanned_bytes: usize,
39    pub source_bytes_copied: usize,
40    pub prefix_bytes_copied: usize,
41    pub parsed_documents: u64,
42    pub rows_generated: usize,
43    pub rows_reused: usize,
44    pub rows_compared: usize,
45    pub rows_materialized: usize,
46    pub highlighted_bytes: usize,
47    pub blocks_visited: usize,
48    pub targets_visited: usize,
49    pub chunks_visited: usize,
50    pub row_store_updates: usize,
51}
52
53impl MarkdownRenderStats {
54    fn record_parse(&mut self, parse: MarkdownParseStats, seen: MarkdownParseStats) {
55        self.parsed_bytes += parse.parsed_bytes.saturating_sub(seen.parsed_bytes);
56        self.scanned_bytes += parse.scanned_bytes.saturating_sub(seen.scanned_bytes);
57        self.source_bytes_copied += parse
58            .source_bytes_copied
59            .saturating_sub(seen.source_bytes_copied);
60        self.prefix_bytes_copied += parse
61            .prefix_bytes_copied
62            .saturating_sub(seen.prefix_bytes_copied);
63        self.parsed_documents += parse.parses.saturating_sub(seen.parses);
64    }
65}
66
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68pub enum StreamingMarkdownPolicy {
69    #[default]
70    Reflowable,
71    Terminal,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
75pub enum MarkdownCommitError {
76    #[error("row commits require the terminal streaming policy")]
77    Reflowable,
78    #[error("commit base revision {base} does not match the layout revision {revision}")]
79    StaleRevision { base: u64, revision: u64 },
80    #[error("cannot commit {requested} rows when {committed} rows are already committed")]
81    Decreasing { committed: usize, requested: usize },
82    #[error("cannot commit {requested} rows of a layout with {rows} rows")]
83    OutOfRange { rows: usize, requested: usize },
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
87pub enum MarkdownStreamError {
88    #[error("{committed} committed rows cannot be rewritten by a replaced or different stream")]
89    CommittedSourceReplaced { committed: usize },
90}
91
92/// Renderer-owned cache for one logical streaming Markdown item.
93#[derive(Debug, Clone, Default)]
94pub struct StreamingMarkdownState {
95    policy: StreamingMarkdownPolicy,
96    layout: MarkdownLayout,
97    cache: LayoutCache,
98    history: History,
99    revision: u64,
100    /// Last revision handed out; survives `reset` so hosts never see a reuse.
101    next_revision: u64,
102    update: Option<MarkdownRowUpdate>,
103    stats: MarkdownRenderStats,
104}
105
106impl StreamingMarkdownState {
107    #[must_use]
108    pub fn new(policy: StreamingMarkdownPolicy) -> Self {
109        Self {
110            policy,
111            ..Self::default()
112        }
113    }
114
115    #[must_use]
116    pub const fn policy(&self) -> StreamingMarkdownPolicy {
117        self.policy
118    }
119
120    pub fn reset(&mut self) {
121        *self = Self {
122            policy: self.policy,
123            history: std::mem::take(&mut self.history),
124            next_revision: self.next_revision,
125            ..Self::default()
126        };
127    }
128
129    /// Returns accumulated renderer work counters and resets them.
130    pub fn take_stats(&mut self) -> MarkdownRenderStats {
131        std::mem::take(&mut self.stats)
132    }
133
134    #[must_use]
135    pub const fn revision(&self) -> u64 {
136        self.revision
137    }
138
139    #[must_use]
140    pub const fn committed_rows(&self) -> usize {
141        self.history.rows
142    }
143
144    #[must_use]
145    pub fn update_since(&self, base_revision: u64) -> MarkdownRowUpdate {
146        let rows = self.layout.rows();
147        if base_revision == self.revision {
148            return MarkdownRowUpdate {
149                base_revision,
150                revision: self.revision,
151                first_changed_row: rows.len(),
152                replacement: rows.slice(rows.len()..rows.len()),
153                reset: false,
154            };
155        }
156        if let Some(update) = &self.update
157            && update.base_revision == base_revision
158        {
159            return update.clone();
160        }
161        let committed = self.history.rows.min(rows.len());
162        MarkdownRowUpdate {
163            base_revision,
164            revision: self.revision,
165            first_changed_row: committed,
166            replacement: rows.slice(committed..rows.len()),
167            reset: committed == 0,
168        }
169    }
170
171    pub fn commit_rows(
172        &mut self,
173        base_revision: u64,
174        end_exclusive: usize,
175    ) -> Result<(), MarkdownCommitError> {
176        if self.policy != StreamingMarkdownPolicy::Terminal {
177            return Err(MarkdownCommitError::Reflowable);
178        }
179        if base_revision != self.revision {
180            return Err(MarkdownCommitError::StaleRevision {
181                base: base_revision,
182                revision: self.revision,
183            });
184        }
185        let committed = self.history.rows;
186        if end_exclusive < committed {
187            return Err(MarkdownCommitError::Decreasing {
188                committed,
189                requested: end_exclusive,
190            });
191        }
192        let rows = self.layout.row_count();
193        if end_exclusive > rows {
194            return Err(MarkdownCommitError::OutOfRange {
195                rows,
196                requested: end_exclusive,
197            });
198        }
199        if end_exclusive == committed {
200            return Ok(());
201        }
202        let mut frozen = RowStore::default();
203        frozen.extend(&self.layout.rows().slice(0..end_exclusive));
204        self.stats.row_store_updates += frozen.take_updates();
205        self.history.store = frozen;
206        self.history.rows = end_exclusive;
207        self.update = None;
208        if let Some(boundary) = self.cache.boundary_at(end_exclusive) {
209            let mut boundary = boundary;
210            boundary.checkpoint = self
211                .layout
212                .row(end_exclusive - 1)
213                .and_then(|row| row.checkpoint.clone());
214            self.history.boundary = Some(boundary);
215        }
216        if self.history.bound.is_none() {
217            self.history.bound = self
218                .cache
219                .identity
220                .map(|identity| (identity, self.cache.revision));
221        }
222        Ok(())
223    }
224}
225
226/// Stateless whole-document renderer plus streaming cache services.
227#[derive(Debug, Default)]
228pub struct MarkdownRenderer;
229
230impl MarkdownRenderer {
231    #[must_use]
232    pub const fn new() -> Self {
233        Self
234    }
235
236    /// Renders a canonical semantic document without review gutters or controls.
237    #[must_use]
238    pub fn render_lines(
239        &self,
240        document: &MarkdownDocument,
241        options: MarkdownLayoutOptions,
242        theme: &ReviewTheme,
243        highlighter: &mut SyntaxHighlighter,
244    ) -> Arc<[Line<'static>]> {
245        self.render_layout(document, options, theme, highlighter)
246            .materialize()
247    }
248
249    pub fn render_stream_lines(
250        &self,
251        state: &mut StreamingMarkdownState,
252        stream: &MarkdownStream,
253        options: MarkdownLayoutOptions,
254        theme: &ReviewTheme,
255        highlighter: &mut SyntaxHighlighter,
256    ) -> Result<Arc<[Line<'static>]>, MarkdownStreamError> {
257        let layout = self.render_stream_layout(state, stream, options, theme, highlighter)?;
258        if !layout.is_materialized() {
259            state.stats.rows_materialized += layout.row_count();
260        }
261        Ok(layout.materialize())
262    }
263
264    #[must_use]
265    pub fn render_layout(
266        &self,
267        document: &MarkdownDocument,
268        options: MarkdownLayoutOptions,
269        theme: &ReviewTheme,
270        highlighter: &mut SyntaxHighlighter,
271    ) -> MarkdownLayout {
272        let mut cache = LayoutCache::default();
273        let mut stats = MarkdownRenderStats::default();
274        let build = cache.build(
275            document,
276            None,
277            0,
278            true,
279            options,
280            theme,
281            theme.revision(),
282            highlighter,
283            &History::default(),
284            &mut stats,
285        );
286        MarkdownLayout::new(build.store, cache.targets)
287    }
288
289    pub fn render_stream_layout(
290        &self,
291        state: &mut StreamingMarkdownState,
292        stream: &MarkdownStream,
293        options: MarkdownLayoutOptions,
294        theme: &ReviewTheme,
295        highlighter: &mut SyntaxHighlighter,
296    ) -> Result<MarkdownLayout, MarkdownStreamError> {
297        let theme_revision = theme.revision();
298        let document = stream.document();
299        state.history.validate_source(stream)?;
300        let same_stream = state.cache.identity == Some(stream.identity());
301        let changes = same_stream.then(|| stream.changes_since(state.cache.revision));
302        let same_shape =
303            state.cache.options == Some(options) && state.cache.theme == Some(theme_revision);
304        let same_source = same_stream && state.cache.source_revision == stream.source_revision();
305        let parse = stream.parse_stats();
306        let seen = if same_stream {
307            state.cache.parse_stats
308        } else {
309            MarkdownParseStats::default()
310        };
311        state.stats.record_parse(parse, seen);
312        state.cache.parse_stats = parse;
313        let unchanged = same_shape
314            && same_source
315            && changes.is_some_and(|changes| {
316                !changes.replaced && changes.first_block >= document.blocks().len()
317            });
318        if unchanged {
319            state.cache.revision = stream.revision();
320            state.stats.rows_reused += state.layout.row_count();
321            return Ok(state.layout.clone());
322        }
323        let (first_block, reset) = match changes {
324            Some(changes) if same_shape && !changes.replaced => (changes.first_block, false),
325            _ => (0, true),
326        };
327        let highlighted_before = highlighter.stats().bytes;
328        let build = state.cache.build(
329            document,
330            stream.open_code_block(),
331            first_block,
332            reset,
333            options,
334            theme,
335            theme_revision,
336            highlighter,
337            &state.history,
338            &mut state.stats,
339        );
340        state.stats.highlighted_bytes +=
341            highlighter.stats().bytes.saturating_sub(highlighted_before);
342        state.cache.identity = Some(stream.identity());
343        state.cache.revision = stream.revision();
344        state.cache.source_revision = stream.source_revision();
345        state.cache.options = Some(options);
346        state.cache.theme = Some(theme_revision);
347        let layout = MarkdownLayout::new(build.store, state.cache.targets.clone());
348        let previous = state.layout.rows();
349        let reset = reset && state.history.rows == 0;
350        let mut first_changed_row = if reset {
351            0
352        } else {
353            build
354                .first_generated
355                .unwrap_or(layout.row_count())
356                .min(previous.len())
357        };
358        while !reset
359            && first_changed_row < layout.row_count()
360            && let (Some(left), Some(right)) = (
361                previous.get(first_changed_row),
362                layout.row(first_changed_row),
363            )
364        {
365            state.stats.rows_compared += 1;
366            if Arc::ptr_eq(left, right) || left == right {
367                first_changed_row += 1;
368            } else {
369                break;
370            }
371        }
372        if first_changed_row == layout.row_count() && layout.row_count() == previous.len() {
373            return Ok(state.layout.clone());
374        }
375        let mut store = RowStore::default();
376        store.extend(&previous.slice(0..first_changed_row));
377        store.extend(&layout.rows().slice(first_changed_row..layout.row_count()));
378        state.stats.row_store_updates += store.take_updates();
379        state.cache.store = store.clone();
380        let layout = MarkdownLayout::new(store, state.cache.targets.clone());
381        state.next_revision += 1;
382        let revision = state.next_revision;
383        state.update = Some(MarkdownRowUpdate {
384            base_revision: state.revision,
385            revision,
386            first_changed_row,
387            replacement: layout.rows().slice(first_changed_row..layout.row_count()),
388            reset,
389        });
390        state.revision = revision;
391        state.layout = layout;
392        Ok(state.layout.clone())
393    }
394}
395
396#[derive(Debug, Clone, Default)]
397struct History {
398    store: RowStore,
399    rows: usize,
400    boundary: Option<Boundary>,
401    bound: Option<(MarkdownStreamIdentity, u64)>,
402}
403
404impl History {
405    fn validate_source(&self, stream: &MarkdownStream) -> Result<(), MarkdownStreamError> {
406        if let Some((identity, revision)) = self.bound
407            && (stream.identity() != identity || stream.changes_since(revision).replaced)
408        {
409            return Err(MarkdownStreamError::CommittedSourceReplaced {
410                committed: self.rows,
411            });
412        }
413        Ok(())
414    }
415}
416
417#[derive(Debug, Clone)]
418struct Boundary {
419    block_start: usize,
420    rows: usize,
421    options: MarkdownLayoutOptions,
422    checkpoint: Option<RowCheckpoint>,
423}
424
425struct LayoutBuild {
426    store: RowStore,
427    first_generated: Option<usize>,
428}
429
430#[derive(Debug, Clone, Default)]
431struct Unit {
432    block_start: usize,
433    store: RowStore,
434    first_generated: Option<usize>,
435    len: usize,
436}
437
438impl Unit {
439    fn push(&mut self, chunk: &RowChunk, generated: bool) {
440        if generated && !chunk.is_empty() && self.first_generated.is_none() {
441            self.first_generated = Some(self.len);
442        }
443        self.len += chunk.len();
444        self.store.push(chunk);
445    }
446}
447
448#[derive(Debug, Clone)]
449struct OpenCode {
450    block: usize,
451    hint: String,
452    syntax: SyntaxStream,
453    fed_lines: usize,
454    fed_partial: usize,
455    checkpoint: Option<RowCheckpoint>,
456    options: Option<MarkdownLayoutOptions>,
457    store: RowStore,
458    first_line: usize,
459    line_ends: Vec<usize>,
460    failed: bool,
461}
462
463#[derive(Debug, Clone, Default)]
464struct LayoutCache {
465    identity: Option<MarkdownStreamIdentity>,
466    revision: u64,
467    source_revision: u64,
468    options: Option<MarkdownLayoutOptions>,
469    theme: Option<Fingerprint>,
470    parse_stats: MarkdownParseStats,
471    store: RowStore,
472    units: Vec<Unit>,
473    block_ends: Vec<usize>,
474    skipped_rows: Vec<usize>,
475    open: Option<OpenCode>,
476    spacer: Option<RowChunk>,
477    targets: TargetIndex,
478    target_ids: Vec<MarkdownTargetId>,
479    source_lines: Vec<CachedSourceLine>,
480    line_ranges: Vec<SourceRange>,
481}
482
483impl LayoutCache {
484    fn boundary_at(&self, offset: usize) -> Option<Boundary> {
485        let options = self.options?;
486        let index = self.block_ends.partition_point(|end| *end <= offset);
487        let index = index.min(self.units.len().checked_sub(1)?);
488        let unit = self.units.get(index)?;
489        let start = index
490            .checked_sub(1)
491            .map_or(0, |previous| self.block_ends[previous]);
492        Some(Boundary {
493            block_start: unit.block_start,
494            rows: self.skipped_rows[index] + offset.saturating_sub(start),
495            options,
496            checkpoint: None,
497        })
498    }
499
500    #[allow(clippy::too_many_arguments)]
501    fn build(
502        &mut self,
503        document: &MarkdownDocument,
504        open_block: Option<usize>,
505        first_block: usize,
506        reset: bool,
507        options: MarkdownLayoutOptions,
508        theme: &ReviewTheme,
509        theme_revision: Fingerprint,
510        highlighter: &mut SyntaxHighlighter,
511        history: &History,
512        stats: &mut MarkdownRenderStats,
513    ) -> LayoutBuild {
514        if self.options != Some(options) || self.theme != Some(theme_revision) {
515            self.spacer = None;
516        }
517        if reset {
518            self.open = None;
519            self.source_lines.clear();
520            self.line_ranges.clear();
521        }
522        if self
523            .open
524            .as_ref()
525            .is_some_and(|open| Some(open.block) != open_block)
526        {
527            self.open = None;
528        }
529        self.sync_targets(document, first_block, open_block, stats);
530        let mut store = history.store.clone();
531        store.take_updates();
532        stats.rows_reused += history.rows;
533        let mut first_generated = None;
534        match options.presentation {
535            MarkdownPresentation::Rendered => {
536                self.build_rendered(
537                    document,
538                    open_block,
539                    first_block,
540                    options,
541                    theme,
542                    highlighter,
543                    history,
544                    stats,
545                    &mut store,
546                    &mut first_generated,
547                );
548            }
549            MarkdownPresentation::SourceLines => {
550                self.units.clear();
551                self.block_ends.clear();
552                self.skipped_rows.clear();
553                self.build_source_lines(
554                    document,
555                    open_block,
556                    first_block,
557                    options,
558                    theme,
559                    highlighter,
560                    history,
561                    stats,
562                    &mut store,
563                    &mut first_generated,
564                );
565            }
566        }
567        stats.row_store_updates += store.take_updates();
568        self.store = store.clone();
569        LayoutBuild {
570            store,
571            first_generated,
572        }
573    }
574
575    fn sync_targets(
576        &mut self,
577        document: &MarkdownDocument,
578        first_block: usize,
579        open_block: Option<usize>,
580        stats: &mut MarkdownRenderStats,
581    ) {
582        let targets = document.targets();
583        let mut first_target = document
584            .blocks()
585            .get(first_block)
586            .map_or(targets.len(), |block| {
587                targets
588                    .partition_point(|target| target.source.bytes.start < block.source.bytes.start)
589            });
590        let code_prefix = self.open.as_ref().and_then(|open| {
591            if open_block != Some(first_block) || open.block != first_block || open.failed {
592                return None;
593            }
594            let MarkdownBlockKind::CodeBlock(code) = &document.blocks()[first_block].kind else {
595                return None;
596            };
597            if code.lines.len() < open.fed_lines || code.highlight_hint() != open.hint {
598                return None;
599            }
600            let root = code.target_id?.index();
601            let line = open.fed_lines.saturating_sub(1).min(code.lines.len());
602            Some((root, root + 1 + line))
603        });
604        if let Some((root, first_line)) = code_prefix {
605            if let Some(target) = targets.get(root) {
606                stats.targets_visited += 1;
607                self.targets.insert(target.id, target.source.clone());
608            }
609            first_target = first_line.min(targets.len());
610        } else if first_block == 0 {
611            self.targets.clear();
612            self.target_ids.clear();
613        }
614        let first_target = first_target.min(self.target_ids.len());
615        for id in self.target_ids.drain(first_target..) {
616            stats.targets_visited += 1;
617            self.targets.remove(&id);
618        }
619        for target in &targets[first_target..] {
620            stats.targets_visited += 1;
621            self.targets.insert(target.id, target.source.clone());
622            self.target_ids.push(target.id);
623        }
624    }
625
626    #[allow(clippy::too_many_arguments)]
627    fn build_rendered(
628        &mut self,
629        document: &MarkdownDocument,
630        open_block: Option<usize>,
631        first_block: usize,
632        options: MarkdownLayoutOptions,
633        theme: &ReviewTheme,
634        highlighter: &mut SyntaxHighlighter,
635        history: &History,
636        stats: &mut MarkdownRenderStats,
637        store: &mut RowStore,
638        first_generated: &mut Option<usize>,
639    ) {
640        let blocks = document.blocks();
641        let boundary_index = history.boundary.as_ref().map_or(0, |boundary| {
642            blocks.partition_point(|block| {
643                boundary.checkpoint.as_ref().map_or(
644                    block.source.bytes.start < boundary.block_start,
645                    |checkpoint| block.source.bytes.end <= checkpoint.source.bytes.start,
646                )
647            })
648        });
649        let first_block = first_block.max(boundary_index).min(blocks.len());
650        let prefix = first_block
651            .checked_sub(1)
652            .and_then(|index| self.block_ends.get(index).copied())
653            .unwrap_or(history.rows)
654            .max(history.rows);
655        if first_block > boundary_index && prefix <= self.store.len() {
656            *store = self.store.clone();
657            store.take_updates();
658            store.truncate(prefix);
659            stats.rows_reused += prefix.saturating_sub(history.rows);
660        }
661        self.units.truncate(first_block);
662        self.units.resize_with(first_block, Unit::default);
663        self.block_ends.truncate(first_block);
664        self.block_ends.resize(first_block, history.rows);
665        self.skipped_rows.truncate(first_block);
666        self.skipped_rows.resize(first_block, 0);
667        let mut next_source_line = first_block
668            .checked_sub(1)
669            .map_or(1, |index| blocks[index].source.lines.end.saturating_add(1));
670        for (index, block) in blocks.iter().enumerate().skip(first_block) {
671            stats.blocks_visited += 1;
672            let start = block.source.bytes.start;
673            let gap = next_source_line..block.source.lines.start;
674            next_source_line = block.source.lines.end.saturating_add(1);
675            let boundary = history.boundary.as_ref();
676            let at_boundary = boundary.filter(|boundary| {
677                boundary
678                    .checkpoint
679                    .as_ref()
680                    .map_or(boundary.block_start == start, |checkpoint| {
681                        start <= checkpoint.source.bytes.start
682                            && checkpoint.source.bytes.start < block.source.bytes.end
683                    })
684            });
685            let checkpoint = at_boundary.and_then(|boundary| boundary.checkpoint.as_ref());
686            let unit_options = at_boundary
687                .filter(|_| checkpoint.is_none())
688                .map_or(options, |boundary| boundary.options);
689            let unit = self.render_unit(
690                document,
691                index,
692                block,
693                gap,
694                open_block,
695                unit_options,
696                checkpoint,
697                theme,
698                highlighter,
699                stats,
700            );
701            self.units.push(unit.clone());
702            let skip = at_boundary
703                .filter(|_| checkpoint.is_none())
704                .map_or(0, |boundary| boundary.rows);
705            self.skipped_rows.push(skip.min(unit.len));
706            emit_unit(&unit, skip, store, first_generated);
707            self.block_ends.push(store.len());
708        }
709    }
710
711    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
712    fn render_unit(
713        &mut self,
714        document: &MarkdownDocument,
715        index: usize,
716        block: &MarkdownBlock,
717        gap: Range<usize>,
718        open_block: Option<usize>,
719        options: MarkdownLayoutOptions,
720        checkpoint: Option<&RowCheckpoint>,
721        theme: &ReviewTheme,
722        highlighter: &mut SyntaxHighlighter,
723        stats: &mut MarkdownRenderStats,
724    ) -> Unit {
725        let mut unit = Unit {
726            block_start: block.source.bytes.start,
727            ..Unit::default()
728        };
729        if options.preserve_source_gaps {
730            if checkpoint.is_none() && !gap.is_empty() {
731                self.line_ranges(document.source());
732                let ranges = &self.line_ranges;
733                let rows = gap
734                    .clone()
735                    .map(|line| {
736                        Arc::new(MarkdownRow {
737                            line: Line::default(),
738                            source: ranges.get(line - 1).cloned(),
739                            target: None,
740                            checkpoint: None,
741                        })
742                    })
743                    .collect::<RowChunk>();
744                stats.rows_generated += rows.len();
745                unit.push(&rows, true);
746            }
747        } else if checkpoint.is_none() && index > 0 && options.block_spacing {
748            let spacer = self.spacer.get_or_insert_with(|| {
749                Arc::from([Arc::new(MarkdownRow {
750                    line: Line::default(),
751                    source: None,
752                    target: None,
753                    checkpoint: None,
754                })])
755            });
756            unit.push(spacer, true);
757        }
758        let context = BlockContext {
759            target: None,
760            foreground: theme.diff.foreground,
761            prefix: "",
762        };
763        if let (MarkdownBlockKind::CodeBlock(code), true) = (&block.kind, open_block == Some(index))
764        {
765            let (highlights, changed_from) =
766                self.open_code_highlights(index, code, theme, highlighter);
767            let open = self.open.as_mut().expect("open code cache initialised");
768            let first_line = checkpoint.map_or(0, |checkpoint| {
769                code.lines
770                    .partition_point(|line| line.source.bytes.end <= checkpoint.source.bytes.start)
771            });
772            let reuse = if open.options == Some(options)
773                && open.checkpoint.as_ref() == checkpoint
774                && open.first_line == first_line
775            {
776                changed_from
777                    .saturating_sub(first_line)
778                    .min(open.line_ends.len())
779            } else {
780                0
781            };
782            open.options = Some(options);
783            open.checkpoint = checkpoint.cloned();
784            let retained = reuse
785                .checked_sub(1)
786                .map_or(0, |index| open.line_ends[index]);
787            open.store.truncate(retained);
788            open.line_ends.truncate(reuse);
789            open.first_line = first_line;
790            stats.rows_reused += retained;
791            if reuse > 0 {
792                unit.first_generated = None;
793            }
794            let base = fenced_code_style(theme);
795            for (line_index, line) in code.lines.iter().enumerate().skip(first_line + reuse) {
796                stats.chunks_visited += 1;
797                let mut output = RowOutput::new(options).after(checkpoint);
798                render_code_line(
799                    line,
800                    highlights.line(line_index).unwrap_or_default(),
801                    base,
802                    "",
803                    line.target_id.or(code.target_id),
804                    &mut output,
805                );
806                let chunk: RowChunk = Arc::from(output.rows);
807                stats.rows_generated += chunk.len();
808                open.store.push(&chunk);
809                open.line_ends.push(open.store.len());
810            }
811            if unit.first_generated.is_none() && retained < open.store.len() {
812                unit.first_generated = Some(unit.len + retained);
813            }
814            unit.store.append(&open.store, 0..open.store.len());
815            unit.len += open.store.len();
816            stats.row_store_updates += open.store.take_updates() + unit.store.take_updates();
817            return unit;
818        }
819        let mut output = RowOutput::new(options).after(checkpoint);
820        render_block(block, theme, highlighter, &mut output, context);
821        let chunk: RowChunk = Arc::from(output.rows);
822        stats.rows_generated += chunk.len();
823        stats.chunks_visited += 1;
824        unit.push(&chunk, true);
825        stats.row_store_updates += unit.store.take_updates();
826        unit
827    }
828
829    fn open_code_highlights(
830        &mut self,
831        index: usize,
832        code: &MarkdownCodeBlock,
833        theme: &ReviewTheme,
834        highlighter: &mut SyntaxHighlighter,
835    ) -> (Arc<DocumentHighlights>, usize) {
836        let hint = code.highlight_hint();
837        let reusable = self.open.as_ref().is_some_and(|open| {
838            open.block == index
839                && !open.failed
840                && open.hint == hint
841                && code.lines.len() >= open.fed_lines
842                && open
843                    .fed_lines
844                    .checked_sub(1)
845                    .is_none_or(|last| code.lines[last].text.len() >= open.fed_partial)
846        });
847        if !reusable {
848            self.open = Some(OpenCode {
849                block: index,
850                hint: hint.to_owned(),
851                syntax: SyntaxStream::new(LanguageHint::InfoString(hint)),
852                fed_lines: 0,
853                fed_partial: 0,
854                checkpoint: None,
855                options: None,
856                store: RowStore::default(),
857                first_line: 0,
858                line_ends: Vec::new(),
859                failed: false,
860            });
861        }
862        let open = self.open.as_mut().expect("open code cache initialised");
863        let first_text_change = open.fed_lines.saturating_sub(1);
864        let mut delta = String::new();
865        if let Some(last) = open.fed_lines.checked_sub(1) {
866            delta.push_str(&code.lines[last].text[open.fed_partial..]);
867        }
868        for (index, line) in code.lines.iter().enumerate().skip(open.fed_lines) {
869            if index > 0 {
870                delta.push('\n');
871            }
872            delta.push_str(&line.text);
873        }
874        open.fed_lines = code.lines.len();
875        open.fed_partial = code.lines.last().map_or(0, |line| line.text.len());
876        if open.failed {
877            return (Arc::default(), 0);
878        }
879        if let Ok(update) = highlighter
880            .with_theme(&theme.syntax)
881            .append(&mut open.syntax, &delta)
882        {
883            (
884                update.highlights,
885                update.changed_lines.start.min(first_text_change),
886            )
887        } else {
888            open.failed = true;
889            open.store = RowStore::default();
890            open.line_ends.clear();
891            (Arc::default(), 0)
892        }
893    }
894
895    fn block_highlights(
896        &mut self,
897        index: usize,
898        code: &MarkdownCodeBlock,
899        open_block: Option<usize>,
900        theme: &ReviewTheme,
901        highlighter: &mut SyntaxHighlighter,
902    ) -> (Arc<DocumentHighlights>, usize) {
903        if open_block == Some(index) {
904            self.open_code_highlights(index, code, theme, highlighter)
905        } else {
906            (highlight_code_block(code, theme, highlighter), 0)
907        }
908    }
909
910    fn line_ranges(&mut self, source: &str) -> usize {
911        let previous = self.line_ranges.len();
912        let mut cursor = match self.line_ranges.pop() {
913            Some(last) if last.bytes.start <= source.len() => last.bytes.start,
914            _ => {
915                self.line_ranges.clear();
916                0
917            }
918        };
919        let mut line = self.line_ranges.len() + 1;
920        loop {
921            let end = source[cursor..]
922                .find('\n')
923                .map(|offset| cursor + offset + 1);
924            self.line_ranges.push(SourceRange {
925                bytes: cursor..end.unwrap_or(source.len()),
926                lines: MarkdownLineRange {
927                    start: line,
928                    end: line,
929                },
930            });
931            line += 1;
932            match end {
933                Some(end) => cursor = end,
934                None => break,
935            }
936        }
937        previous.saturating_sub(1)
938    }
939
940    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
941    fn build_source_lines(
942        &mut self,
943        document: &MarkdownDocument,
944        open_block: Option<usize>,
945        first_block: usize,
946        options: MarkdownLayoutOptions,
947        theme: &ReviewTheme,
948        highlighter: &mut SyntaxHighlighter,
949        history: &History,
950        stats: &mut MarkdownRenderStats,
951        store: &mut RowStore,
952        first_generated: &mut Option<usize>,
953    ) {
954        let source = document.source();
955        let blocks = document.blocks();
956        let first_block = if history
957            .boundary
958            .as_ref()
959            .is_some_and(|boundary| boundary.checkpoint.is_some())
960        {
961            0
962        } else {
963            first_block
964        };
965        let first_byte = blocks
966            .get(first_block)
967            .map_or(source.len(), |block| block.source.bytes.start);
968        let mut first_line = blocks.get(first_block).map_or(usize::MAX, |block| {
969            block.source.lines.start.saturating_sub(1)
970        });
971        first_line = first_line.min(self.line_ranges(source));
972        let code_style = fenced_code_style(theme);
973        let mut code_lines = HashMap::new();
974        for (index, block) in blocks.iter().enumerate().skip(first_block) {
975            stats.blocks_visited += 1;
976            for code in block_code_blocks(block) {
977                let own = matches!(block.kind, MarkdownBlockKind::CodeBlock(_));
978                let (highlights, changed_from) = if own {
979                    self.block_highlights(index, code, open_block, theme, highlighter)
980                } else {
981                    (highlight_code_block(code, theme, highlighter), 0)
982                };
983                if own && open_block == Some(index) {
984                    let content_line = code.content.lines.start.saturating_sub(1);
985                    first_line = first_line.max(content_line + changed_from);
986                }
987                for (line_index, line) in code.lines.iter().enumerate() {
988                    stats.chunks_visited += 1;
989                    if let Some(source_line) = line.source_line {
990                        code_lines.insert(
991                            source_line,
992                            (
993                                line.text.as_str(),
994                                highlights.line_shared(line_index).unwrap_or_default(),
995                            ),
996                        );
997                    }
998                }
999            }
1000        }
1001        let lines = std::mem::take(&mut self.line_ranges);
1002        let checkpoint = history
1003            .boundary
1004            .as_ref()
1005            .and_then(|boundary| boundary.checkpoint.as_ref());
1006        let first_line = if checkpoint.is_some() {
1007            0
1008        } else {
1009            first_line.min(lines.len())
1010        };
1011        let first_style = document
1012            .source_styles()
1013            .partition_point(|style| style.source.bytes.start < first_byte);
1014        let mut styles_by_line: HashMap<usize, Vec<&MarkdownSourceStyle>> = HashMap::new();
1015        for style in &document.source_styles()[first_style..] {
1016            for line in style.source.lines.start..=style.source.lines.end {
1017                styles_by_line.entry(line).or_default().push(style);
1018            }
1019        }
1020        let first_target = document
1021            .targets()
1022            .partition_point(|target| target.source.bytes.start < first_byte);
1023        let mut target_by_line: HashMap<usize, (usize, MarkdownTargetId)> = HashMap::new();
1024        for target in &document.targets()[first_target..] {
1025            for line in target.source.lines.start..=target.source.lines.end {
1026                let candidate = (target.source.bytes.len(), target.id);
1027                let slot = target_by_line.entry(line).or_insert(candidate);
1028                if candidate.0 < slot.0 {
1029                    *slot = candidate;
1030                }
1031            }
1032        }
1033        let base = Style::new().fg(page_color(theme, theme.diff.foreground));
1034        self.source_lines
1035            .truncate(first_line.min(self.source_lines.len()));
1036        let mut unit = Unit::default();
1037        for (index, range) in lines.iter().enumerate() {
1038            stats.chunks_visited += 1;
1039            if index < first_line
1040                && let Some(cached) = self.source_lines.get(index)
1041            {
1042                stats.rows_reused += cached.rows.len();
1043                unit.push(&cached.rows, false);
1044                continue;
1045            }
1046            let raw = &source[range.bytes.clone()];
1047            let text = raw.strip_suffix('\n').unwrap_or(raw);
1048            let text = text.strip_suffix('\r').unwrap_or(text);
1049            let line_number = index + 1;
1050            let target = target_by_line.get(&line_number).map(|(_, id)| *id);
1051            let code_input = code_lines.get(&line_number);
1052            let styles = styles_by_line
1053                .get(&line_number)
1054                .map_or(&[][..], Vec::as_slice);
1055            if let Some(cached) = self
1056                .source_lines
1057                .get(index)
1058                .filter(|cached| cached.key.matches(text, range, target, styles, code_input))
1059            {
1060                stats.rows_compared += 1;
1061                stats.rows_reused += cached.rows.len();
1062                unit.push(&cached.rows, false);
1063                continue;
1064            }
1065            stats.rows_compared += usize::from(self.source_lines.get(index).is_some());
1066            self.source_lines.truncate(index);
1067            let mut output = RowOutput::new(options).after(checkpoint);
1068            let code = code_input.and_then(|(code, spans)| {
1069                Some((
1070                    text.strip_suffix(code)?,
1071                    highlighted_line(code, spans, code_style),
1072                ))
1073            });
1074            let spans = match code {
1075                Some((prefix, line)) => {
1076                    let mut spans = vec![Span::styled(prefix.to_owned(), code_style)];
1077                    spans.extend(line.spans.iter().cloned());
1078                    spans
1079                }
1080                None => text
1081                    .grapheme_indices(true)
1082                    .map(|(offset, grapheme)| {
1083                        let position = range.bytes.start + offset;
1084                        let style = styles
1085                            .iter()
1086                            .filter(|style| style.source.bytes.contains(&position))
1087                            .fold(base, |style, role| {
1088                                source_role_style(role.role, style, theme)
1089                            });
1090                        Span::styled(grapheme.to_owned(), style)
1091                    })
1092                    .collect(),
1093            };
1094            output.push_wrapped(
1095                spans,
1096                "",
1097                RowOrigin {
1098                    source: range,
1099                    target,
1100                },
1101            );
1102            let chunk: RowChunk = output.rows.into();
1103            stats.rows_generated += chunk.len();
1104            self.source_lines.push(CachedSourceLine {
1105                key: Arc::new(SourceLineKey {
1106                    text: text.to_owned(),
1107                    source: range.clone(),
1108                    target,
1109                    styles: styles.iter().copied().cloned().collect(),
1110                    code: code_input.map(|(text, spans)| ((*text).to_owned(), Arc::clone(spans))),
1111                }),
1112                rows: Arc::clone(&chunk),
1113            });
1114            unit.push(&chunk, true);
1115        }
1116        self.source_lines.truncate(lines.len());
1117        self.line_ranges = lines;
1118        let skip = if checkpoint.is_some() {
1119            0
1120        } else {
1121            history.boundary.as_ref().map_or(0, |_| history.rows)
1122        };
1123        stats.row_store_updates += unit.store.take_updates();
1124        emit_unit(&unit, skip, store, first_generated);
1125        self.skipped_rows.push(skip.min(unit.len));
1126        self.block_ends.push(store.len());
1127        self.units.push(unit);
1128    }
1129}
1130
1131fn emit_unit(unit: &Unit, skip: usize, store: &mut RowStore, first_generated: &mut Option<usize>) {
1132    if skip < unit.len {
1133        if let Some(generated) = unit.first_generated
1134            && first_generated.is_none()
1135        {
1136            *first_generated = Some(store.len() + generated.saturating_sub(skip));
1137        }
1138        store.append(&unit.store, skip..unit.len);
1139    }
1140}
1141
1142fn block_code_blocks(block: &MarkdownBlock) -> Vec<&MarkdownCodeBlock> {
1143    fn collect<'a>(blocks: &'a [MarkdownBlock], output: &mut Vec<&'a MarkdownCodeBlock>) {
1144        for block in blocks {
1145            match &block.kind {
1146                MarkdownBlockKind::CodeBlock(code) => output.push(code),
1147                MarkdownBlockKind::List { items, .. } => {
1148                    for item in items {
1149                        collect(&item.blocks, output);
1150                    }
1151                }
1152                MarkdownBlockKind::BlockQuote { blocks } => collect(blocks, output),
1153                _ => {}
1154            }
1155        }
1156    }
1157    let mut output = Vec::new();
1158    collect(std::slice::from_ref(block), &mut output);
1159    output
1160}
1161
1162/// Foreground and background for fenced code, composited over the page.
1163fn fenced_code_style(theme: &ReviewTheme) -> Style {
1164    layered_style(
1165        theme.markdown.code,
1166        theme.markdown.code_background,
1167        theme.diff.background,
1168    )
1169}
1170
1171/// Applies the inline-code role on top of `style`.
1172fn inline_code_style(style: Style, theme: &ReviewTheme) -> Style {
1173    style.patch(layered_style(
1174        theme.markdown.inline_code,
1175        theme.markdown.inline_code_background,
1176        theme.diff.background,
1177    ))
1178}
1179
1180/// Highlights a fenced block with its complete content as parser context.
1181fn highlight_code_block(
1182    code: &MarkdownCodeBlock,
1183    theme: &ReviewTheme,
1184    highlighter: &mut SyntaxHighlighter,
1185) -> Arc<DocumentHighlights> {
1186    highlighter
1187        .with_theme(&theme.syntax)
1188        .highlight_document(
1189            Fingerprint::of([
1190                b"markdown-code-without-final-newline".as_slice(),
1191                SourceSequenceId::from_lines(code.lines.iter().map(|line| line.text.as_str()))
1192                    .fingerprint()
1193                    .as_bytes(),
1194            ]),
1195            LanguageHint::InfoString(code.highlight_hint()),
1196            || {
1197                code.lines
1198                    .iter()
1199                    .map(|line| line.text.as_str())
1200                    .collect::<Vec<_>>()
1201                    .join("\n")
1202            },
1203        )
1204        .unwrap_or_default()
1205}
1206
1207/// Ownership and styling a block inherits from its enclosing blocks.
1208#[derive(Clone, Copy)]
1209struct BlockContext<'a> {
1210    target: Option<MarkdownTargetId>,
1211    foreground: Rgba,
1212    prefix: &'a str,
1213}
1214
1215/// Where the rows produced for one block element come from.
1216#[derive(Clone, Copy)]
1217struct RowOrigin<'a> {
1218    source: &'a SourceRange,
1219    target: Option<MarkdownTargetId>,
1220}
1221
1222struct RowOutput {
1223    rows: Vec<Arc<MarkdownRow>>,
1224    options: MarkdownLayoutOptions,
1225    checkpoint: Option<RowCheckpoint>,
1226}
1227
1228impl RowOutput {
1229    const fn new(options: MarkdownLayoutOptions) -> Self {
1230        Self {
1231            rows: Vec::new(),
1232            options,
1233            checkpoint: None,
1234        }
1235    }
1236
1237    fn after(mut self, checkpoint: Option<&RowCheckpoint>) -> Self {
1238        self.checkpoint = checkpoint.cloned();
1239        self
1240    }
1241
1242    fn push(&mut self, line: Line<'static>, origin: RowOrigin<'_>) {
1243        self.rows.push(Arc::new(MarkdownRow {
1244            line,
1245            source: Some(origin.source.clone()),
1246            target: origin.target,
1247            checkpoint: None,
1248        }));
1249    }
1250
1251    fn push_wrapped(
1252        &mut self,
1253        spans: Vec<Span<'static>>,
1254        continuation: &str,
1255        origin: RowOrigin<'_>,
1256    ) {
1257        if self
1258            .checkpoint
1259            .as_ref()
1260            .is_some_and(|checkpoint| origin.source.bytes.end <= checkpoint.source.bytes.start)
1261        {
1262            return;
1263        }
1264        let rendered: Arc<str> = spans
1265            .iter()
1266            .map(|span| span.content.as_ref())
1267            .collect::<String>()
1268            .into();
1269        let mut from = FitPosition::default();
1270        if let Some(checkpoint) = &self.checkpoint {
1271            if origin.source.bytes.end <= checkpoint.source.bytes.start {
1272                return;
1273            }
1274            if origin.source.bytes.start <= checkpoint.source.bytes.start {
1275                from = checkpoint.position;
1276                if !rendered.starts_with(checkpoint.rendered.as_ref()) {
1277                    from.byte = translated_offset(&checkpoint.rendered, &rendered, from.byte);
1278                    from.tab_remaining = 0;
1279                }
1280                if from.byte >= rendered.len() {
1281                    return;
1282                }
1283            }
1284        }
1285        for (line, position) in fit_spans_from(
1286            spans,
1287            self.fit_options(self.options.width, continuation),
1288            from,
1289        ) {
1290            self.rows.push(Arc::new(MarkdownRow {
1291                line,
1292                source: Some(origin.source.clone()),
1293                target: origin.target,
1294                checkpoint: Some(RowCheckpoint {
1295                    source: origin.source.clone(),
1296                    position,
1297                    rendered: Arc::clone(&rendered),
1298                }),
1299            }));
1300        }
1301    }
1302
1303    fn fit_options<'a>(&self, width: u16, continuation: &'a str) -> FitOptions<'a> {
1304        FitOptions {
1305            width: usize::from(width),
1306            wrap: self.options.wrap,
1307            tab_width: usize::from(self.options.tab_width),
1308            continuation,
1309        }
1310    }
1311}
1312
1313fn translated_offset(before: &str, after: &str, offset: usize) -> usize {
1314    let mut old = 0;
1315    let mut new = 0;
1316    for change in TextDiff::from_chars(before, after).iter_all_changes() {
1317        if old >= offset {
1318            break;
1319        }
1320        match change.tag() {
1321            ChangeTag::Equal => {
1322                old += change.value().len();
1323                new += change.value().len();
1324            }
1325            ChangeTag::Delete => old += change.value().len(),
1326            ChangeTag::Insert => new += change.value().len(),
1327        }
1328    }
1329    new
1330}
1331
1332fn render_block(
1333    block: &MarkdownBlock,
1334    theme: &ReviewTheme,
1335    highlighter: &mut SyntaxHighlighter,
1336    output: &mut RowOutput,
1337    context: BlockContext<'_>,
1338) {
1339    let prefix = context.prefix;
1340    let width = output.options.width;
1341    let origin = RowOrigin {
1342        source: &block.source,
1343        target: block.target_id.or(context.target),
1344    };
1345    match &block.kind {
1346        MarkdownBlockKind::Heading { level, content } => {
1347            let marker = if output.options.heading_markers {
1348                format!("{} ", "#".repeat(usize::from(*level)))
1349            } else {
1350                String::new()
1351            };
1352            let base = Style::new()
1353                .fg(page_color(theme, theme.markdown.heading))
1354                .add_modifier(Modifier::BOLD);
1355            let mut spans = vec![Span::styled(format!("{prefix}{marker}"), base)];
1356            spans.extend(inline_spans(content, base, theme));
1357            output.push_wrapped(spans, prefix, origin);
1358        }
1359        MarkdownBlockKind::Paragraph { content } | MarkdownBlockKind::HtmlFallback { content } => {
1360            let base = Style::new().fg(page_color(theme, context.foreground));
1361            let mut spans = vec![Span::styled(prefix.to_owned(), base)];
1362            spans.extend(inline_spans(content, base, theme));
1363            output.push_wrapped(spans, prefix, origin);
1364        }
1365        MarkdownBlockKind::List {
1366            ordered,
1367            start,
1368            items,
1369        } => render_list(
1370            items,
1371            (*ordered, *start),
1372            theme,
1373            highlighter,
1374            output,
1375            BlockContext {
1376                target: origin.target,
1377                ..context
1378            },
1379        ),
1380        MarkdownBlockKind::BlockQuote { blocks } => {
1381            let quote_prefix = format!("{prefix}│ ");
1382            for child in blocks {
1383                render_block(
1384                    child,
1385                    theme,
1386                    highlighter,
1387                    output,
1388                    BlockContext {
1389                        target: origin.target,
1390                        foreground: theme.markdown.quote,
1391                        prefix: &quote_prefix,
1392                    },
1393                );
1394            }
1395        }
1396        MarkdownBlockKind::CodeBlock(code) => {
1397            render_code(code, theme, highlighter, output, origin.target, prefix);
1398        }
1399        MarkdownBlockKind::Table(table) => {
1400            render_table(
1401                table,
1402                theme,
1403                output,
1404                context.foreground,
1405                origin.target,
1406                prefix,
1407            );
1408        }
1409        MarkdownBlockKind::Rule => output.push(
1410            Line::styled(
1411                "─".repeat(usize::from(width)),
1412                Style::new().fg(page_color(theme, theme.diff.border)),
1413            ),
1414            origin,
1415        ),
1416    }
1417}
1418
1419fn render_list(
1420    items: &[MarkdownListItem],
1421    (ordered, start): (bool, Option<u64>),
1422    theme: &ReviewTheme,
1423    highlighter: &mut SyntaxHighlighter,
1424    output: &mut RowOutput,
1425    context: BlockContext<'_>,
1426) {
1427    let prefix = context.prefix;
1428    let base = Style::new().fg(page_color(theme, context.foreground));
1429    for (index, item) in items.iter().enumerate() {
1430        let item_target = item.target_id.or(context.target);
1431        let marker = if ordered {
1432            format!("{}.", start.unwrap_or(1).saturating_add(index as u64))
1433        } else {
1434            "•".to_owned()
1435        };
1436        let mut spans = vec![Span::styled(
1437            format!("{prefix}{}{marker} ", "  ".repeat(item.depth)),
1438            base,
1439        )];
1440        spans.extend(inline_spans(&item.content, base, theme));
1441        let continuation = format!("{prefix}{}", " ".repeat(marker.width() + 1));
1442        output.push_wrapped(
1443            spans,
1444            &continuation,
1445            RowOrigin {
1446                source: &item.source,
1447                target: item_target,
1448            },
1449        );
1450        let child_prefix = format!("{prefix}  ");
1451        for child in &item.blocks {
1452            render_block(
1453                child,
1454                theme,
1455                highlighter,
1456                output,
1457                BlockContext {
1458                    target: item_target,
1459                    prefix: &child_prefix,
1460                    ..context
1461                },
1462            );
1463        }
1464    }
1465}
1466
1467fn render_code(
1468    code: &MarkdownCodeBlock,
1469    theme: &ReviewTheme,
1470    highlighter: &mut SyntaxHighlighter,
1471    output: &mut RowOutput,
1472    target: Option<MarkdownTargetId>,
1473    prefix: &str,
1474) {
1475    let highlights = highlight_code_block(code, theme, highlighter);
1476    let base = fenced_code_style(theme);
1477    for (index, line) in code.lines.iter().enumerate() {
1478        render_code_line(
1479            line,
1480            highlights.line(index).unwrap_or_default(),
1481            base,
1482            prefix,
1483            line.target_id.or(target),
1484            output,
1485        );
1486    }
1487}
1488
1489fn render_code_line(
1490    line: &clankerdiff_markdown::MarkdownCodeLine,
1491    spans: &[HighlightSpan],
1492    base: Style,
1493    prefix: &str,
1494    target: Option<MarkdownTargetId>,
1495    output: &mut RowOutput,
1496) {
1497    let mut rendered = highlighted_line(&line.text, spans, base);
1498    rendered
1499        .spans
1500        .insert(0, Span::styled(prefix.to_owned(), base));
1501    output.push_wrapped(
1502        rendered.spans,
1503        prefix,
1504        RowOrigin {
1505            source: &line.source,
1506            target,
1507        },
1508    );
1509}
1510
1511/// Column widths for `table`, or `None` when the columns cannot fit side by
1512/// side and cells must stack.
1513fn table_column_widths(table: &MarkdownTable, available: usize, wrap: bool) -> Option<Vec<usize>> {
1514    let columns = table_columns(table);
1515    let mut natural = vec![1; columns];
1516    for row in &table.rows {
1517        for (index, cell) in row.cells.iter().enumerate() {
1518            natural[index] = natural[index].max(rendered_text(&cell.content).width());
1519        }
1520    }
1521    if !wrap || natural.iter().sum::<usize>() <= available {
1522        return Some(natural);
1523    }
1524    if available < columns {
1525        return None;
1526    }
1527    let mut order = (0..columns).collect::<Vec<_>>();
1528    order.sort_by_key(|index| natural[*index]);
1529    let mut widths = vec![0; columns];
1530    let mut budget = available;
1531    for (rank, index) in order.into_iter().enumerate() {
1532        let share = budget / (columns - rank);
1533        widths[index] = natural[index].min(share);
1534        budget -= widths[index];
1535    }
1536    Some(widths)
1537}
1538
1539fn table_columns(table: &MarkdownTable) -> usize {
1540    table
1541        .rows
1542        .iter()
1543        .map(|row| row.cells.len())
1544        .max()
1545        .unwrap_or(0)
1546}
1547
1548fn render_table(
1549    table: &MarkdownTable,
1550    theme: &ReviewTheme,
1551    output: &mut RowOutput,
1552    foreground: Rgba,
1553    target: Option<MarkdownTargetId>,
1554    prefix: &str,
1555) {
1556    let text = Style::new().fg(page_color(theme, foreground));
1557    let border = Style::new().fg(page_color(theme, theme.diff.border));
1558    let columns = table_columns(table);
1559    let available =
1560        usize::from(output.options.width).saturating_sub(prefix.width() + columns * 3 + 1);
1561    let widths = table_column_widths(table, available, output.options.wrap);
1562    for row in &table.rows {
1563        let base = if row.header {
1564            text.add_modifier(Modifier::BOLD)
1565        } else {
1566            text
1567        };
1568        let origin = RowOrigin {
1569            source: &row.source,
1570            target: row.target_id.or(target),
1571        };
1572        let Some(widths) = &widths else {
1573            for cell in &row.cells {
1574                output.push_wrapped(inline_spans(&cell.content, base, theme), prefix, origin);
1575            }
1576            continue;
1577        };
1578        if widths.is_empty() {
1579            continue;
1580        }
1581        let cells = row
1582            .cells
1583            .iter()
1584            .enumerate()
1585            .map(|(index, cell)| {
1586                fit_spans(
1587                    inline_spans(&cell.content, base, theme),
1588                    output.fit_options(u16::try_from(widths[index]).unwrap_or(u16::MAX), ""),
1589                )
1590            })
1591            .collect::<Vec<_>>();
1592        let height = cells.iter().map(Vec::len).max().unwrap_or(1);
1593        for line in 0..height {
1594            let mut spans = vec![Span::styled(format!("{prefix}│ "), border)];
1595            for (index, cell_width) in widths.iter().copied().enumerate() {
1596                if index > 0 {
1597                    spans.push(Span::styled(" │ ", border));
1598                }
1599                let cell = cells.get(index).and_then(|rows| rows.get(line));
1600                let padding = cell_width.saturating_sub(cell.map_or(0, Line::width));
1601                let left = match table.alignments.get(index) {
1602                    Some(MarkdownTableAlignment::Right) => padding,
1603                    Some(MarkdownTableAlignment::Center) => padding / 2,
1604                    _ => 0,
1605                };
1606                spans.push(Span::styled(" ".repeat(left), base));
1607                if let Some(cell) = cell {
1608                    spans.extend(cell.spans.iter().cloned());
1609                }
1610                spans.push(Span::styled(" ".repeat(padding - left), base));
1611            }
1612            spans.push(Span::styled(" │", border));
1613            output.push_wrapped(spans, prefix, origin);
1614        }
1615    }
1616}
1617
1618fn inline_spans(
1619    inlines: &[MarkdownInline],
1620    style: Style,
1621    theme: &ReviewTheme,
1622) -> Vec<Span<'static>> {
1623    fn append(
1624        inline: &MarkdownInline,
1625        style: Style,
1626        theme: &ReviewTheme,
1627        output: &mut Vec<Span<'static>>,
1628    ) {
1629        match inline {
1630            MarkdownInline::Text(text) => output.push(Span::styled(text.clone(), style)),
1631            MarkdownInline::Code(text) => {
1632                output.push(Span::styled(text.clone(), inline_code_style(style, theme)));
1633            }
1634            MarkdownInline::Strong(children) => children.iter().for_each(|child| {
1635                append(child, style.add_modifier(Modifier::BOLD), theme, output);
1636            }),
1637            MarkdownInline::Emphasis(children) => children.iter().for_each(|child| {
1638                append(child, style.add_modifier(Modifier::ITALIC), theme, output);
1639            }),
1640            MarkdownInline::Strikethrough(children) => children.iter().for_each(|child| {
1641                append(
1642                    child,
1643                    style.add_modifier(Modifier::CROSSED_OUT),
1644                    theme,
1645                    output,
1646                );
1647            }),
1648            MarkdownInline::Link { content, .. } => content.iter().for_each(|child| {
1649                append(
1650                    child,
1651                    style
1652                        .fg(page_color(theme, theme.markdown.link))
1653                        .add_modifier(Modifier::UNDERLINED),
1654                    theme,
1655                    output,
1656                );
1657            }),
1658            MarkdownInline::SoftBreak => output.push(Span::styled(" ", style)),
1659            MarkdownInline::HardBreak => output.push(Span::styled("\n", style)),
1660            MarkdownInline::ImageAlt(text) => output.push(Span::styled(
1661                format!("Image: {text}"),
1662                style
1663                    .fg(page_color(theme, theme.markdown.link))
1664                    .add_modifier(Modifier::ITALIC),
1665            )),
1666        }
1667    }
1668
1669    let mut output = Vec::new();
1670    for inline in inlines {
1671        append(inline, style, theme, &mut output);
1672    }
1673    output
1674}
1675
1676#[derive(Debug, Clone, PartialEq, Eq)]
1677struct SourceLineKey {
1678    text: String,
1679    source: SourceRange,
1680    target: Option<MarkdownTargetId>,
1681    styles: Vec<MarkdownSourceStyle>,
1682    code: Option<(String, Arc<[HighlightSpan]>)>,
1683}
1684
1685impl SourceLineKey {
1686    fn matches(
1687        &self,
1688        text: &str,
1689        source: &SourceRange,
1690        target: Option<MarkdownTargetId>,
1691        styles: &[&MarkdownSourceStyle],
1692        code: Option<&(&str, Arc<[HighlightSpan]>)>,
1693    ) -> bool {
1694        self.text == text
1695            && self.source == *source
1696            && self.target == target
1697            && self.styles.iter().eq(styles.iter().copied())
1698            && match (&self.code, code) {
1699                (None, None) => true,
1700                (Some((cached_text, cached_spans)), Some((text, spans))) => {
1701                    cached_text == text
1702                        && (Arc::ptr_eq(cached_spans, spans) || cached_spans == spans)
1703                }
1704                _ => false,
1705            }
1706    }
1707}
1708
1709#[derive(Debug, Clone)]
1710struct CachedSourceLine {
1711    key: Arc<SourceLineKey>,
1712    rows: RowChunk,
1713}
1714
1715fn source_role_style(role: MarkdownSourceRole, style: Style, theme: &ReviewTheme) -> Style {
1716    match role {
1717        MarkdownSourceRole::Heading => style
1718            .fg(page_color(theme, theme.markdown.heading))
1719            .add_modifier(Modifier::BOLD),
1720        MarkdownSourceRole::Link => style
1721            .fg(page_color(theme, theme.markdown.link))
1722            .add_modifier(Modifier::UNDERLINED),
1723        MarkdownSourceRole::Quote => style.fg(page_color(theme, theme.markdown.quote)),
1724        MarkdownSourceRole::Code => inline_code_style(style, theme),
1725        MarkdownSourceRole::Strong => style.add_modifier(Modifier::BOLD),
1726        MarkdownSourceRole::Emphasis => style.add_modifier(Modifier::ITALIC),
1727        MarkdownSourceRole::Strikethrough => style.add_modifier(Modifier::CROSSED_OUT),
1728    }
1729}