Skip to main content

grit_lib/diff/
detail.rs

1//! Per-file diff detail levels (patch, stat, name-only) and aggregate totals.
2
3use super::options::{DiffAlgorithm, DiffOptions};
4use super::structured::StructuredFileDiff;
5use super::{count_changes_with_algorithm, DiffEntry, DiffStatus};
6
7/// Count insertions and deletions between two text sides using [`DiffOptions`]'s algorithm.
8///
9/// Does not allocate hunk structures (used for `--stat` / `--numstat`). Myers
10/// comparisons trim identical boundary lines before allocating line tokens, so
11/// small edits in large files need tokens only for the changed region.
12#[must_use]
13pub fn line_change_counts(old_content: &str, new_content: &str, options: &DiffOptions) -> LineStat {
14    let (old_content, new_content) = if options.algorithm == DiffAlgorithm::Myers {
15        changed_region(old_content, new_content)
16    } else {
17        (old_content, new_content)
18    };
19    if options.algorithm == DiffAlgorithm::Myers
20        && !old_content.as_bytes().contains(&b'\r')
21        && !new_content.as_bytes().contains(&b'\r')
22    {
23        return myers_lf_counts(old_content, new_content);
24    }
25    let (insertions, deletions) = count_changes_with_algorithm(
26        old_content,
27        new_content,
28        options.algorithm.similar_algorithm(),
29        options.algorithm.uses_git_histogram(),
30    );
31    LineStat {
32        insertions,
33        deletions,
34    }
35}
36
37fn myers_lf_counts(old: &str, new: &str) -> LineStat {
38    if old.is_empty() || new.is_empty() {
39        return LineStat {
40            insertions: new.split_inclusive('\n').count(),
41            deletions: old.split_inclusive('\n').count(),
42        };
43    }
44    // Borrow line slices and use the same compact interner as the patch engine.
45    // The text adapter otherwise scans Unicode characters and constructs a much
46    // larger comparison table before Myers can run. CR-containing input keeps
47    // the text adapter's special CR/CRLF tokenization above.
48    let (old_ids, new_ids) = {
49        let old_lines: Vec<_> = old.split_inclusive('\n').collect();
50        let new_lines: Vec<_> = new.split_inclusive('\n').collect();
51        let mut interner =
52            crate::line_interner::LineInterner::with_capacity(old_lines.len() + new_lines.len());
53        (
54            interner.intern_all(&old_lines),
55            interner.intern_all(&new_lines),
56        )
57    };
58    let mut stat = LineStat::default();
59    for op in similar::capture_diff_slices(similar::Algorithm::Myers, &old_ids, &new_ids) {
60        match op {
61            similar::DiffOp::Delete { old_len, .. } => stat.deletions += old_len,
62            similar::DiffOp::Insert { new_len, .. } => stat.insertions += new_len,
63            similar::DiffOp::Replace {
64                old_len, new_len, ..
65            } => {
66                stat.deletions += old_len;
67                stat.insertions += new_len;
68            }
69            similar::DiffOp::Equal { .. } => {}
70        }
71    }
72    stat
73}
74
75// Myers already discards equal boundary tokens. Do that before tokenization as
76// well, without allocating a token or comparison-table entry for every line.
77// Keep delimiters in the comparison so an EOF newline change remains visible.
78fn changed_region<'a>(old: &'a str, new: &'a str) -> (&'a str, &'a str) {
79    let prefix: usize = old
80        .split_inclusive('\n')
81        .zip(new.split_inclusive('\n'))
82        .take_while(|(a, b)| a == b)
83        .map(|(line, _)| line.len())
84        .sum();
85    let (old, new) = (&old[prefix..], &new[prefix..]);
86    let suffix: usize = old
87        .split_inclusive('\n')
88        .rev()
89        .zip(new.split_inclusive('\n').rev())
90        .take_while(|(a, b)| a == b)
91        .map(|(line, _)| line.len())
92        .sum();
93    (&old[..old.len() - suffix], &new[..new.len() - suffix])
94}
95
96/// How much content to compute for each changed file.
97#[non_exhaustive]
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
99pub enum DiffDetail {
100    /// Full structured line hunks.
101    #[default]
102    Patch,
103    /// Insertion/deletion counts only (`--stat`, `--numstat`, `--shortstat`).
104    Stat,
105    /// `git diff --name-status` (status letter and paths).
106    NameStatus,
107    /// `git diff --name-only` (paths only).
108    NameOnly,
109}
110
111/// Line insertion/deletion counts for one file (`--numstat`).
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113pub struct LineStat {
114    /// Lines added on the new side.
115    pub insertions: usize,
116    /// Lines removed from the old side.
117    pub deletions: usize,
118}
119
120/// Summary across all files in a stat-style diff.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub struct DiffTotals {
123    /// Number of changed paths.
124    pub files_changed: usize,
125    /// Total insertions across text files.
126    pub insertions: usize,
127    /// Total deletions across text files.
128    pub deletions: usize,
129}
130
131impl DiffTotals {
132    /// Sum per-file stats into totals (binary files contribute to `files_changed` only).
133    #[must_use]
134    pub fn from_files(files: &[FileDiff]) -> Self {
135        let mut insertions = 0usize;
136        let mut deletions = 0usize;
137        for file in files {
138            if let Some(stat) = file.stat {
139                insertions += stat.insertions;
140                deletions += stat.deletions;
141            }
142        }
143        Self {
144            files_changed: files.len(),
145            insertions,
146            deletions,
147        }
148    }
149}
150
151/// One changed file with optional patch body or line counts.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct FileDiff {
154    /// Display path (new side, or old for deletions).
155    pub path: String,
156    /// Pre-rename path when it differs from [`Self::path`].
157    pub old_path: Option<String>,
158    /// Change kind.
159    pub status: DiffStatus,
160    /// Old git mode (octal), when present.
161    pub old_mode: Option<String>,
162    /// New git mode (octal), when present.
163    pub new_mode: Option<String>,
164    /// Either side looks like a binary blob.
165    pub binary: bool,
166    /// Rename/copy similarity (0–100), when applicable.
167    pub similarity: Option<u32>,
168    /// Line counts when [`DiffDetail::Stat`] was requested.
169    pub stat: Option<LineStat>,
170    /// Structured hunks when [`DiffDetail::Patch`] was requested.
171    pub structured: Option<StructuredFileDiff>,
172    /// Combined (`--cc`) hunks for merge commits; mutually exclusive with [`Self::structured`].
173    pub combined: Option<crate::combined_diff_patch::CombinedStructuredTextDiff>,
174    /// Per-parent status letters for combined merge name-status (e.g. `MM`, `DD`).
175    pub combined_status: Option<String>,
176}
177
178impl DiffEntry {
179    /// Similarity index for renames and copies (`DiffEntry::score`).
180    #[must_use]
181    pub fn rename_similarity(&self) -> Option<u32> {
182        match self.status {
183            DiffStatus::Renamed | DiffStatus::Copied => self.score,
184            _ => None,
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    fn reference_counts(old: &str, new: &str) -> LineStat {
194        let (insertions, deletions) =
195            count_changes_with_algorithm(old, new, similar::Algorithm::Myers, false);
196        LineStat {
197            insertions,
198            deletions,
199        }
200    }
201
202    #[test]
203    fn myers_stat_preserves_line_boundaries_and_repeated_lines() {
204        let options = DiffOptions::default();
205        let texts = [
206            "",
207            "a",
208            "a\n",
209            "a\na",
210            "a\na\n",
211            "a\nb\na\n",
212            "b\na\nb\n",
213            "\n",
214            "\n\n",
215            "a\r",
216            "a\r\n",
217            "a\rb\n",
218            "é\n猫",
219            "é\n猫\n",
220            "a\n\r\nb\n",
221        ];
222        for old in texts {
223            for new in texts {
224                assert_eq!(
225                    line_change_counts(old, new, &options),
226                    reference_counts(old, new),
227                    "old={old:?}, new={new:?}"
228                );
229            }
230        }
231
232        // Cross the line-interning threshold with repeated lines and edits at
233        // different offsets, including insertions beside identical boundary lines.
234        let mut seed = 4687u64;
235        for iteration in 0..500 {
236            let mut text = || {
237                (0..160)
238                    .map(|_| {
239                        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
240                        match seed >> 61 {
241                            0..=3 => "repeat\n",
242                            4 => "é\r\n",
243                            5 => "\n",
244                            6 => "x\ry\n",
245                            _ => "tail\n",
246                        }
247                    })
248                    .collect::<String>()
249            };
250            let (mut old, mut new) = (text(), text());
251            if iteration % 2 == 0 {
252                old = old.replace('\r', "");
253                new = new.replace('\r', "");
254            }
255            let prefix = "unchanged\n".repeat(120);
256            let old = format!("{prefix}{old}{prefix}");
257            let new = format!("{prefix}{new}{prefix}");
258            assert_eq!(
259                line_change_counts(&old, &new, &options),
260                reference_counts(&old, &new)
261            );
262        }
263    }
264
265    #[test]
266    fn myers_stat_large_file_small_edit() {
267        let prefix = "unchanged prefix\n".repeat(10_000);
268        let suffix = "unchanged suffix\n".repeat(10_000);
269        let old = format!("{prefix}old\n{suffix}");
270        let new = format!("{prefix}new\nextra\n{suffix}");
271        assert_eq!(changed_region(&old, &new), ("old\n", "new\nextra\n"));
272        assert_eq!(
273            line_change_counts(&old, &new, &DiffOptions::default()),
274            LineStat {
275                insertions: 2,
276                deletions: 1,
277            }
278        );
279        assert_eq!(changed_region(&old, &old), ("", ""));
280    }
281}