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;
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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42pub struct MarkdownRenderStats {
43 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#[derive(Debug, Clone, Default)]
101pub struct StreamingMarkdownState {
102 policy: StreamingMarkdownPolicy,
103 layout: MarkdownLayout,
104 cache: LayoutCache,
105 history: History,
106 revision: u64,
107 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 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#[derive(Debug, Default)]
235pub struct MarkdownRenderer;
236
237impl MarkdownRenderer {
238 #[must_use]
239 pub const fn new() -> Self {
240 Self
241 }
242
243 #[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
1169fn 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
1178fn 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
1187fn 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#[derive(Clone, Copy)]
1216struct BlockContext<'a> {
1217 target: Option<MarkdownTargetId>,
1218 foreground: Rgba,
1219 prefix: &'a str,
1220}
1221
1222#[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: "e_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
1518fn 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}