返回 CodeWhale
diff.rs
1 //! Diff: a diff the caller has already parsed, painted so its meaning never
2 //! depends on color.
3 //!
4 //! The kit does not diff and does not highlight. The caller hands in
5 //! [`DiffLine`]s (or [`parse_unified`] reads unified-diff text into them),
6 //! optionally with intraline [`DiffLine::emphasis`] ranges its own word-diff
7 //! produced and a [`DiffHighlight`] hook from its own highlighter. What the
8 //! kit owns is the paint:
9 //!
10 //! - every changed line carries `+` or `−` (`-` in ASCII) in its own
11 //! column, so nothing depends on a ground;
12 //! - where grounds paint (truecolor, 256 colors) the two tint roles sit
13 //! behind the whole row, under `Foreground`, `Muted` and the line's own hue
14 //! only (the inks the tints are audited for); at 16 colors and `NO_COLOR`
15 //! the tint is dropped and emphasis falls back to bold and underline;
16 //! - long lines are cut with a continuation mark or wrapped (a setting);
17 //! - tabs expand, and control and bidi characters are removed by
18 //! [`text::display_safe`] before anything is measured.
19 //!
20 //! Modelled on the engine's `crates/tui/src/tui/diff_render.rs`
21 //! (`Hmbown/CodeWhale` `58b1dd3dd`).
22
23 use std::{borrow::Cow, ops::Range};
24
25 use ratatui::{
26 buffer::Buffer,
27 layout::Rect,
28 style::{Modifier, Style},
29 text::{Line, Span},
30 widgets::Widget,
31 };
32 use unicode_segmentation::UnicodeSegmentation;
33
34 use crate::{Paint, Role, Theme, glyphs, text};
35
36 /// What a [`DiffLine`] is.
37 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38 pub enum DiffKind {
39 Context,
40 Added,
41 Removed,
42 /// `@@ -12,7 +12,8 @@ fn main()`.
43 HunkHeader,
44 /// `diff --git`, `index`, `---`, `+++`, `rename from`, `Binary files`.
45 FileHeader,
46 /// `\ No newline at end of file`: about the line above it.
47 Note,
48 }
49
50 /// One line of a parsed diff. `text` excludes the leading `+`, `-` or space;
51 /// `emphasis` holds byte ranges into `text` the caller's word-diff marked as
52 /// the part that changed.
53 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
54 pub struct DiffLine<'a> {
55 pub kind: DiffKind,
56 pub old_no: Option<u32>,
57 pub new_no: Option<u32>,
58 pub text: &'a str,
59 pub emphasis: &'a [Range<usize>],
60 }
61
62 impl<'a> DiffLine<'a> {
63 #[must_use]
64 pub const fn new(kind: DiffKind, text: &'a str) -> Self {
65 Self {
66 kind,
67 old_no: None,
68 new_no: None,
69 text,
70 emphasis: &[],
71 }
72 }
73
74 #[must_use]
75 pub const fn context(old_no: u32, new_no: u32, text: &'a str) -> Self {
76 Self {
77 old_no: Some(old_no),
78 new_no: Some(new_no),
79 ..Self::new(DiffKind::Context, text)
80 }
81 }
82
83 #[must_use]
84 pub const fn added(new_no: u32, text: &'a str) -> Self {
85 Self {
86 new_no: Some(new_no),
87 ..Self::new(DiffKind::Added, text)
88 }
89 }
90
91 #[must_use]
92 pub const fn removed(old_no: u32, text: &'a str) -> Self {
93 Self {
94 old_no: Some(old_no),
95 ..Self::new(DiffKind::Removed, text)
96 }
97 }
98
99 #[must_use]
100 pub const fn hunk(text: &'a str) -> Self {
101 Self::new(DiffKind::HunkHeader, text)
102 }
103
104 #[must_use]
105 pub const fn file(text: &'a str) -> Self {
106 Self::new(DiffKind::FileHeader, text)
107 }
108
109 /// The byte ranges of `text` to emphasise.
110 #[must_use]
111 pub const fn emphasis(mut self, emphasis: &'a [Range<usize>]) -> Self {
112 self.emphasis = emphasis;
113 self
114 }
115 }
116
117 /// `(added, removed)` line counts, for a summary beside the diff.
118 #[must_use]
119 pub fn diff_counts(lines: &[DiffLine<'_>]) -> (usize, usize) {
120 let count = |kind| lines.iter().filter(|l| l.kind == kind).count();
121 (count(DiffKind::Added), count(DiffKind::Removed))
122 }
123
124 // ---------------------------------------------------------------------------
125 // Parsing
126 // ---------------------------------------------------------------------------
127
128 /// The numbers of a hunk header: `(old_start, old_len, new_start, new_len)`.
129 fn hunk_numbers(header: &str) -> Option<(u32, u32, u32, u32)> {
130 let mut parts = header.strip_prefix("@@")?.split_whitespace();
131 let span = |part: &str, sign: char| -> Option<(u32, u32)> {
132 let body = part.strip_prefix(sign)?;
133 let (start, len) = match body.split_once(',') {
134 Some((start, len)) => (start, len),
135 None => (body, "1"),
136 };
137 Some((start.parse().ok()?, len.parse().ok()?))
138 };
139 let (old_start, old_len) = span(parts.next()?, '-')?;
140 let (new_start, new_len) = span(parts.next()?, '+')?;
141 Some((old_start, old_len, new_start, new_len))
142 }
143
144 /// Read unified-diff text (`git diff`, `diff -u`, a patch file) into lines.
145 ///
146 /// Inside a hunk the header's counts decide what a line is, so a removed
147 /// line that reads `-- note` (`--- note` in the diff) is a removed line, not
148 /// a file header. Renames, copies, mode changes and binary notices are
149 /// [`DiffKind::FileHeader`]; `\ No newline at end of file` is
150 /// [`DiffKind::Note`]. CRLF line endings are dropped, blank lines between
151 /// files are skipped, and the result borrows from `diff`.
152 #[must_use]
153 pub fn parse_unified(diff: &str) -> Vec<DiffLine<'_>> {
154 let mut out = Vec::new();
155 let (mut old_left, mut new_left) = (0u32, 0u32);
156 let (mut old_no, mut new_no): (Option<u32>, Option<u32>) = (None, None);
157 for raw in diff.lines() {
158 if raw.starts_with('\\') {
159 out.push(DiffLine::new(DiffKind::Note, raw));
160 continue;
161 }
162 let in_hunk = old_left > 0 || new_left > 0;
163 let header = !in_hunk && (raw.starts_with("--- ") || raw.starts_with("+++ "));
164 if !header && !raw.starts_with("@@") && !raw.starts_with("diff ") {
165 let body = match raw.as_bytes().first() {
166 Some(b'+') => Some((DiffKind::Added, &raw[1..])),
167 Some(b'-') => Some((DiffKind::Removed, &raw[1..])),
168 Some(b' ') => Some((DiffKind::Context, &raw[1..])),
169 // Editors strip the trailing space of an empty context line.
170 None if in_hunk => Some((DiffKind::Context, "")),
171 _ => None,
172 };
173 if let Some((kind, text)) = body {
174 let (old, new) = match kind {
175 DiffKind::Added => (None, new_no),
176 DiffKind::Removed => (old_no, None),
177 _ => (old_no, new_no),
178 };
179 if kind != DiffKind::Added {
180 old_left = old_left.saturating_sub(1);
181 old_no = old_no.map(|n| n.saturating_add(1));
182 }
183 if kind != DiffKind::Removed {
184 new_left = new_left.saturating_sub(1);
185 new_no = new_no.map(|n| n.saturating_add(1));
186 }
187 out.push(DiffLine {
188 kind,
189 old_no: old,
190 new_no: new,
191 text,
192 emphasis: &[],
193 });
194 continue;
195 }
196 }
197 if raw.starts_with("@@") {
198 match hunk_numbers(raw) {
199 Some((old_start, old_len, new_start, new_len)) => {
200 (old_left, new_left) = (old_len, new_len);
201 (old_no, new_no) = (Some(old_start), Some(new_start));
202 }
203 None => {
204 (old_left, new_left) = (0, 0);
205 (old_no, new_no) = (None, None);
206 }
207 }
208 out.push(DiffLine::hunk(raw));
209 } else if raw.is_empty() {
210 // Spacing between files in a patch series.
211 } else {
212 if raw.starts_with("diff ") {
213 (old_left, new_left) = (0, 0);
214 (old_no, new_no) = (None, None);
215 }
216 out.push(DiffLine::file(raw));
217 }
218 }
219 out
220 }
221
222 // ---------------------------------------------------------------------------
223 // Settings
224 // ---------------------------------------------------------------------------
225
226 /// What a line longer than the room does.
227 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
228 pub enum DiffWrap {
229 /// Cut it and end the row with a continuation mark (`…`).
230 #[default]
231 Truncate,
232 /// Continue it on the next row, under the same gutter.
233 Wrap,
234 }
235
236 /// Which line-number columns to draw.
237 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
238 pub enum DiffGutter {
239 /// Old and new numbers side by side.
240 Both,
241 /// One number: the old for a removed line, the new for the rest.
242 Single,
243 /// No numbers; the sign column stays.
244 Off,
245 }
246
247 /// A caller's syntax highlighter for one line: byte ranges of the line's
248 /// text and the [`Role`] to ink each with. On changed lines under a tint only
249 /// `Foreground` and `Muted` are honoured, the inks the tints are audited
250 /// for; everything else falls back to `Foreground`.
251 pub type DiffHighlight = fn(&str) -> Vec<(Range<usize>, Role)>;
252
253 /// Words a diff prints. `Default` is English.
254 #[derive(Clone, Debug, PartialEq, Eq)]
255 pub struct DiffWords {
256 /// `+3 more`: lines that did not fit, counted exactly.
257 pub more: Cow<'static, str>,
258 }
259
260 impl Default for DiffWords {
261 fn default() -> Self {
262 Self {
263 more: Cow::Borrowed("more"),
264 }
265 }
266 }
267
268 /// A parsed diff, ready to paint.
269 #[derive(Clone, Debug)]
270 pub struct Diff<'a> {
271 pub lines: Vec<DiffLine<'a>>,
272 pub wrap: DiffWrap,
273 /// Spaces per tab stop.
274 pub tab_width: u8,
275 /// `None` picks by width: both numbers from 56 columns, one below. A
276 /// gutter that does not fit gives way (both numbers, one, none) so the
277 /// `+`/`-` sign always shows, down to a single column.
278 pub gutter: Option<DiffGutter>,
279 pub highlight: Option<DiffHighlight>,
280 /// The first line to show: the host's scroll position.
281 pub scroll: usize,
282 pub words: DiffWords,
283 }
284
285 impl<'a> Diff<'a> {
286 #[must_use]
287 pub fn new(lines: Vec<DiffLine<'a>>) -> Self {
288 Self {
289 lines,
290 wrap: DiffWrap::default(),
291 tab_width: 4,
292 gutter: None,
293 highlight: None,
294 scroll: 0,
295 words: DiffWords::default(),
296 }
297 }
298
299 #[must_use]
300 pub fn wrap(mut self, wrap: DiffWrap) -> Self {
301 self.wrap = wrap;
302 self
303 }
304
305 #[must_use]
306 pub fn tab_width(mut self, tab_width: u8) -> Self {
307 self.tab_width = tab_width.max(1);
308 self
309 }
310
311 #[must_use]
312 pub fn gutter(mut self, gutter: DiffGutter) -> Self {
313 self.gutter = Some(gutter);
314 self
315 }
316
317 #[must_use]
318 pub fn highlight(mut self, highlight: DiffHighlight) -> Self {
319 self.highlight = Some(highlight);
320 self
321 }
322
323 #[must_use]
324 pub fn scroll(mut self, scroll: usize) -> Self {
325 self.scroll = scroll;
326 self
327 }
328
329 #[must_use]
330 pub fn words(mut self, words: DiffWords) -> Self {
331 self.words = words;
332 self
333 }
334 }
335
336 // ---------------------------------------------------------------------------
337 // Layout
338 // ---------------------------------------------------------------------------
339
340 /// One displayed grapheme: tabs already expanded, controls already gone.
341 struct Piece {
342 text: String,
343 width: usize,
344 /// Byte offset of the source grapheme in the line's `text`.
345 at: usize,
346 }
347
348 /// `text` as pieces. Offsets stay those of the original string, so a
349 /// caller's emphasis and highlight ranges still land on the right cells
350 /// after control characters are removed and tabs are widened.
351 fn pieces(text: &str, tab_width: usize) -> Vec<Piece> {
352 let mut out = Vec::new();
353 let mut col = 0;
354 for (at, g) in text.grapheme_indices(true) {
355 if g == "\t" {
356 let n = tab_width - col % tab_width;
357 out.extend((0..n).map(|_| Piece {
358 text: " ".to_string(),
359 width: 1,
360 at,
361 }));
362 col += n;
363 continue;
364 }
365 let safe = text::display_safe(g);
366 if safe.is_empty() {
367 continue;
368 }
369 let width = text::width(&safe);
370 col += width;
371 out.push(Piece {
372 text: safe.into_owned(),
373 width,
374 at,
375 });
376 }
377 out
378 }
379
380 /// The cut points of `pieces` for rows `avail` wide: `(start, end, cut)`,
381 /// where `cut` means the row ends in the continuation mark.
382 fn split_rows(
383 pieces: &[Piece],
384 avail: usize,
385 wrap: DiffWrap,
386 mark_w: usize,
387 ) -> Vec<(usize, usize, bool)> {
388 let avail = avail.max(1);
389 let total: usize = pieces.iter().map(|p| p.width).sum();
390 if total <= avail {
391 return vec![(0, pieces.len(), false)];
392 }
393 let take = |from: usize, budget: usize| -> usize {
394 let (mut used, mut end) = (0, from);
395 while end < pieces.len() && (used + pieces[end].width <= budget || end == from) {
396 used += pieces[end].width;
397 end += 1;
398 }
399 end
400 };
401 match wrap {
402 DiffWrap::Truncate => {
403 let end = take(0, avail.saturating_sub(mark_w));
404 vec![(0, end, true)]
405 }
406 DiffWrap::Wrap => {
407 let mut rows = Vec::new();
408 let mut start = 0;
409 while start < pieces.len() {
410 let end = take(start, avail);
411 rows.push((start, end, false));
412 start = end;
413 }
414 rows
415 }
416 }
417 }
418
419 /// A row ready to paint.
420 struct Row {
421 /// Index into the diff's lines.
422 line: usize,
423 spans: Vec<Span<'static>>,
424 tint: Style,
425 }
426
427 fn digits(n: u32) -> usize {
428 n.max(1).ilog10() as usize + 1
429 }
430
431 /// Cells the number columns take, with the space after each.
432 fn numbers_width(gutter: DiffGutter, nw: usize) -> usize {
433 match gutter {
434 DiffGutter::Both => 2 * nw + 2,
435 DiffGutter::Single => nw + 1,
436 DiffGutter::Off => 0,
437 }
438 }
439
440 /// The richest gutter that leaves the sign, the space after it and a cell of
441 /// text in `width` columns, and whether the space fits. Numbers are shed
442 /// first (`Both`, then `Single`, then none); the sign is the last thing to go,
443 /// so a changed line says what it is at any width.
444 fn fit_gutter(wanted: DiffGutter, nw: usize, width: usize) -> (DiffGutter, bool) {
445 let ladder = match wanted {
446 DiffGutter::Both => &[DiffGutter::Both, DiffGutter::Single, DiffGutter::Off][..],
447 DiffGutter::Single => &[DiffGutter::Single, DiffGutter::Off][..],
448 DiffGutter::Off => &[DiffGutter::Off][..],
449 };
450 match ladder.iter().find(|g| numbers_width(**g, nw) + 3 <= width) {
451 Some(g) => (*g, true),
452 None => (DiffGutter::Off, false),
453 }
454 }
455
456 impl Diff<'_> {
457 fn number_width(&self) -> usize {
458 let top = self
459 .lines
460 .iter()
461 .flat_map(|l| [l.old_no, l.new_no])
462 .flatten()
463 .max()
464 .unwrap_or(0);
465 digits(top).max(2)
466 }
467
468 fn rows(&self, width: u16, theme: &Theme) -> Vec<Row> {
469 let ascii = theme.ascii();
470 let width = usize::from(width);
471 let gutter = self.gutter.unwrap_or(if width >= 56 {
472 DiffGutter::Both
473 } else {
474 DiffGutter::Single
475 });
476 let nw = self.number_width();
477 let (gutter, gap) = fit_gutter(gutter, nw, width);
478 // Number columns, the sign, and the space after it when there is room.
479 let gutter_w = numbers_width(gutter, nw) + 1 + usize::from(gap);
480 let mut rows = Vec::new();
481 for (index, line) in self.lines.iter().enumerate().skip(self.scroll) {
482 let changed = matches!(line.kind, DiffKind::Added | DiffKind::Removed);
483 let tint = match line.kind {
484 DiffKind::Added => theme.bg(Role::DiffAddedTint),
485 DiffKind::Removed => theme.bg(Role::DiffRemovedTint),
486 _ => Style::default(),
487 };
488 let tinted = changed && tint.bg.is_some();
489 let hue = match line.kind {
490 DiffKind::Added => Role::Live,
491 DiffKind::Removed => Role::Danger,
492 _ => Role::Foreground,
493 };
494 let sign = match line.kind {
495 DiffKind::Added => "+",
496 DiffKind::Removed if ascii => "-",
497 DiffKind::Removed => "\u{2212}",
498 _ => " ",
499 };
500 let own_gutter = matches!(
501 line.kind,
502 DiffKind::Context | DiffKind::Added | DiffKind::Removed
503 );
504 let prefix_w = if own_gutter || line.kind == DiffKind::Note {
505 gutter_w
506 } else {
507 0
508 };
509 // Zero when even the sign takes the whole row: no body is shown.
510 let avail = width.saturating_sub(prefix_w);
511 let all = pieces(line.text, usize::from(self.tab_width.max(1)));
512 // The one ellipsis; ASCII spells it out where there is room.
513 let mark = match (ascii, avail) {
514 (false, _) => glyphs::ELLIPSIS,
515 (true, 4..) => "...",
516 (true, _) => ".",
517 };
518 let mark_w = text::width(mark);
519 let cuts = if avail == 0 {
520 vec![(0, 0, false)]
521 } else {
522 split_rows(&all, avail, self.wrap, mark_w)
523 };
524 let highlights = match (self.highlight, own_gutter) {
525 (Some(highlight), true) => highlight(line.text),
526 _ => Vec::new(),
527 };
528 for (n, (start, end, cut)) in cuts.iter().enumerate() {
529 let mut spans: Vec<Span<'static>> = Vec::new();
530 let number = |v: Option<u32>| match (n, v) {
531 (0, Some(v)) => format!("{v:>nw$}"),
532 _ => " ".repeat(nw),
533 };
534 if own_gutter {
535 let (old, new) = (number(line.old_no), number(line.new_no));
536 let shown = match gutter {
537 DiffGutter::Both => format!("{old} {new} "),
538 DiffGutter::Single => {
539 let one = if line.kind == DiffKind::Removed {
540 old
541 } else {
542 number(line.new_no.or(line.old_no))
543 };
544 format!("{one} ")
545 }
546 DiffGutter::Off => String::new(),
547 };
548 spans.push(Span::styled(shown, theme.fg(Role::Muted)));
549 let sign_style = if changed {
550 theme.fg(hue).add_modifier(Modifier::BOLD)
551 } else {
552 theme.fg(Role::Foreground)
553 };
554 spans.push(Span::styled(sign.to_string(), sign_style));
555 if gap {
556 spans.push(Span::raw(" "));
557 }
558 } else if line.kind == DiffKind::Note {
559 spans.push(Span::raw(" ".repeat(gutter_w)));
560 }
561 let (base_role, base_mod) = match line.kind {
562 DiffKind::FileHeader
563 if line.text.starts_with("diff ") || line.text.starts_with("+++ ") =>
564 {
565 (Role::Foreground, Modifier::BOLD)
566 }
567 DiffKind::FileHeader => (Role::Foreground, Modifier::empty()),
568 DiffKind::HunkHeader => (Role::Muted, Modifier::empty()),
569 DiffKind::Note => (Role::Hint, Modifier::empty()),
570 _ => (Role::Foreground, Modifier::empty()),
571 };
572 let mut run: Option<(Style, String)> = None;
573 for piece in &all[*start..*end] {
574 let emphasised = line.emphasis.iter().any(|r| r.contains(&piece.at));
575 let mut role = highlights
576 .iter()
577 .find(|(r, _)| r.contains(&piece.at))
578 .map(|(_, role)| *role)
579 .filter(|role| !tinted || matches!(role, Role::Foreground | Role::Muted))
580 .unwrap_or(base_role);
581 let mut modifier = base_mod;
582 if emphasised {
583 modifier |= Modifier::BOLD;
584 if tinted || (changed && theme.color(hue).is_some()) {
585 role = hue;
586 }
587 if !tinted {
588 modifier |= Modifier::UNDERLINED;
589 }
590 }
591 let style = theme.fg(role).add_modifier(modifier);
592 match &mut run {
593 Some((s, buf)) if *s == style => buf.push_str(&piece.text),
594 _ => {
595 if let Some((s, buf)) = run.take() {
596 spans.push(Span::styled(buf, s));
597 }
598 run = Some((style, piece.text.clone()));
599 }
600 }
601 }
602 if let Some((s, buf)) = run {
603 spans.push(Span::styled(buf, s));
604 }
605 if *cut {
606 // `Hint` is not audited behind a tint; `Muted` is.
607 let ink = if tinted { Role::Muted } else { Role::Hint };
608 spans.push(Span::styled(mark.to_string(), theme.fg(ink)));
609 }
610 rows.push(Row {
611 line: index,
612 spans,
613 tint: if changed { tint } else { Style::default() },
614 });
615 }
616 }
617 rows
618 }
619 }
620
621 impl Paint for Diff<'_> {
622 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
623 let area = area.intersection(buf.area);
624 if area.is_empty() {
625 return;
626 }
627 let rows = self.rows(area.width, theme);
628 let height = usize::from(area.height);
629 // One row cannot hold both a line and the count of the rest: the
630 // first line wins.
631 let clipped = rows.len() > height && height > 1;
632 let shown = if clipped {
633 height - 1
634 } else {
635 rows.len().min(height)
636 };
637 for (y, row) in rows.iter().take(shown).enumerate() {
638 let rect = Rect::new(area.x, area.y + y as u16, area.width, 1);
639 buf.set_style(rect, row.tint);
640 Line::from(row.spans.clone()).render(rect, buf);
641 }
642 if clipped {
643 // Lines with a row that is not on screen, counted once each: a
644 // wrapped line cut off part-way is not fully shown either.
645 let mut hidden_lines: Vec<usize> = rows.iter().skip(shown).map(|r| r.line).collect();
646 hidden_lines.dedup();
647 let more = format!(
648 "+{} {}",
649 hidden_lines.len(),
650 text::display_safe(&self.words.more)
651 );
652 let more = text::truncate(&more, usize::from(area.width), theme.ascii());
653 let rect = Rect::new(area.x, area.y + shown as u16, area.width, 1);
654 Line::from(Span::styled(more.into_owned(), theme.fg(Role::Muted))).render(rect, buf);
655 }
656 }
657
658 fn height(&self, width: u16, theme: &Theme) -> u16 {
659 u16::try_from(self.rows(width, theme).len()).unwrap_or(u16::MAX)
660 }
661 }
662
662 lines RUST