1use crate::syntax::highlighted_line;
4use clankerdiff_markdown::{
5 MarkdownBlock, MarkdownBlockKind, MarkdownDocument, MarkdownInline, MarkdownStream,
6 MarkdownStreamIdentity,
7};
8use clankerdiff_syntax::{DocumentHighlights, LanguageHint, SourceSequenceId, SyntaxHighlighter};
9use clankerdiff_theme::{Fingerprint, ReviewTheme, Rgba};
10use ratatui::{
11 style::{Color, Modifier, Style},
12 text::{Line, Span},
13};
14use std::sync::Arc;
15use unicode_width::UnicodeWidthChar;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct MarkdownRenderOptions {
20 pub width: u16,
21 pub block_spacing: bool,
22}
23
24impl Default for MarkdownRenderOptions {
25 fn default() -> Self {
26 Self {
27 width: 80,
28 block_spacing: true,
29 }
30 }
31}
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct MarkdownRenderStats {
36 pub parsed_bytes: usize,
38 pub parsed_documents: u64,
39 pub rows_generated: usize,
40 pub rows_reused: usize,
41}
42
43#[derive(Debug, Clone, Default)]
45pub struct StreamingMarkdownState {
46 parsed: Option<ParsedSource>,
47 options: MarkdownRenderOptions,
48 theme_revision: Fingerprint,
49 lines: Arc<[Line<'static>]>,
50 stats: MarkdownRenderStats,
51}
52
53impl StreamingMarkdownState {
54 pub fn reset(&mut self) {
55 *self = Self::default();
56 }
57
58 pub fn take_stats(&mut self) -> MarkdownRenderStats {
60 std::mem::take(&mut self.stats)
61 }
62}
63
64#[derive(Debug, Clone)]
65struct ParsedSource {
66 identity: MarkdownStreamIdentity,
67 revision: u64,
68 document: MarkdownDocument,
69}
70
71impl ParsedSource {
72 fn parse(stream: &MarkdownStream) -> Self {
73 Self {
74 identity: stream.identity(),
75 revision: stream.revision(),
76 document: MarkdownDocument::parse(stream.source()),
77 }
78 }
79
80 fn matches(&self, stream: &MarkdownStream) -> bool {
81 self.identity == stream.identity() && self.revision == stream.revision()
82 }
83}
84
85#[derive(Debug, Default)]
87pub struct MarkdownRenderer;
88
89impl MarkdownRenderer {
90 #[must_use]
91 pub const fn new() -> Self {
92 Self
93 }
94
95 #[must_use]
97 pub fn render_lines(
98 &self,
99 document: &MarkdownDocument,
100 options: MarkdownRenderOptions,
101 theme: &ReviewTheme,
102 highlighter: &mut SyntaxHighlighter,
103 ) -> Arc<[Line<'static>]> {
104 Arc::from(document_rows(document, options, theme, highlighter))
105 }
106
107 pub fn render_stream_lines(
108 &self,
109 state: &mut StreamingMarkdownState,
110 stream: &MarkdownStream,
111 options: MarkdownRenderOptions,
112 theme: &ReviewTheme,
113 highlighter: &mut SyntaxHighlighter,
114 ) -> Arc<[Line<'static>]> {
115 let theme_revision = theme.revision();
116 let parsed = match state.parsed.take() {
117 Some(parsed) if parsed.matches(stream) => {
118 if state.options == options && state.theme_revision == theme_revision {
119 state.stats.rows_reused += state.lines.len();
120 state.parsed = Some(parsed);
121 return Arc::clone(&state.lines);
122 }
123 parsed
124 }
125 _ => {
126 state.stats.parsed_bytes += stream.source().len();
127 state.stats.parsed_documents += 1;
128 ParsedSource::parse(stream)
129 }
130 };
131 let rows = document_rows(&parsed.document, options, theme, highlighter);
132 state.stats.rows_generated += rows.len();
133 state.parsed = Some(parsed);
134 state.options = options;
135 state.theme_revision = theme_revision;
136 state.lines = Arc::from(rows);
137 Arc::clone(&state.lines)
138 }
139}
140
141fn document_rows(
142 document: &MarkdownDocument,
143 options: MarkdownRenderOptions,
144 theme: &ReviewTheme,
145 highlighter: &mut SyntaxHighlighter,
146) -> Vec<Line<'static>> {
147 let mut output = Vec::new();
148 for (index, block) in document.blocks().iter().enumerate() {
149 render_block(
150 block,
151 options.width.max(1),
152 theme,
153 highlighter,
154 &mut output,
155 "",
156 );
157 if options.block_spacing && index + 1 < document.blocks().len() {
158 output.push(Line::default());
159 }
160 }
161 output
162}
163
164fn code_rows(
166 lines: &[&str],
167 highlights: &DocumentHighlights,
168 theme: &ReviewTheme,
169 width: u16,
170 prefix: &str,
171) -> Vec<Line<'static>> {
172 let base = Style::new()
173 .fg(color(theme.markdown.code))
174 .bg(color(theme.diff.background));
175 let mut output = Vec::new();
176 for (index, line) in lines.iter().enumerate() {
177 let spans = highlights.line(index).unwrap_or_default();
178 let mut rendered = highlighted_line(line, spans, base);
179 if !prefix.is_empty() {
180 rendered
181 .spans
182 .insert(0, Span::styled(prefix.to_owned(), base));
183 }
184 push_wrapped_spans(&mut output, rendered.spans, width);
185 }
186 output
187}
188
189fn render_block(
190 block: &MarkdownBlock,
191 width: u16,
192 theme: &ReviewTheme,
193 highlighter: &mut SyntaxHighlighter,
194 output: &mut Vec<Line<'static>>,
195 prefix: &str,
196) {
197 match &block.kind {
198 MarkdownBlockKind::Heading { level, content } => {
199 let marker = format!("{} ", "#".repeat(usize::from(*level)));
200 let base = Style::new()
201 .fg(color(theme.markdown.heading))
202 .add_modifier(Modifier::BOLD);
203 let mut spans = vec![Span::styled(format!("{prefix}{marker}"), base)];
204 spans.extend(inline_spans(content, base, theme));
205 push_wrapped_spans(output, spans, width);
206 }
207 MarkdownBlockKind::Paragraph { content } | MarkdownBlockKind::HtmlFallback { content } => {
208 let base = Style::new().fg(color(theme.diff.foreground));
209 let mut spans = vec![Span::styled(prefix.to_owned(), base)];
210 spans.extend(inline_spans(content, base, theme));
211 push_wrapped_spans(output, spans, width);
212 }
213 MarkdownBlockKind::List {
214 ordered,
215 start,
216 items,
217 } => {
218 for (index, item) in items.iter().enumerate() {
219 let marker = if *ordered {
220 format!("{}.", start.unwrap_or(1).saturating_add(index as u64))
221 } else {
222 "•".to_owned()
223 };
224 let base = Style::new().fg(color(theme.diff.foreground));
225 let mut spans = vec![Span::styled(
226 format!("{prefix}{}{marker} ", " ".repeat(item.depth)),
227 base,
228 )];
229 spans.extend(inline_spans(&item.content, base, theme));
230 push_wrapped_spans(output, spans, width);
231 for child in &item.blocks {
232 render_block(
233 child,
234 width,
235 theme,
236 highlighter,
237 output,
238 &format!("{prefix} "),
239 );
240 }
241 }
242 }
243 MarkdownBlockKind::BlockQuote { blocks } => {
244 for child in blocks {
245 render_block(
246 child,
247 width,
248 theme,
249 highlighter,
250 output,
251 &format!("{prefix}│ "),
252 );
253 }
254 }
255 MarkdownBlockKind::CodeBlock(code) => {
256 let lines = code
257 .lines
258 .iter()
259 .map(|line| line.text.as_str())
260 .collect::<Vec<_>>();
261 let highlights = highlighter
262 .with_theme(&theme.syntax)
263 .highlight_document_lines(
264 SourceSequenceId::from_lines(lines.iter().copied()),
265 LanguageHint::InfoString(code.highlight_hint()),
266 lines.iter().copied(),
267 );
268 output.extend(code_rows(&lines, &highlights, theme, width, prefix));
269 }
270 MarkdownBlockKind::Table(table) => {
271 for row in &table.rows {
272 let base = Style::new().fg(color(theme.diff.foreground));
273 let border = Style::new().fg(color(theme.diff.border));
274 let mut spans = vec![Span::styled(format!("{prefix}│ "), border)];
275 for (index, cell) in row.cells.iter().enumerate() {
276 if index > 0 {
277 spans.push(Span::styled(" │ ", border));
278 }
279 let cell_base = if row.header {
280 base.add_modifier(Modifier::BOLD)
281 } else {
282 base
283 };
284 spans.extend(inline_spans(&cell.content, cell_base, theme));
285 }
286 spans.push(Span::styled(" │", border));
287 push_wrapped_spans(output, spans, width);
288 }
289 }
290 MarkdownBlockKind::Rule => output.push(Line::styled(
291 "─".repeat(usize::from(width)),
292 Style::new().fg(color(theme.diff.border)),
293 )),
294 }
295}
296
297fn inline_spans(
298 inlines: &[MarkdownInline],
299 style: Style,
300 theme: &ReviewTheme,
301) -> Vec<Span<'static>> {
302 fn append(
303 inline: &MarkdownInline,
304 style: Style,
305 theme: &ReviewTheme,
306 output: &mut Vec<Span<'static>>,
307 ) {
308 match inline {
309 MarkdownInline::Text(text) => output.push(Span::styled(text.clone(), style)),
310 MarkdownInline::Code(text) => output.push(Span::styled(
311 text.clone(),
312 style
313 .fg(color(theme.markdown.code))
314 .bg(color(theme.diff.border)),
315 )),
316 MarkdownInline::Strong(children) => children.iter().for_each(|child| {
317 append(child, style.add_modifier(Modifier::BOLD), theme, output);
318 }),
319 MarkdownInline::Emphasis(children) => children.iter().for_each(|child| {
320 append(child, style.add_modifier(Modifier::ITALIC), theme, output);
321 }),
322 MarkdownInline::Strikethrough(children) => children.iter().for_each(|child| {
323 append(
324 child,
325 style.add_modifier(Modifier::CROSSED_OUT),
326 theme,
327 output,
328 );
329 }),
330 MarkdownInline::Link { content, .. } => content.iter().for_each(|child| {
331 append(
332 child,
333 style
334 .fg(color(theme.markdown.link))
335 .add_modifier(Modifier::UNDERLINED),
336 theme,
337 output,
338 );
339 }),
340 MarkdownInline::SoftBreak => output.push(Span::styled(" ", style)),
341 MarkdownInline::HardBreak => output.push(Span::styled("\n", style)),
342 MarkdownInline::ImageAlt(text) => output.push(Span::styled(
343 format!("Image: {text}"),
344 style
345 .fg(color(theme.markdown.link))
346 .add_modifier(Modifier::ITALIC),
347 )),
348 }
349 }
350
351 let mut output = Vec::new();
352 for inline in inlines {
353 append(inline, style, theme, &mut output);
354 }
355 output
356}
357
358fn push_wrapped_spans(output: &mut Vec<Line<'static>>, spans: Vec<Span<'static>>, width: u16) {
359 let width = usize::from(width.max(1));
360 let mut rows = vec![Line::default()];
361 let mut used = 0usize;
362 for span in spans {
363 let style = span.style;
364 for character in span.content.chars() {
365 if character == '\n' {
366 rows.push(Line::default());
367 used = 0;
368 continue;
369 }
370 let character_width = character.width().unwrap_or(0);
371 if used > 0 && used.saturating_add(character_width) > width {
372 rows.push(Line::default());
373 used = 0;
374 }
375 let row = rows.last_mut().expect("wrapping always retains one row");
376 if let Some(last) = row.spans.last_mut().filter(|last| last.style == style) {
377 last.content.to_mut().push(character);
378 } else {
379 row.spans.push(Span::styled(character.to_string(), style));
380 }
381 used = used.saturating_add(character_width);
382 }
383 }
384 output.extend(rows);
385}
386
387const fn color(value: Rgba) -> Color {
388 Color::Rgb(value.r, value.g, value.b)
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn finished_stream_matches_one_shot_for_every_utf8_chunk_boundary() {
397 let source = "# Héading\n\nText with 世界.\n\n```rust\n/* open\nstill comment */\n```\n";
398 let options = MarkdownRenderOptions {
399 width: 36,
400 block_spacing: true,
401 };
402 let theme = ReviewTheme::default();
403 let expected = MarkdownRenderer::new().render_lines(
404 &MarkdownDocument::parse(source),
405 options,
406 &theme,
407 &mut SyntaxHighlighter::default(),
408 );
409
410 for split in source
411 .char_indices()
412 .map(|(offset, _)| offset)
413 .chain(std::iter::once(source.len()))
414 {
415 let mut stream = MarkdownStream::new();
416 stream.push(&source[..split]);
417 stream.push(&source[split..]);
418 stream.finish();
419 let actual = MarkdownRenderer::new().render_stream_lines(
420 &mut StreamingMarkdownState::default(),
421 &stream,
422 options,
423 &theme,
424 &mut SyntaxHighlighter::default(),
425 );
426 assert_eq!(actual, expected, "split at byte {split}");
427 }
428 }
429
430 #[test]
431 fn open_fence_stream_matches_the_current_one_shot_snapshot() {
432 let source = "Settled prose.\n\n```rust\n/* open\nstill open";
433 let mut stream = MarkdownStream::new();
434 for chunk in ["Settled prose.\n\n```rust\n", "/* open\n", "still open"] {
435 stream.push(chunk);
436 }
437 assert!(!stream.is_finished());
438 let options = MarkdownRenderOptions {
439 width: 48,
440 block_spacing: true,
441 };
442 let theme = ReviewTheme::default();
443 let expected = MarkdownRenderer::new().render_lines(
444 &MarkdownDocument::parse(source),
445 options,
446 &theme,
447 &mut SyntaxHighlighter::default(),
448 );
449 let actual = MarkdownRenderer::new().render_stream_lines(
450 &mut StreamingMarkdownState::default(),
451 &stream,
452 options,
453 &theme,
454 &mut SyntaxHighlighter::default(),
455 );
456 assert_eq!(actual, expected);
457 }
458
459 #[test]
460 fn finishing_an_open_fence_commits_its_partial_final_line() {
461 let source = "```rust\n/* open\nstill open";
462 let mut stream = MarkdownStream::new();
463 stream.push(source);
464 let mut state = StreamingMarkdownState::default();
465 let renderer = MarkdownRenderer::new();
466 let options = MarkdownRenderOptions::default();
467 let theme = ReviewTheme::default();
468 let mut highlighter = SyntaxHighlighter::default();
469 renderer.render_stream_lines(&mut state, &stream, options, &theme, &mut highlighter);
470 stream.finish();
471 let actual =
472 renderer.render_stream_lines(&mut state, &stream, options, &theme, &mut highlighter);
473 let expected = renderer.render_lines(
474 &MarkdownDocument::parse(source),
475 options,
476 &theme,
477 &mut SyntaxHighlighter::default(),
478 );
479 assert_eq!(actual, expected);
480 }
481
482 #[test]
483 fn unchanged_stream_reuses_rendered_lines() {
484 let mut stream = MarkdownStream::new();
485 stream.push("settled\n\n");
486 let mut state = StreamingMarkdownState::default();
487 let renderer = MarkdownRenderer::new();
488 let mut highlighter = SyntaxHighlighter::default();
489 let theme = ReviewTheme::default();
490 let first = renderer.render_stream_lines(
491 &mut state,
492 &stream,
493 MarkdownRenderOptions::default(),
494 &theme,
495 &mut highlighter,
496 );
497 let second = renderer.render_stream_lines(
498 &mut state,
499 &stream,
500 MarkdownRenderOptions::default(),
501 &theme,
502 &mut highlighter,
503 );
504 assert!(Arc::ptr_eq(&first, &second));
505 }
506
507 #[test]
508 fn read_only_rendering_preserves_nested_inline_styles() {
509 let source = "**bold and *both***, ~~gone~~, [`link`](https://example.com), `code`.";
510 let lines = MarkdownRenderer::new().render_lines(
511 &MarkdownDocument::parse(source),
512 MarkdownRenderOptions::default(),
513 &ReviewTheme::default(),
514 &mut SyntaxHighlighter::default(),
515 );
516 let spans = &lines[0].spans;
517 let both = spans
518 .iter()
519 .find(|span| span.content.contains("both"))
520 .unwrap();
521 assert!(both.style.add_modifier.contains(Modifier::BOLD));
522 assert!(both.style.add_modifier.contains(Modifier::ITALIC));
523 let gone = spans
524 .iter()
525 .find(|span| span.content.contains("gone"))
526 .unwrap();
527 assert!(gone.style.add_modifier.contains(Modifier::CROSSED_OUT));
528 let link = spans
529 .iter()
530 .find(|span| span.content.contains("link"))
531 .unwrap();
532 assert!(link.style.add_modifier.contains(Modifier::UNDERLINED));
533 let code = spans
534 .iter()
535 .find(|span| span.content.contains("code"))
536 .unwrap();
537 assert!(code.style.bg.is_some());
538 }
539
540 #[test]
541 fn stream_stats_reset_and_changed_snapshots_parse_complete_context() {
542 let renderer = MarkdownRenderer::new();
543 let theme = ReviewTheme::default();
544 let options = MarkdownRenderOptions::default();
545 let mut stream = MarkdownStream::new();
546 let mut state = StreamingMarkdownState::default();
547 let mut highlighter = SyntaxHighlighter::default();
548 stream.push("first paragraph\n\n");
549 renderer.render_stream_lines(&mut state, &stream, options, &theme, &mut highlighter);
550 let first = state.take_stats();
551 assert_eq!(first.parsed_bytes, "first paragraph\n\n".len());
552 stream.push("second paragraph\n\n");
553 renderer.render_stream_lines(&mut state, &stream, options, &theme, &mut highlighter);
554 let second = state.take_stats();
555 assert_eq!(second.parsed_bytes, stream.source().len());
556 assert_eq!(state.take_stats(), MarkdownRenderStats::default());
557
558 stream.replace("replacement is longer than both prior paragraphs\n\n");
559 let replaced =
560 renderer.render_stream_lines(&mut state, &stream, options, &theme, &mut highlighter);
561 let expected = renderer.render_lines(
562 &MarkdownDocument::parse(stream.source()),
563 options,
564 &theme,
565 &mut SyntaxHighlighter::default(),
566 );
567 assert_eq!(replaced, expected);
568 assert_eq!(state.take_stats().parsed_bytes, stream.source().len());
569 }
570
571 #[test]
572 fn stream_cache_invalidates_for_spacing_and_markdown_palette_changes() {
573 let mut stream = MarkdownStream::new();
574 stream.push("# Heading\n\nParagraph\n\n");
575 let mut state = StreamingMarkdownState::default();
576 let renderer = MarkdownRenderer::new();
577 let mut highlighter = SyntaxHighlighter::default();
578 let theme = ReviewTheme::default();
579 let spaced = renderer.render_stream_lines(
580 &mut state,
581 &stream,
582 MarkdownRenderOptions {
583 width: 80,
584 block_spacing: true,
585 },
586 &theme,
587 &mut highlighter,
588 );
589 let compact = renderer.render_stream_lines(
590 &mut state,
591 &stream,
592 MarkdownRenderOptions {
593 width: 80,
594 block_spacing: false,
595 },
596 &theme,
597 &mut highlighter,
598 );
599 assert_ne!(spaced, compact);
600
601 let mut changed = theme.clone();
602 changed.markdown.heading = Rgba::new(1, 2, 3, 255);
603 let recolored = renderer.render_stream_lines(
604 &mut state,
605 &stream,
606 MarkdownRenderOptions {
607 width: 80,
608 block_spacing: false,
609 },
610 &changed,
611 &mut highlighter,
612 );
613 assert_ne!(compact, recolored);
614 }
615}