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