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