返回 CodeWhale
diff_render.rs
根目录 / crates / tui / src / tui / diff_render.rs
1 //! Diff rendering helpers for TUI previews.
2
3 use ratatui::style::{Modifier, Style};
4 use ratatui::text::{Line, Span};
5 use similar::{ChangeTag, TextDiff};
6 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
7
8 use codewhale_palette as palette;
9
10 const LINE_NUMBER_WIDTH: usize = 4;
11
12 /// Below this word-level similarity a replaced line pair is rewritten, not
13 /// edited, and emphasising the changed words would light up the whole row.
14 const INTRALINE_MIN_RATIO: f32 = 0.5;
15
16 /// Pairing stops here so a huge hunk cannot buffer word segments for every
17 /// replaced line; longer runs render line by line.
18 const INTRALINE_MAX_RUN: usize = 64;
19
20 /// A run of text inside a changed line and whether it is part of the change.
21 type Segment = (String, bool);
22
23 #[derive(Debug, Clone, PartialEq, Eq)]
24 pub struct DiffFileSummary {
25 pub path: String,
26 pub added: usize,
27 pub deleted: usize,
28 pub hunks: usize,
29 }
30
31 /// A rendered diff preview with an exact count of rows not retained.
32 ///
33 /// The renderer still scans the complete diff so summaries and omission
34 /// counts stay truthful, but it never accumulates more than the requested
35 /// number of body rows. This keeps narrow, generated diffs from first
36 /// materializing an unbounded `Vec<Line>` only to be truncated by a caller.
37 #[derive(Debug, Clone)]
38 pub struct BoundedDiffRender {
39 pub lines: Vec<Line<'static>>,
40 pub omitted_rows: usize,
41 }
42
43 pub fn render_diff(diff: &str, width: u16) -> Vec<Line<'static>> {
44 render_diff_bounded(diff, width, usize::MAX).lines
45 }
46
47 /// Render a diff summary and at most `max_body_rows` rows of diff evidence.
48 #[must_use]
49 pub fn render_diff_bounded(diff: &str, width: u16, max_body_rows: usize) -> BoundedDiffRender {
50 let summaries = summarize_diff(diff);
51 let mut rendered = render_diff_body_bounded(diff, width, max_body_rows);
52 if !summaries.is_empty() {
53 let mut lines = render_diff_summary(&summaries, width);
54 lines.append(&mut rendered.lines);
55 rendered.lines = lines;
56 }
57 rendered
58 }
59
60 /// Render only the diff body. Callers that already own a semantic summary use
61 /// this form so the bounded preview budget is spent on the actual red/green
62 /// evidence instead of a second, generic summary.
63 /// Render at most `max_rows` body rows while counting every omitted wrapped
64 /// row. Allocation is bounded by the retained preview, one source line's
65 /// wrapped representation, and the current `-`/`+` run: its line slices
66 /// (borrowed, pointer-sized) plus word segments for at most
67 /// `INTRALINE_MAX_RUN` replaced pairs — never by the size of the complete
68 /// diff.
69 #[must_use]
70 pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> BoundedDiffRender {
71 let mut collector = BoundedLineCollector::new(max_rows);
72 let mut old_line: Option<usize> = None;
73 let mut new_line: Option<usize> = None;
74
75 let mut lines = diff.lines().peekable();
76 'line: while let Some(raw) = lines.next() {
77 if raw.starts_with("diff --git") || raw.starts_with("index ") {
78 collector.extend(render_header_line(raw, width));
79 continue;
80 }
81
82 if raw.starts_with("--- ") || raw.starts_with("+++ ") {
83 collector.extend(render_header_line(raw, width));
84 continue;
85 }
86
87 if raw.starts_with("@@") {
88 if let Some((old_start, new_start)) = parse_hunk_header(raw) {
89 old_line = Some(old_start);
90 new_line = Some(new_start);
91 }
92 collector.extend(render_hunk_header(raw, width));
93 continue;
94 }
95
96 if is_no_newline_marker(raw) {
97 collector.extend(render_header_line(raw, width));
98 continue;
99 }
100
101 if is_added(raw) {
102 let content = raw.trim_start_matches('+');
103 collector.extend(render_diff_line(
104 content,
105 width,
106 old_line,
107 new_line,
108 '+',
109 added_style(),
110 None,
111 ));
112 if let Some(line) = new_line.as_mut() {
113 *line = line.saturating_add(1);
114 }
115 continue;
116 }
117
118 if is_deleted(raw) {
119 // A deleted run followed by an added run of the same length is a
120 // set of replaced lines: emphasise the words that changed within
121 // each pair. Any other shape renders line by line as before.
122 let mut removed = vec![raw.trim_start_matches('-')];
123 let mut added: Vec<&str> = Vec::new();
124 let mut removed_markers: Vec<(usize, &str)> = Vec::new();
125 let mut added_markers: Vec<(usize, &str)> = Vec::new();
126 loop {
127 if let Some(marker) = lines.next_if(|next| is_no_newline_marker(next)) {
128 if added.is_empty() {
129 removed_markers.push((removed.len() - 1, marker));
130 } else {
131 added_markers.push((added.len() - 1, marker));
132 }
133 } else if added.is_empty()
134 && let Some(next) = lines.next_if(|next| is_deleted(next))
135 {
136 if removed.len() < INTRALINE_MAX_RUN {
137 removed.push(next.trim_start_matches('-'));
138 } else {
139 flush_plain_run(
140 &mut collector,
141 &removed,
142 &removed_markers,
143 '-',
144 width,
145 &mut old_line,
146 &mut new_line,
147 );
148 render_plain_diff_line(
149 &mut collector,
150 next.trim_start_matches('-'),
151 width,
152 &mut old_line,
153 &mut new_line,
154 '-',
155 );
156 continue 'line;
157 }
158 } else if let Some(next) = lines.next_if(|next| is_added(next)) {
159 if added.len() < INTRALINE_MAX_RUN {
160 added.push(next.trim_start_matches('+'));
161 } else {
162 flush_plain_run(
163 &mut collector,
164 &removed,
165 &removed_markers,
166 '-',
167 width,
168 &mut old_line,
169 &mut new_line,
170 );
171 flush_plain_run(
172 &mut collector,
173 &added,
174 &added_markers,
175 '+',
176 width,
177 &mut old_line,
178 &mut new_line,
179 );
180 render_plain_diff_line(
181 &mut collector,
182 next.trim_start_matches('+'),
183 width,
184 &mut old_line,
185 &mut new_line,
186 '+',
187 );
188 continue 'line;
189 }
190 } else {
191 break;
192 }
193 }
194
195 let pairs: Vec<Option<(Vec<Segment>, Vec<Segment>)>> =
196 if removed.len() == added.len() && removed.len() <= INTRALINE_MAX_RUN {
197 removed
198 .iter()
199 .zip(&added)
200 .map(|(old, new)| intraline_segments(old, new))
201 .collect()
202 } else {
203 Vec::new()
204 };
205
206 for (idx, content) in removed.iter().enumerate() {
207 let emphasis = pairs
208 .get(idx)
209 .and_then(|pair| pair.as_ref().map(|(old, _)| old.as_slice()));
210 collector.extend(render_diff_line(
211 content,
212 width,
213 old_line,
214 new_line,
215 '-',
216 deleted_style(),
217 emphasis,
218 ));
219 if let Some(line) = old_line.as_mut() {
220 *line = line.saturating_add(1);
221 }
222 for &(marker_idx, marker) in &removed_markers {
223 if marker_idx == idx {
224 collector.extend(render_header_line(marker, width));
225 }
226 }
227 }
228 for (idx, content) in added.iter().enumerate() {
229 let emphasis = pairs
230 .get(idx)
231 .and_then(|pair| pair.as_ref().map(|(_, new)| new.as_slice()));
232 collector.extend(render_diff_line(
233 content,
234 width,
235 old_line,
236 new_line,
237 '+',
238 added_style(),
239 emphasis,
240 ));
241 if let Some(line) = new_line.as_mut() {
242 *line = line.saturating_add(1);
243 }
244 for &(marker_idx, marker) in &added_markers {
245 if marker_idx == idx {
246 collector.extend(render_header_line(marker, width));
247 }
248 }
249 }
250 continue;
251 }
252
253 if raw.starts_with(' ') {
254 let content = raw.trim_start_matches(' ');
255 collector.extend(render_diff_line(
256 content,
257 width,
258 old_line,
259 new_line,
260 ' ',
261 Style::default().fg(palette::TEXT_PRIMARY),
262 None,
263 ));
264 if let Some(line) = old_line.as_mut() {
265 *line = line.saturating_add(1);
266 }
267 if let Some(line) = new_line.as_mut() {
268 *line = line.saturating_add(1);
269 }
270 continue;
271 }
272
273 collector.extend(render_header_line(raw, width));
274 }
275
276 collector.finish()
277 }
278
279 struct BoundedLineCollector {
280 lines: Vec<Line<'static>>,
281 max_rows: usize,
282 total_rows: usize,
283 }
284
285 impl BoundedLineCollector {
286 fn new(max_rows: usize) -> Self {
287 Self {
288 lines: Vec::with_capacity(max_rows.min(256)),
289 max_rows,
290 total_rows: 0,
291 }
292 }
293
294 fn extend(&mut self, rows: Vec<Line<'static>>) {
295 self.total_rows = self.total_rows.saturating_add(rows.len());
296 let remaining = self.max_rows.saturating_sub(self.lines.len());
297 self.lines.extend(rows.into_iter().take(remaining));
298 }
299
300 fn finish(self) -> BoundedDiffRender {
301 BoundedDiffRender {
302 omitted_rows: self.total_rows.saturating_sub(self.lines.len()),
303 lines: self.lines,
304 }
305 }
306 }
307
308 #[must_use]
309 pub fn summarize_diff(diff: &str) -> Vec<DiffFileSummary> {
310 let mut summaries = Vec::new();
311 let mut current: Option<DiffFileSummary> = None;
312
313 for raw in diff.lines() {
314 if raw.starts_with("diff --git ") {
315 if let Some(summary) = current.take()
316 && summary.has_changes()
317 {
318 summaries.push(summary);
319 }
320 current = Some(DiffFileSummary {
321 path: parse_diff_git_path(raw).unwrap_or_else(|| "<file>".to_string()),
322 added: 0,
323 deleted: 0,
324 hunks: 0,
325 });
326 continue;
327 }
328
329 if raw.starts_with("+++ ") {
330 let path = raw
331 .trim_start_matches("+++ ")
332 .trim_start_matches("b/")
333 .to_string();
334 if path != "/dev/null" {
335 current
336 .get_or_insert_with(|| DiffFileSummary {
337 path: path.clone(),
338 added: 0,
339 deleted: 0,
340 hunks: 0,
341 })
342 .path = path.clone();
343 }
344 continue;
345 }
346
347 if raw.starts_with("@@") {
348 current
349 .get_or_insert_with(|| DiffFileSummary {
350 path: "<file>".to_string(),
351 added: 0,
352 deleted: 0,
353 hunks: 0,
354 })
355 .hunks += 1;
356 continue;
357 }
358
359 if raw.starts_with('+') && !raw.starts_with("+++") {
360 current
361 .get_or_insert_with(|| DiffFileSummary {
362 path: "<file>".to_string(),
363 added: 0,
364 deleted: 0,
365 hunks: 0,
366 })
367 .added += 1;
368 } else if raw.starts_with('-') && !raw.starts_with("---") {
369 current
370 .get_or_insert_with(|| DiffFileSummary {
371 path: "<file>".to_string(),
372 added: 0,
373 deleted: 0,
374 hunks: 0,
375 })
376 .deleted += 1;
377 }
378 }
379
380 if let Some(summary) = current
381 && summary.has_changes()
382 {
383 summaries.push(summary);
384 }
385
386 summaries
387 }
388
389 #[must_use]
390 pub fn diff_summary_label(diff: &str) -> Option<String> {
391 let summaries = summarize_diff(diff);
392 if summaries.is_empty() {
393 return None;
394 }
395 let files = summaries.len();
396 let added: usize = summaries.iter().map(|summary| summary.added).sum();
397 let deleted: usize = summaries.iter().map(|summary| summary.deleted).sum();
398 Some(format!(
399 "{files} file{} +{added} -{deleted}",
400 if files == 1 { "" } else { "s" }
401 ))
402 }
403
404 impl DiffFileSummary {
405 fn has_changes(&self) -> bool {
406 self.added > 0 || self.deleted > 0 || self.hunks > 0
407 }
408 }
409
410 fn parse_diff_git_path(line: &str) -> Option<String> {
411 let mut parts = line.split_whitespace();
412 let _diff = parts.next()?;
413 let _git = parts.next()?;
414 let _old = parts.next()?;
415 let new = parts.next()?;
416 Some(new.trim_start_matches("b/").to_string())
417 }
418
419 fn render_diff_summary(summaries: &[DiffFileSummary], width: u16) -> Vec<Line<'static>> {
420 let files = summaries.len();
421 let added: usize = summaries.iter().map(|summary| summary.added).sum();
422 let deleted: usize = summaries.iter().map(|summary| summary.deleted).sum();
423 let hunks: usize = summaries.iter().map(|summary| summary.hunks).sum();
424
425 let mut lines = Vec::new();
426 lines.extend(wrap_with_style(
427 &format!(
428 "summary: {files} file{}, +{added} -{deleted}, {hunks} hunk{}",
429 if files == 1 { "" } else { "s" },
430 if hunks == 1 { "" } else { "s" },
431 ),
432 Style::default()
433 .fg(palette::TEXT_PRIMARY)
434 .add_modifier(Modifier::BOLD),
435 width,
436 ));
437 for summary in summaries {
438 let row = format!(
439 " {} +{} -{} {} hunk{}",
440 summary.path,
441 summary.added,
442 summary.deleted,
443 summary.hunks,
444 if summary.hunks == 1 { "" } else { "s" },
445 );
446 lines.extend(wrap_with_style(
447 &row,
448 Style::default().fg(palette::TEXT_MUTED),
449 width,
450 ));
451 }
452 lines
453 }
454
455 fn parse_hunk_header(line: &str) -> Option<(usize, usize)> {
456 let parts: Vec<&str> = line.split_whitespace().collect();
457 if parts.len() < 3 {
458 return None;
459 }
460 let old = parts[1].trim_start_matches('-');
461 let new = parts[2].trim_start_matches('+');
462 let old_start = old.split(',').next()?.parse::<usize>().ok()?;
463 let new_start = new.split(',').next()?.parse::<usize>().ok()?;
464 Some((old_start, new_start))
465 }
466
467 fn render_header_line(line: &str, width: u16) -> Vec<Line<'static>> {
468 let style = Style::default()
469 .fg(palette::WHALE_ACTION)
470 .add_modifier(Modifier::BOLD);
471 wrap_with_style(line, style, width)
472 }
473
474 fn render_hunk_header(line: &str, width: u16) -> Vec<Line<'static>> {
475 let style = Style::default().fg(palette::WHALE_ACTION);
476 wrap_with_style(line, style, width)
477 }
478
479 fn is_added(raw: &str) -> bool {
480 raw.starts_with('+') && !raw.starts_with("+++")
481 }
482
483 fn is_deleted(raw: &str) -> bool {
484 raw.starts_with('-') && !raw.starts_with("---")
485 }
486
487 fn is_no_newline_marker(raw: &str) -> bool {
488 raw.starts_with("\\ No newline")
489 }
490
491 fn added_style() -> Style {
492 Style::default()
493 .fg(palette::DIFF_ADDED)
494 .bg(palette::DIFF_ADDED_BG)
495 }
496
497 fn deleted_style() -> Style {
498 Style::default()
499 .fg(palette::STATUS_ERROR)
500 .bg(palette::DIFF_DELETED_BG)
501 }
502
503 /// Split a replaced line pair into word runs (unicode word boundaries, so
504 /// punctuation stays out of the emphasis), flagging the runs that differ.
505 ///
506 /// Returns `None` when the pair shares too few *words* to read as an edit —
507 /// whitespace and punctuation tokens do not count, so two lines that agree
508 /// only on syntax are painted whole, the way they always were.
509 fn intraline_segments(old: &str, new: &str) -> Option<(Vec<Segment>, Vec<Segment>)> {
510 let diff = TextDiff::from_unicode_words(old, new);
511 let mut old_segments: Vec<Segment> = Vec::new();
512 let mut new_segments: Vec<Segment> = Vec::new();
513 let mut changed = false;
514 let mut shared_words = 0usize;
515 let mut total_words = 0usize;
516 for change in diff.iter_all_changes() {
517 let text = change.value();
518 let is_word = text.chars().any(char::is_alphanumeric);
519 match change.tag() {
520 ChangeTag::Equal => {
521 if is_word {
522 shared_words += 2;
523 total_words += 2;
524 }
525 push_segment(&mut old_segments, text, false);
526 push_segment(&mut new_segments, text, false);
527 }
528 ChangeTag::Delete => {
529 changed = true;
530 total_words += usize::from(is_word);
531 push_segment(&mut old_segments, text, true);
532 }
533 ChangeTag::Insert => {
534 changed = true;
535 total_words += usize::from(is_word);
536 push_segment(&mut new_segments, text, true);
537 }
538 }
539 }
540 if !changed || total_words == 0 {
541 return None;
542 }
543 let ratio = shared_words as f32 / total_words as f32;
544 (ratio >= INTRALINE_MIN_RATIO).then_some((old_segments, new_segments))
545 }
546
547 fn push_segment(segments: &mut Vec<Segment>, text: &str, emphasised: bool) {
548 match segments.last_mut() {
549 Some((run, flag)) if *flag == emphasised => run.push_str(text),
550 _ => segments.push((text.to_string(), emphasised)),
551 }
552 }
553
554 /// Paint the wrapped chunks of a changed line, mapping every non-whitespace
555 /// character back to its source flag by position. `wrap_text` only drops,
556 /// collapses, or re-inserts whitespace (the indent lead comes back on every
557 /// continuation chunk), so the non-whitespace sequence is the invariant.
558 /// Whitespace joins an emphasised run only when both its neighbours are in
559 /// it. Returns `None` if the invariant ever fails, and the caller paints the
560 /// whole line plainly rather than emphasising the wrong word.
561 fn emphasised_chunks(
562 chunks: &[String],
563 style: Style,
564 segments: &[Segment],
565 ) -> Option<Vec<Vec<Span<'static>>>> {
566 let source: Vec<(char, bool)> = segments
567 .iter()
568 .flat_map(|(run, flag)| {
569 run.chars()
570 .filter(|ch| !ch.is_whitespace())
571 .map(move |ch| (ch, *flag))
572 })
573 .collect();
574 let emphasis = style.add_modifier(Modifier::BOLD | Modifier::REVERSED);
575 let mut cursor = 0usize;
576 let mut out = Vec::with_capacity(chunks.len());
577 for chunk in chunks {
578 let mut spans = Vec::new();
579 let mut run = String::new();
580 let mut run_flag = false;
581 let mut prev_flag = false;
582 for ch in chunk.chars() {
583 let flag = if ch.is_whitespace() {
584 prev_flag && source.get(cursor).is_some_and(|(_, next)| *next)
585 } else {
586 let (expected, flag) = *source.get(cursor)?;
587 debug_assert_eq!(
588 expected, ch,
589 "wrapped chunk diverged from its source line at {cursor}"
590 );
591 if expected != ch {
592 return None;
593 }
594 cursor += 1;
595 prev_flag = flag;
596 flag
597 };
598 if flag != run_flag && !run.is_empty() {
599 let painted = if run_flag { emphasis } else { style };
600 spans.push(Span::styled(std::mem::take(&mut run), painted));
601 }
602 run_flag = flag;
603 run.push(ch);
604 }
605 if !run.is_empty() {
606 let painted = if run_flag { emphasis } else { style };
607 spans.push(Span::styled(run, painted));
608 }
609 out.push(spans);
610 }
611 debug_assert_eq!(
612 cursor,
613 source.len(),
614 "wrapped chunks did not consume the whole source line"
615 );
616 (cursor == source.len()).then_some(out)
617 }
618
619 fn render_plain_diff_line(
620 collector: &mut BoundedLineCollector,
621 content: &str,
622 width: u16,
623 old_line: &mut Option<usize>,
624 new_line: &mut Option<usize>,
625 marker: char,
626 ) {
627 let style = match marker {
628 '-' => deleted_style(),
629 '+' => added_style(),
630 _ => Style::default().fg(palette::TEXT_PRIMARY),
631 };
632 collector.extend(render_diff_line(
633 content, width, *old_line, *new_line, marker, style, None,
634 ));
635 match marker {
636 '-' => {
637 if let Some(line) = old_line.as_mut() {
638 *line = line.saturating_add(1);
639 }
640 }
641 '+' => {
642 if let Some(line) = new_line.as_mut() {
643 *line = line.saturating_add(1);
644 }
645 }
646 _ => {}
647 }
648 }
649
650 fn flush_plain_run(
651 collector: &mut BoundedLineCollector,
652 lines: &[&str],
653 markers: &[(usize, &str)],
654 sign: char,
655 width: u16,
656 old_line: &mut Option<usize>,
657 new_line: &mut Option<usize>,
658 ) {
659 for (idx, content) in lines.iter().enumerate() {
660 render_plain_diff_line(collector, content, width, old_line, new_line, sign);
661 for &(marker_idx, marker) in markers {
662 if marker_idx == idx {
663 collector.extend(render_header_line(marker, width));
664 }
665 }
666 }
667 }
668
669 fn render_diff_line(
670 content: &str,
671 width: u16,
672 old_line: Option<usize>,
673 new_line: Option<usize>,
674 marker: char,
675 style: Style,
676 emphasis: Option<&[Segment]>,
677 ) -> Vec<Line<'static>> {
678 let prefix = format_line_numbers(old_line, new_line, marker);
679 let prefix_width = prefix.width();
680 // The whole logical row carries the change tint — numbers included. A
681 // bare gutter next to a painted body read as two unrelated strips.
682 let gutter_style = match style.bg {
683 Some(bg) => Style::default().fg(palette::TEXT_MUTED).bg(bg),
684 None => Style::default().fg(palette::TEXT_MUTED),
685 };
686 let available = width.saturating_sub(prefix_width as u16).max(1) as usize;
687 let wrapped = wrap_text(content, available);
688 let mut painted = emphasis.and_then(|segments| emphasised_chunks(&wrapped, style, segments));
689
690 let mut out = Vec::new();
691 for (idx, chunk) in wrapped.into_iter().enumerate() {
692 let gutter = if idx == 0 {
693 Span::styled(prefix.clone(), gutter_style)
694 } else {
695 Span::styled(" ".repeat(prefix_width), gutter_style)
696 };
697 let mut spans = vec![gutter];
698 match painted.as_mut() {
699 Some(rows) => spans.append(&mut rows[idx]),
700 None => spans.push(Span::styled(chunk, style)),
701 }
702 out.push(Line::from(spans));
703 }
704
705 if out.is_empty() {
706 out.push(Line::from(vec![Span::styled(prefix, gutter_style)]));
707 }
708
709 out
710 }
711
712 fn format_line_numbers(old_line: Option<usize>, new_line: Option<usize>, marker: char) -> String {
713 let old = old_line
714 .map(|value| format!("{value:>LINE_NUMBER_WIDTH$}"))
715 .unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH));
716 let new = new_line
717 .map(|value| format!("{value:>LINE_NUMBER_WIDTH$}"))
718 .unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH));
719 format!("{old} {new} {marker} ")
720 }
721
722 fn wrap_with_style(text: &str, style: Style, width: u16) -> Vec<Line<'static>> {
723 let mut out = Vec::new();
724 for part in wrap_text(text, width.max(1) as usize) {
725 out.push(Line::from(Span::styled(part, style)));
726 }
727 if out.is_empty() {
728 out.push(Line::from(Span::styled("", style)));
729 }
730 out
731 }
732
733 fn wrap_text(text: &str, width: usize) -> Vec<String> {
734 if width == 0 {
735 return vec![text.to_string()];
736 }
737 let lead = text
738 .chars()
739 .take_while(|ch| ch.is_whitespace())
740 .collect::<String>();
741 let trimmed = text.trim_start();
742 if trimmed.is_empty() {
743 return vec![text.to_string()];
744 }
745
746 let mut lines = Vec::new();
747 let lead_width = lead.width();
748 let mut current = lead.clone();
749 let mut current_width = lead_width;
750 let mut has_word = false;
751
752 for word in trimmed.split_whitespace() {
753 let word_width = word.width();
754 if word_width > width {
755 if has_word {
756 lines.push(std::mem::take(&mut current));
757 current = lead.clone();
758 current_width = lead_width;
759 }
760 push_word_breaking_chars(word, width, &mut current, &mut current_width, &mut lines);
761 has_word = current_width > lead_width;
762 continue;
763 }
764 let additional = if has_word { word_width + 1 } else { word_width };
765 if current_width + additional > width && has_word {
766 lines.push(current);
767 current = lead.clone();
768 current_width = lead_width;
769 has_word = false;
770 }
771 if has_word {
772 current.push(' ');
773 current_width += 1;
774 }
775 if current_width + word_width > width && !has_word && lead_width > 0 {
776 lines.push(std::mem::take(&mut current));
777 current_width = 0;
778 }
779 if current_width == 0 && lead_width > 0 && word_width + lead_width <= width {
780 current = lead.clone();
781 current_width = lead_width;
782 }
783 current.push_str(word);
784 current_width += word_width;
785 has_word = true;
786 }
787
788 if has_word || !current.is_empty() {
789 lines.push(current);
790 } else {
791 lines.push(String::new());
792 }
793
794 lines
795 }
796
797 fn push_word_breaking_chars(
798 word: &str,
799 width: usize,
800 current: &mut String,
801 current_width: &mut usize,
802 lines: &mut Vec<String>,
803 ) {
804 for ch in word.chars() {
805 let char_width = ch.width().unwrap_or(1);
806 if *current_width + char_width > width && *current_width > 0 {
807 lines.push(std::mem::take(current));
808 *current_width = 0;
809 }
810 current.push(ch);
811 *current_width += char_width;
812 }
813 }
814
815 #[cfg(test)]
816 mod tests {
817 use super::*;
818
819 fn line_text(line: &Line<'static>) -> String {
820 line.spans
821 .iter()
822 .map(|span| span.content.as_ref())
823 .collect()
824 }
825
826 fn diff_content_text(line: &Line<'static>) -> Option<String> {
827 line.spans
828 .get(1..)
829 .filter(|rest| !rest.is_empty())
830 .map(|rest| rest.iter().map(|span| span.content.as_ref()).collect())
831 }
832
833 fn emphasised_text(line: &Line<'static>) -> String {
834 line.spans
835 .iter()
836 .filter(|span| span.style.add_modifier.contains(Modifier::REVERSED))
837 .map(|span| span.content.as_ref())
838 .collect()
839 }
840
841 fn rendered_body(diff: &str, width: u16) -> Vec<Line<'static>> {
842 render_diff_body_bounded(diff, width, usize::MAX).lines
843 }
844
845 /// Text between the gutter and the first emphasised span, so a test can
846 /// pin where on the row the emphasis starts.
847 fn text_before_emphasis(line: &Line<'static>) -> String {
848 line.spans
849 .iter()
850 .skip(1)
851 .take_while(|span| !span.style.add_modifier.contains(Modifier::REVERSED))
852 .map(|span| span.content.as_ref())
853 .collect()
854 }
855
856 fn emphasis_per_row(diff: &str, width: u16) -> Vec<String> {
857 rendered_body(diff, width)
858 .iter()
859 .skip(1) // hunk header
860 .map(emphasised_text)
861 .collect()
862 }
863
864 #[test]
865 fn replaced_line_pair_emphasises_only_the_changed_words() {
866 let diff = "\
867 @@ -1,1 +1,1 @@
868 - let total = price * quantity;
869 + let total = price * count;
870 ";
871 let rendered = rendered_body(diff, 80);
872 assert_eq!(emphasis_per_row(diff, 80), vec!["quantity", "count"]);
873 let content = rendered
874 .iter()
875 .filter_map(diff_content_text)
876 .collect::<Vec<_>>();
877 assert_eq!(
878 content,
879 vec![
880 " let total = price * quantity;".to_string(),
881 " let total = price * count;".to_string()
882 ],
883 "emphasis must not alter the line text"
884 );
885 }
886
887 #[test]
888 fn every_pair_in_a_replaced_run_is_emphasised_in_order() {
889 let diff = "\
890 @@ -1,2 +1,2 @@
891 -let alpha = 1;
892 -let beta = 2;
893 +let alpha = 10;
894 +let beta = 20;
895 ";
896 assert_eq!(emphasis_per_row(diff, 80), vec!["1", "2", "10", "20"]);
897 }
898
899 #[test]
900 fn a_rewrite_inside_a_replaced_run_stays_plain_while_its_neighbour_is_emphasised() {
901 let diff = "\
902 @@ -1,2 +1,2 @@
903 -let x = 1;
904 -fn old_name() {}
905 +let x = 2;
906 +return None;
907 ";
908 assert_eq!(emphasis_per_row(diff, 80), vec!["1", "", "2", ""]);
909 }
910
911 #[test]
912 fn lines_sharing_only_syntax_are_not_emphasised() {
913 // Whitespace, `=` and `;` agree; every word differs but `let`.
914 let diff = "\
915 @@ -1,1 +1,1 @@
916 -let alpha = beta;
917 +let gamma = delta;
918 ";
919 assert_eq!(emphasis_per_row(diff, 80), vec!["", ""]);
920 }
921
922 #[test]
923 fn unequal_runs_render_without_emphasis() {
924 let unequal = "\
925 @@ -1,2 +1,1 @@
926 -let a = 1;
927 -let b = 2;
928 +let a = 1; let b = 2;
929 ";
930 assert_eq!(emphasis_per_row(unequal, 80), vec!["", "", ""]);
931 }
932
933 #[test]
934 fn emphasis_follows_unicode_word_boundaries() {
935 let diff = "\
936 @@ -1,1 +1,1 @@
937 -café au lait, naïve
938 +café au thé, naïve
939 ";
940 assert_eq!(emphasis_per_row(diff, 80), vec!["lait", "thé"]);
941 }
942
943 #[test]
944 fn no_newline_marker_does_not_break_pairing() {
945 let diff = "\
946 @@ -1,1 +1,1 @@
947 -old line
948 \\ No newline at end of file
949 +new line
950 \\ No newline at end of file
951 ";
952 let rendered = rendered_body(diff, 80);
953 let rows: Vec<String> = rendered.iter().map(emphasised_text).collect();
954 assert_eq!(rows, vec!["", "old", "", "new", ""]);
955 let text: Vec<String> = rendered.iter().map(line_text).collect();
956 assert_eq!(
957 text.iter()
958 .filter(|row| row.contains("No newline at end of file"))
959 .count(),
960 2,
961 "markers are still shown: {text:?}"
962 );
963 }
964
965 #[test]
966 fn pure_insertion_no_newline_marker_is_a_header_row() {
967 let diff = "\
968 @@ -1,0 +1,1 @@
969 +inserted
970 \\ No newline at end of file
971 context
972 ";
973 let rendered = rendered_body(diff, 80);
974 let marker = rendered
975 .iter()
976 .find(|line| line_text(line).contains("No newline at end of file"))
977 .expect("marker row");
978 assert!(marker.spans[0].style.add_modifier.contains(Modifier::BOLD));
979
980 let context = rendered
981 .iter()
982 .find(|line| line_text(line).contains("context"))
983 .expect("context row");
984 assert!(
985 line_text(context).starts_with(" 1 2 "),
986 "context numbering was changed by the marker: {context:?}"
987 );
988 }
989
990 #[test]
991 fn emphasis_lands_on_the_right_chunk_of_a_wrapped_indented_line() {
992 // Gutter is 12 columns; width 40 leaves 28 for text. With the 8-space
993 // indent re-inserted on every continuation chunk, the line wraps as
994 // " let total =" / " compute(alpha, beta," /
995 // " gamma, quantity);" — the change sits on the third chunk.
996 let diff = "\
997 @@ -1,1 +1,1 @@
998 - let total = compute(alpha, beta, gamma, quantity);
999 + let total = compute(alpha, beta, gamma, count);
1000 ";
1001 let rendered = rendered_body(diff, 40);
1002 let rows: Vec<String> = rendered.iter().map(emphasised_text).collect();
1003 assert_eq!(rows, vec!["", "", "", "quantity", "", "", "count"]);
1004 assert_eq!(
1005 diff_content_text(&rendered[3]).as_deref(),
1006 Some(" gamma, quantity);")
1007 );
1008 assert_eq!(text_before_emphasis(&rendered[3]), " gamma, ");
1009 assert_eq!(text_before_emphasis(&rendered[6]), " gamma, ");
1010 // Row text is untouched by the emphasis.
1011 assert_eq!(
1012 diff_content_text(&rendered[6]).as_deref(),
1013 Some(" gamma, count);")
1014 );
1015 }
1016
1017 #[test]
1018 fn emphasis_survives_wrapping_without_changing_text() {
1019 let diff = "\
1020 @@ -1,1 +1,1 @@
1021 -alpha beta gamma delta epsilon zeta eta theta iota kappa
1022 +alpha beta gamma delta epsilon zeta eta THETA iota kappa
1023 ";
1024 let rendered = rendered_body(diff, 30);
1025 let rows: Vec<String> = rendered.iter().map(emphasised_text).collect();
1026 // 18 text columns: "alpha beta gamma" / "delta epsilon zeta" /
1027 // "eta theta iota" / "kappa" — the change sits on the third chunk.
1028 assert_eq!(rows, vec!["", "", "", "theta", "", "", "", "THETA", ""]);
1029 assert_eq!(text_before_emphasis(&rendered[3]), "eta ");
1030 assert_eq!(text_before_emphasis(&rendered[7]), "eta ");
1031 // Wrapping drops the space at each break; everything else survives.
1032 let body: String = rendered
1033 .iter()
1034 .skip(1)
1035 .filter_map(diff_content_text)
1036 .collect::<String>()
1037 .split_whitespace()
1038 .collect();
1039 assert_eq!(
1040 body,
1041 "alphabetagammadeltaepsilonzetaetathetaiotakappa\
1042 alphabetagammadeltaepsilonzetaetaTHETAiotakappa"
1043 );
1044 }
1045
1046 #[test]
1047 fn summarizes_multi_file_diff() {
1048 let diff = "\
1049 diff --git a/src/a.rs b/src/a.rs
1050 --- a/src/a.rs
1051 +++ b/src/a.rs
1052 @@ -1,2 +1,3 @@
1053 line
1054 +new
1055 -old
1056 diff --git a/src/b.rs b/src/b.rs
1057 --- a/src/b.rs
1058 +++ b/src/b.rs
1059 @@ -10,0 +11,2 @@
1060 +one
1061 +two
1062 ";
1063
1064 let summaries = summarize_diff(diff);
1065 assert_eq!(summaries.len(), 2);
1066 assert_eq!(summaries[0].path, "src/a.rs");
1067 assert_eq!(summaries[0].added, 1);
1068 assert_eq!(summaries[0].deleted, 1);
1069 assert_eq!(summaries[1].path, "src/b.rs");
1070 assert_eq!(summaries[1].added, 2);
1071 assert_eq!(summaries[1].deleted, 0);
1072 assert_eq!(diff_summary_label(diff).as_deref(), Some("2 files +3 -1"));
1073 }
1074
1075 #[test]
1076 fn render_diff_prepends_summary_and_gutter_markers() {
1077 let diff = "\
1078 diff --git a/src/a.rs b/src/a.rs
1079 --- a/src/a.rs
1080 +++ b/src/a.rs
1081 @@ -1,2 +1,3 @@
1082 line
1083 +new
1084 -old
1085 ";
1086
1087 let rendered = render_diff(diff, 80);
1088 let text = rendered.iter().map(line_text).collect::<Vec<_>>();
1089 assert!(text[0].contains("summary: 1 file, +1 -1, 1 hunk"));
1090 assert!(text.iter().any(|line| line.contains("src/a.rs +1 -1")));
1091 assert!(
1092 text.iter().any(|line| line.contains(" + new")),
1093 "added line should carry + gutter: {text:?}"
1094 );
1095 assert!(
1096 text.iter().any(|line| line.contains(" - old")),
1097 "deleted line should carry - gutter: {text:?}"
1098 );
1099 }
1100
1101 #[test]
1102 fn render_diff_tints_the_gutter_with_the_row() {
1103 let diff = "\
1104 diff --git a/src/a.rs b/src/a.rs
1105 --- a/src/a.rs
1106 +++ b/src/a.rs
1107 @@ -1,2 +1,3 @@
1108 line
1109 +new
1110 -old
1111 ";
1112
1113 let rendered = render_diff(diff, 80);
1114 let gutter_bg = |needle: &str| {
1115 rendered
1116 .iter()
1117 .find(|line| line_text(line).contains(needle))
1118 .expect("diff row renders")
1119 .spans
1120 .first()
1121 .expect("gutter span")
1122 .style
1123 .bg
1124 };
1125 assert_eq!(
1126 gutter_bg("+ new"),
1127 Some(palette::DIFF_ADDED_BG),
1128 "added numbers share the added tint"
1129 );
1130 assert_eq!(
1131 gutter_bg("- old"),
1132 Some(palette::DIFF_DELETED_BG),
1133 "deleted numbers share the deleted tint"
1134 );
1135 assert_eq!(
1136 gutter_bg(" line"),
1137 None,
1138 "context numbers stay on the bare ground"
1139 );
1140 }
1141
1142 #[test]
1143 fn wrap_text_preserves_leading_whitespace_without_extra_space() {
1144 assert_eq!(wrap_text(" let y = 2;", 80), vec![" let y = 2;"]);
1145 assert_eq!(
1146 wrap_text(" println!(\"hello\");", 80),
1147 vec![" println!(\"hello\");"]
1148 );
1149 }
1150
1151 #[test]
1152 fn render_diff_preserves_leading_whitespace_exactly() {
1153 let diff = "\
1154 diff --git a/src/lib.rs b/src/lib.rs
1155 --- a/src/lib.rs
1156 +++ b/src/lib.rs
1157 @@ -1,2 +1,3 @@
1158 fn main() {
1159 + let y = 2;
1160 + println!(\"{y}\");
1161 }
1162 ";
1163
1164 let rendered = render_diff(diff, 80);
1165 let content = rendered
1166 .iter()
1167 .filter_map(diff_content_text)
1168 .collect::<Vec<_>>();
1169
1170 assert!(
1171 content.iter().any(|line| line == " let y = 2;"),
1172 "added line should keep exact 4-space indent: {content:?}"
1173 );
1174 assert!(
1175 content
1176 .iter()
1177 .any(|line| line == " println!(\"{y}\");"),
1178 "added line should keep exact 8-space indent: {content:?}"
1179 );
1180 }
1181
1182 #[test]
1183 fn wrap_text_breaks_overlong_cjk_runs() {
1184 let text = "这是一个非常长的中文字符串".repeat(10);
1185 let lines = wrap_text(&text, 16);
1186
1187 for line in &lines {
1188 assert!(line.width() <= 16, "line {line:?} exceeds width 16");
1189 }
1190
1191 assert_eq!(lines.join(""), text);
1192 }
1193
1194 #[test]
1195 fn bounded_body_retains_only_budget_and_counts_wrapped_omissions() {
1196 let mut diff = String::from(
1197 "diff --git a/src/generated.rs b/src/generated.rs\n\
1198 --- a/src/generated.rs\n\
1199 +++ b/src/generated.rs\n\
1200 @@ -1,0 +1,3000 @@\n",
1201 );
1202 for index in 0..3_000 {
1203 use std::fmt::Write as _;
1204 writeln!(
1205 diff,
1206 "+ generated_{index:04} = a deliberately long value that wraps narrowly"
1207 )
1208 .expect("append generated diff");
1209 }
1210
1211 let full_row_count = render_diff_body_bounded(&diff, 32, usize::MAX).lines.len();
1212 let rendered = render_diff_body_bounded(&diff, 32, 14);
1213
1214 assert_eq!(rendered.lines.len(), 14);
1215 assert_eq!(
1216 rendered.omitted_rows,
1217 full_row_count.saturating_sub(rendered.lines.len())
1218 );
1219 let retained = rendered.lines.iter().map(line_text).collect::<Vec<_>>();
1220 assert!(
1221 retained
1222 .iter()
1223 .any(|line| line.contains("@@ -1,0 +1,3000 @@"))
1224 );
1225 assert!(
1226 retained
1227 .iter()
1228 .any(|line| line.contains(" + generated_0000")),
1229 "retained rows preserve gutters and leading whitespace: {retained:?}"
1230 );
1231 }
1232 }
1233
1233 lines RUST