Skip to main content

diffler_core/
pairing.rs

1//! Pair deleted/added line runs inside a hunk and attach intra-line
2//! emphasis. Lines pair positionally within a run, gated by similarity,
3//! mirroring delta's homologous-line model.
4
5use similar::TextDiff;
6
7use crate::diff::intraline;
8use crate::model::{DiffLine, FileDiff, Hunk, LineKind};
9
10/// Below this similarity the pair is treated as unrelated (no emphasis).
11const MIN_SIMILARITY: f32 = 0.4;
12
13/// Attach intra-line emphasis to one file's hunks. Pairing is a render-time
14/// concern (only the TUI reads `.emphasis`), so callers enrich the file they
15/// are about to display rather than enriching whole models up front.
16pub fn enrich_file(file: &mut FileDiff) {
17    for hunk in &mut file.hunks {
18        enrich_hunk(hunk);
19    }
20}
21
22fn enrich_hunk(hunk: &mut Hunk) {
23    for (del_idx, add_idx) in paired_run_indices(&hunk.lines) {
24        let (Some(old), Some(new)) = (hunk.lines.get(del_idx), hunk.lines.get(add_idx)) else {
25            continue;
26        };
27        if similarity(&old.text, &new.text) < MIN_SIMILARITY {
28            continue;
29        }
30        let (old_emphasis, new_emphasis) = intraline(&old.text, &new.text);
31        if let Some(line) = hunk.lines.get_mut(del_idx) {
32            line.emphasis = old_emphasis;
33        }
34        if let Some(line) = hunk.lines.get_mut(add_idx) {
35            line.emphasis = new_emphasis;
36        }
37    }
38}
39
40/// `(deleted, added)` index pairs for a hunk's del/add runs, paired
41/// positionally within each run — the shared homologous-line model.
42pub(crate) fn paired_run_indices(lines: &[DiffLine]) -> Vec<(usize, usize)> {
43    let kind_at = |i: usize| lines.get(i).map(|l| l.kind);
44    let mut pairs = Vec::new();
45    let mut i = 0;
46    while i < lines.len() {
47        if kind_at(i) != Some(LineKind::Deleted) {
48            i += 1;
49            continue;
50        }
51        let del_start = i;
52        while kind_at(i) == Some(LineKind::Deleted) {
53            i += 1;
54        }
55        let add_start = i;
56        while kind_at(i) == Some(LineKind::Added) {
57            i += 1;
58        }
59        for p in 0..(add_start - del_start).min(i - add_start) {
60            pairs.push((del_start + p, add_start + p));
61        }
62    }
63    pairs
64}
65
66fn similarity(old: &str, new: &str) -> f32 {
67    if old.is_empty() && new.is_empty() {
68        return 1.0;
69    }
70    TextDiff::from_graphemes(old, new).ratio()
71}
72
73#[cfg(test)]
74mod tests {
75    use crate::model::{DiffLine, HunkId, LineKind};
76
77    use super::*;
78
79    fn hunk(lines: Vec<(LineKind, &str)>) -> Hunk {
80        Hunk {
81            id: HunkId("test".into()),
82            old_start: 1,
83            old_lines: 1,
84            new_start: 1,
85            new_lines: 1,
86            context: String::new(),
87            lines: lines
88                .into_iter()
89                .map(|(k, t)| DiffLine::new(k, None, None, t.to_owned()))
90                .collect(),
91        }
92    }
93
94    #[test]
95    fn similar_pair_gets_emphasis_on_both_sides() {
96        let mut h = hunk(vec![
97            (LineKind::Context, "def f():"),
98            (LineKind::Deleted, "    if x < y:"),
99            (LineKind::Added, "    if x <= y:"),
100        ]);
101        enrich_hunk(&mut h);
102        assert!(h.lines[1].emphasis.is_empty()); // deletion side: nothing removed, only insert
103        assert_eq!(h.lines[2].emphasis, vec![10..11]);
104    }
105
106    #[test]
107    fn dissimilar_pair_gets_no_emphasis() {
108        let mut h = hunk(vec![
109            (LineKind::Deleted, "totally_different_thing()"),
110            (LineKind::Added, "x = 1"),
111        ]);
112        enrich_hunk(&mut h);
113        assert!(h.lines[0].emphasis.is_empty());
114        assert!(h.lines[1].emphasis.is_empty());
115    }
116
117    #[test]
118    fn unbalanced_runs_pair_prefix_only() {
119        let mut h = hunk(vec![
120            (LineKind::Deleted, "alpha line one"),
121            (LineKind::Deleted, "beta line two"),
122            (LineKind::Added, "alpha line ONE"),
123        ]);
124        enrich_hunk(&mut h);
125        assert!(!h.lines[2].emphasis.is_empty()); // paired with first deletion
126        assert!(h.lines[1].emphasis.is_empty()); // unpaired deletion untouched
127    }
128
129    #[test]
130    fn separate_runs_pair_independently() {
131        let mut h = hunk(vec![
132            (LineKind::Deleted, "first old line"),
133            (LineKind::Added, "first new line"),
134            (LineKind::Context, "middle"),
135            (LineKind::Deleted, "second old line"),
136            (LineKind::Added, "second new line"),
137        ]);
138        enrich_hunk(&mut h);
139        assert!(!h.lines[0].emphasis.is_empty());
140        assert!(!h.lines[1].emphasis.is_empty());
141        assert!(!h.lines[3].emphasis.is_empty());
142        assert!(!h.lines[4].emphasis.is_empty());
143    }
144}