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