Skip to main content

diffler_core/
diffalgo.rs

1//! Selectable line-diff algorithms, shared by every diff source. `Myers`,
2//! `Minimal` and `Patience` are git2's own (see [`crate::git`]); `Histogram`
3//! and `Structural` (which layers reformat detection on top, in
4//! [`crate::syntax::intraline`]) run through imara-diff, since libgit2 has no
5//! histogram implementation.
6
7use std::collections::HashMap;
8
9use imara_diff::{Algorithm, Diff, InternedInput};
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12use crate::model::{DiffLine, Hunk, HunkId, LineKind, disambiguated_hunk_id};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum DiffAlgorithm {
16    #[default]
17    Myers,
18    Minimal,
19    Patience,
20    Histogram,
21    /// The histogram line diff, plus: a paired deleted/added line that only
22    /// reformats the same tokens renders dimmed, leaving red/green for an
23    /// actual change.
24    Structural,
25}
26
27impl DiffAlgorithm {
28    pub const ALL: [Self; 5] = [
29        Self::Myers,
30        Self::Minimal,
31        Self::Patience,
32        Self::Histogram,
33        Self::Structural,
34    ];
35
36    /// The config and display name, the one spelling serde and [`Self::parse`]
37    /// both read.
38    pub const fn as_str(self) -> &'static str {
39        match self {
40            Self::Myers => "myers",
41            Self::Minimal => "minimal",
42            Self::Patience => "patience",
43            Self::Histogram => "histogram",
44            Self::Structural => "structural",
45        }
46    }
47
48    pub fn parse(value: &str) -> Option<Self> {
49        Self::ALL.into_iter().find(|a| a.as_str() == value)
50    }
51
52    /// Whether this algorithm needs imara-diff, since libgit2 has no
53    /// histogram implementation.
54    pub const fn is_imara(self) -> bool {
55        matches!(self, Self::Histogram | Self::Structural)
56    }
57}
58
59impl std::fmt::Display for DiffAlgorithm {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.write_str(self.as_str())
62    }
63}
64
65impl Serialize for DiffAlgorithm {
66    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
67        serializer.serialize_str(self.as_str())
68    }
69}
70
71impl<'de> Deserialize<'de> for DiffAlgorithm {
72    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
73        let name = String::deserialize(deserializer)?;
74        Self::parse(&name).ok_or_else(|| {
75            let names = Self::ALL.map(Self::as_str).join(", ");
76            serde::de::Error::custom(format!(
77                "unknown diff algorithm `{name}`, expected one of {names}"
78            ))
79        })
80    }
81}
82
83/// The line-diff context, algorithm and indent heuristic every diff source
84/// honours, threaded through construction so the review's long-lived backend
85/// and every worker that opens a fresh one read the same values and can
86/// never drift apart.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct DiffSettings {
89    pub context_lines: u32,
90    pub algorithm: DiffAlgorithm,
91    pub indent_heuristic: bool,
92}
93
94impl Default for DiffSettings {
95    fn default() -> Self {
96        Self {
97            context_lines: crate::git::DEFAULT_CONTEXT_LINES,
98            algorithm: DiffAlgorithm::default(),
99            indent_heuristic: crate::git::DEFAULT_INDENT_HEURISTIC,
100        }
101    }
102}
103
104impl DiffSettings {
105    /// [`Self::default`] with a custom number of context lines.
106    pub fn with_context(context_lines: u32) -> Self {
107        Self {
108            context_lines,
109            ..Self::default()
110        }
111    }
112}
113
114/// Line hunks of `old` vs `new` computed by imara-diff's histogram algorithm,
115/// grouped with `context` unchanged lines around each change the way git
116/// itself merges nearby hunks together (mirrors imara-diff's own
117/// `unified_diff` grouping rule, without needing its text printer).
118/// `file_path` seeds the hunk ids the same way [`crate::git`] does, so
119/// staging can find the hunk it shows regardless of which algorithm produced
120/// it, as long as both read the current algorithm.
121pub fn histogram_hunks(
122    old: &str,
123    new: &str,
124    file_path: &str,
125    context: u32,
126    indent_heuristic: bool,
127) -> Vec<Hunk> {
128    let input = InternedInput::new(old, new);
129    let mut diff = Diff::compute(Algorithm::Histogram, &input);
130    if indent_heuristic {
131        diff.postprocess_lines(&input);
132    } else {
133        diff.postprocess_no_heuristic(&input);
134    }
135
136    let before_len = input.before.len() as u32;
137    let raw: Vec<imara_diff::Hunk> = diff.hunks().collect();
138    if raw.is_empty() {
139        return Vec::new();
140    }
141
142    let two_context = context.saturating_mul(2);
143    let mut groups: Vec<Vec<imara_diff::Hunk>> = Vec::new();
144    let mut pos = 0u32;
145    for hunk in raw {
146        let starts_new = groups.is_empty() || hunk.before.start.saturating_sub(pos) > two_context;
147        if starts_new {
148            groups.push(Vec::new());
149        }
150        pos = hunk.before.end;
151        if let Some(group) = groups.last_mut() {
152            group.push(hunk);
153        }
154    }
155
156    let mut seen: HashMap<HunkId, usize> = HashMap::new();
157    let mut heading = FuncHeading::default();
158    groups
159        .into_iter()
160        .map(|group| {
161            build_hunk(
162                &group,
163                &input,
164                file_path,
165                context,
166                before_len,
167                &mut seen,
168                &mut heading,
169            )
170        })
171        .collect()
172}
173
174fn line_text(input: &InternedInput<&str>, token: imara_diff::Token) -> String {
175    input.interner[token]
176        .trim_end_matches(['\n', '\r'])
177        .to_owned()
178}
179
180/// The hunk heading git2 gives the other algorithms, from libgit2's default
181/// funcname rule: the nearest old-side line above the hunk's first row that
182/// opens with an ASCII letter, `_` or `$`, right-trimmed and cut to 80 bytes.
183/// Hunks arrive in file order, so each one scans only the rows since the
184/// previous hunk's first row and keeps that hunk's heading when none match.
185#[derive(Default)]
186struct FuncHeading {
187    scanned: u32,
188    text: String,
189}
190
191impl FuncHeading {
192    const MAX_BYTES: usize = 80;
193
194    fn above(&mut self, input: &InternedInput<&str>, row: u32) -> String {
195        let found = (self.scanned..row)
196            .rev()
197            .filter_map(|idx| input.before.get(idx as usize))
198            .map(|&token| input.interner[token].trim_end())
199            .find(|line| {
200                line.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_' || c == '$')
201            });
202        if let Some(line) = found {
203            let mut end = line.len().min(Self::MAX_BYTES);
204            while !line.is_char_boundary(end) {
205                end -= 1;
206            }
207            line[..end].trim_end().clone_into(&mut self.text);
208        }
209        self.scanned = self.scanned.max(row);
210        self.text.clone()
211    }
212}
213
214/// Append context lines for the unchanged old-side span `old_from..old_to`,
215/// whose new-side counterpart starts at `new_from`.
216fn push_context_lines(
217    lines: &mut Vec<DiffLine>,
218    input: &InternedInput<&str>,
219    old_from: u32,
220    old_to: u32,
221    new_from: u32,
222) {
223    for offset in 0..old_to.saturating_sub(old_from) {
224        let old_idx = old_from + offset;
225        let new_idx = new_from + offset;
226        let Some(&token) = input.before.get(old_idx as usize) else {
227            continue;
228        };
229        lines.push(DiffLine::new(
230            LineKind::Context,
231            Some(old_idx + 1),
232            Some(new_idx + 1),
233            line_text(input, token),
234        ));
235    }
236}
237
238fn build_hunk(
239    group: &[imara_diff::Hunk],
240    input: &InternedInput<&str>,
241    file_path: &str,
242    context: u32,
243    before_len: u32,
244    seen: &mut HashMap<HunkId, usize>,
245    heading: &mut FuncHeading,
246) -> Hunk {
247    let first = group.first().unwrap_or(&imara_diff::Hunk::NONE);
248    let last = group.last().unwrap_or(&imara_diff::Hunk::NONE);
249
250    let lead_start = first.before.start.saturating_sub(context);
251    let lead_len = first.before.start - lead_start;
252    let after_lead_start = first.after.start.saturating_sub(lead_len);
253    let tail_end = last.before.end.saturating_add(context).min(before_len);
254
255    let mut lines = Vec::new();
256    push_context_lines(
257        &mut lines,
258        input,
259        lead_start,
260        first.before.start,
261        after_lead_start,
262    );
263
264    for (index, hunk) in group.iter().enumerate() {
265        for old_idx in hunk.before.start..hunk.before.end {
266            let Some(&token) = input.before.get(old_idx as usize) else {
267                continue;
268            };
269            lines.push(DiffLine::new(
270                LineKind::Deleted,
271                Some(old_idx + 1),
272                None,
273                line_text(input, token),
274            ));
275        }
276        for new_idx in hunk.after.start..hunk.after.end {
277            let Some(&token) = input.after.get(new_idx as usize) else {
278                continue;
279            };
280            lines.push(DiffLine::new(
281                LineKind::Added,
282                None,
283                Some(new_idx + 1),
284                line_text(input, token),
285            ));
286        }
287        if let Some(next) = group.get(index + 1) {
288            push_context_lines(
289                &mut lines,
290                input,
291                hunk.before.end,
292                next.before.start,
293                hunk.after.end,
294            );
295        }
296    }
297    push_context_lines(&mut lines, input, last.before.end, tail_end, last.after.end);
298
299    let old_lines = lines.iter().filter(|l| l.kind != LineKind::Added).count() as u32;
300    let new_lines = lines.iter().filter(|l| l.kind != LineKind::Deleted).count() as u32;
301    // git's header gives an empty side the line before the change
302    let start = |index: u32, len: u32| if len == 0 { index } else { index + 1 };
303    let id = disambiguated_hunk_id(file_path, &lines, seen);
304    Hunk {
305        id,
306        old_start: start(lead_start, old_lines),
307        old_lines,
308        new_start: start(after_lead_start, new_lines),
309        new_lines,
310        context: heading.above(input, lead_start),
311        lines,
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn algorithm_names_round_trip() {
321        for algo in DiffAlgorithm::ALL {
322            assert_eq!(DiffAlgorithm::parse(algo.as_str()), Some(algo));
323            let json = serde_json::to_string(&algo).expect("serialize");
324            assert_eq!(json, format!("\"{}\"", algo.as_str()));
325            assert_eq!(
326                serde_json::from_str::<DiffAlgorithm>(&json).expect("deserialize"),
327                algo
328            );
329        }
330        assert_eq!(DiffAlgorithm::parse("bogus"), None);
331        assert!(serde_json::from_str::<DiffAlgorithm>("\"bogus\"").is_err());
332    }
333
334    #[test]
335    fn default_algorithm_is_myers() {
336        assert_eq!(DiffAlgorithm::default(), DiffAlgorithm::Myers);
337    }
338
339    #[test]
340    fn single_line_change_yields_one_hunk_with_context() {
341        let old = "a\nb\nc\nd\ne\n";
342        let new = "a\nb\nX\nd\ne\n";
343        let hunks = histogram_hunks(old, new, "f.txt", 1, true);
344        assert_eq!(hunks.len(), 1);
345        let h = &hunks[0];
346        assert_eq!(h.old_start, 2);
347        assert_eq!(h.old_lines, 3);
348        assert_eq!(h.new_start, 2);
349        assert_eq!(h.new_lines, 3);
350        let kinds: Vec<_> = h.lines.iter().map(|l| (l.kind, l.text.as_str())).collect();
351        assert_eq!(
352            kinds,
353            vec![
354                (LineKind::Context, "b"),
355                (LineKind::Deleted, "c"),
356                (LineKind::Added, "X"),
357                (LineKind::Context, "d"),
358            ]
359        );
360    }
361
362    #[test]
363    fn nearby_changes_merge_into_one_hunk() {
364        // one unchanged line between two edits, context 3: 1 < 2*3, so they merge
365        let old = "a\nb\nc\nd\ne\n";
366        let new = "A\nb\nc\nD\ne\n";
367        let hunks = histogram_hunks(old, new, "f.txt", 3, true);
368        assert_eq!(hunks.len(), 1, "the two edits merge: {hunks:?}");
369        assert_eq!(hunks[0].old_start, 1);
370        assert_eq!(hunks[0].old_lines, 5);
371    }
372
373    #[test]
374    fn distant_changes_stay_separate_hunks() {
375        let mut old = vec!["a".to_owned()];
376        old.extend((0..20).map(|i| format!("ctx{i}")));
377        old.push("z".to_owned());
378        let mut new = old.clone();
379        new[0] = "A".to_owned();
380        let last = new.len() - 1;
381        new[last] = "Z".to_owned();
382        let old_text = format!("{}\n", old.join("\n"));
383        let new_text = format!("{}\n", new.join("\n"));
384        let hunks = histogram_hunks(&old_text, &new_text, "f.txt", 3, true);
385        assert_eq!(hunks.len(), 2, "far-apart edits stay separate: {hunks:?}");
386    }
387
388    #[test]
389    fn leading_and_trailing_context_clip_to_file_bounds() {
390        let old = "a\nb\n";
391        let new = "A\nb\n";
392        let hunks = histogram_hunks(old, new, "f.txt", 3, true);
393        assert_eq!(hunks.len(), 1);
394        assert_eq!(hunks[0].old_start, 1);
395        assert_eq!(hunks[0].old_lines, 2);
396    }
397
398    #[test]
399    fn no_context_only_shows_changed_lines() {
400        let old = "a\nb\nc\n";
401        let new = "a\nB\nc\n";
402        let hunks = histogram_hunks(old, new, "f.txt", 0, true);
403        assert_eq!(hunks.len(), 1);
404        let kinds: Vec<_> = hunks[0].lines.iter().map(|l| l.kind).collect();
405        assert_eq!(kinds, vec![LineKind::Deleted, LineKind::Added]);
406    }
407
408    #[test]
409    fn an_empty_side_starts_at_the_line_before_like_git() {
410        let insert = histogram_hunks("a\nb\n", "a\nX\nb\n", "f.txt", 0, true);
411        assert_eq!(
412            (
413                insert[0].old_start,
414                insert[0].old_lines,
415                insert[0].new_start
416            ),
417            (1, 0, 2)
418        );
419        let delete = histogram_hunks("a\nX\nb\n", "a\nb\n", "f.txt", 0, true);
420        assert_eq!(
421            (
422                delete[0].old_start,
423                delete[0].new_start,
424                delete[0].new_lines
425            ),
426            (2, 1, 0)
427        );
428    }
429
430    #[test]
431    fn hunk_ids_are_stable_for_identical_input() {
432        let old = "a\nb\nc\n";
433        let new = "a\nB\nc\n";
434        let first = histogram_hunks(old, new, "f.txt", 1, true);
435        let second = histogram_hunks(old, new, "f.txt", 1, true);
436        assert_eq!(first[0].id, second[0].id);
437    }
438
439    // the hunk_context expectations below match `git diff` on the same input
440
441    #[test]
442    fn hunk_context_finds_the_enclosing_function() {
443        let old = "def parse_config():\n    a = 1\n    b = 2\n    c = 3\n";
444        let new = "def parse_config():\n    a = 1\n    b = 20\n    c = 3\n";
445        let hunks = histogram_hunks(old, new, "f.py", 0, true);
446        assert_eq!(hunks[0].context, "def parse_config():");
447    }
448
449    #[test]
450    fn hunk_context_skips_a_same_indent_sibling() {
451        // `a = 1` sits right above the change at the same indentation as `b = 2`
452        // and starts with a letter too, but git skips it for the def above it.
453        let old = "def parse_config():\n    a = 1\n    b = 2\n";
454        let new = "def parse_config():\n    a = 1\n    b = 20\n";
455        let hunks = histogram_hunks(old, new, "f.py", 0, true);
456        assert_eq!(hunks[0].context, "def parse_config():");
457    }
458
459    #[test]
460    fn hunk_context_climbs_to_the_outermost_scope() {
461        let old = "class Foo:\n    def bar():\n        a = 1\n        b = 2\n";
462        let new = "class Foo:\n    def bar():\n        a = 1\n        b = 20\n";
463        let hunks = histogram_hunks(old, new, "f.py", 0, true);
464        assert_eq!(hunks[0].context, "class Foo:");
465    }
466
467    #[test]
468    fn hunk_context_prefers_the_nearest_top_level_definition() {
469        let old =
470            "def first():\n    pass\n\ndef second():\n    if true:\n        x = 1\n        y = 2\n";
471        let new = "def first():\n    pass\n\ndef second():\n    if true:\n        x = 1\n        y = 20\n";
472        let hunks = histogram_hunks(old, new, "f.py", 0, true);
473        assert_eq!(hunks[0].context, "def second():");
474    }
475
476    #[test]
477    fn hunk_context_for_an_insertion_reads_the_enclosing_function() {
478        let old = "def outer():\n    a = 1\n    b = 2\n";
479        let new = "def outer():\n    a = 1\n    newline = 99\n    b = 2\n";
480        let hunks = histogram_hunks(old, new, "f.py", 0, true);
481        assert_eq!(hunks[0].context, "def outer():");
482    }
483
484    #[test]
485    fn hunk_context_for_a_top_level_change_names_the_definition_above() {
486        let old = "fn top() {\n    1;\n}\n\nfn next() {\n    2;\n}\n";
487        let new = "fn top() {\n    1;\n}\n\nfn renamed() {\n    2;\n}\n";
488        let hunks = histogram_hunks(old, new, "f.rs", 0, true);
489        assert_eq!(hunks[0].context, "fn top() {");
490    }
491
492    #[test]
493    fn hunk_context_reads_above_the_leading_context_lines() {
494        let old = "fn a() {\n    1;\n    2;\n}\n";
495        let new = "fn a() {\n    10;\n    2;\n}\n";
496        let hunks = histogram_hunks(old, new, "f.rs", 3, true);
497        assert_eq!(hunks[0].context, "", "the definition is a context line");
498    }
499
500    #[test]
501    fn a_later_hunk_keeps_the_heading_when_no_definition_lies_between() {
502        let mut old = String::from("fn only() {\n");
503        old.extend((0..20).map(|i| format!("    line{i};\n")));
504        old.push_str("}\n");
505        let new = old
506            .replace("line2;", "LINE2;")
507            .replace("line17;", "LINE17;");
508        let hunks = histogram_hunks(&old, &new, "f.rs", 1, true);
509        let contexts: Vec<_> = hunks.iter().map(|h| h.context.as_str()).collect();
510        assert_eq!(contexts, ["fn only() {", "fn only() {"]);
511    }
512
513    #[test]
514    fn hunk_context_is_cut_to_git_s_80_bytes_on_a_char_boundary() {
515        let name = "é".repeat(60);
516        let old = format!("fn {name}() {{\n    a;\n    b;\n}}\n");
517        let new = old.replace("    b;", "    B;");
518        let hunks = histogram_hunks(&old, &new, "f.rs", 0, true);
519        assert_eq!(hunks[0].context, format!("fn {}", "é".repeat(38)));
520    }
521}