1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct MarkdownRenderStats {
36 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#[derive(Debug, Clone, Default)]
94pub struct StreamingMarkdownState {
95 policy: StreamingMarkdownPolicy,
96 layout: MarkdownLayout,
97 cache: LayoutCache,
98 history: History,
99 revision: u64,
100 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 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#[derive(Debug, Default)]
228pub struct MarkdownRenderer;
229
230impl MarkdownRenderer {
231 #[must_use]
232 pub const fn new() -> Self {
233 Self
234 }
235
236 #[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
1162fn 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
1171fn 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
1180fn 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#[derive(Clone, Copy)]
1209struct BlockContext<'a> {
1210 target: Option<MarkdownTargetId>,
1211 foreground: Rgba,
1212 prefix: &'a str,
1213}
1214
1215#[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: "e_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
1511fn 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}