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    let mut i = 0;
24    while i < hunk.lines.len() {
25        if kind_at(&hunk.lines, i) != Some(LineKind::Deleted) {
26            i += 1;
27            continue;
28        }
29        let del_start = i;
30        while kind_at(&hunk.lines, i) == Some(LineKind::Deleted) {
31            i += 1;
32        }
33        let add_start = i;
34        while kind_at(&hunk.lines, i) == Some(LineKind::Added) {
35            i += 1;
36        }
37        let pairs = (add_start - del_start).min(i - add_start);
38        for p in 0..pairs {
39            let (del_idx, add_idx) = (del_start + p, add_start + p);
40            let (Some(old), Some(new)) = (hunk.lines.get(del_idx), hunk.lines.get(add_idx)) else {
41                continue;
42            };
43            if similarity(&old.text, &new.text) < MIN_SIMILARITY {
44                continue;
45            }
46            let (old_emphasis, new_emphasis) = intraline(&old.text, &new.text);
47            if let Some(line) = hunk.lines.get_mut(del_idx) {
48                line.emphasis = old_emphasis;
49            }
50            if let Some(line) = hunk.lines.get_mut(add_idx) {
51                line.emphasis = new_emphasis;
52            }
53        }
54    }
55}
56
57fn kind_at(lines: &[DiffLine], i: usize) -> Option<LineKind> {
58    lines.get(i).map(|l| l.kind)
59}
60
61fn similarity(old: &str, new: &str) -> f32 {
62    if old.is_empty() && new.is_empty() {
63        return 1.0;
64    }
65    TextDiff::from_graphemes(old, new).ratio()
66}
67
68#[cfg(test)]
69mod tests {
70    use crate::model::{DiffLine, HunkId, LineKind};
71
72    use super::*;
73
74    fn hunk(lines: Vec<(LineKind, &str)>) -> Hunk {
75        Hunk {
76            id: HunkId("test".into()),
77            old_start: 1,
78            old_lines: 1,
79            new_start: 1,
80            new_lines: 1,
81            context: String::new(),
82            lines: lines
83                .into_iter()
84                .map(|(k, t)| DiffLine::new(k, None, None, t.to_owned()))
85                .collect(),
86        }
87    }
88
89    #[test]
90    fn similar_pair_gets_emphasis_on_both_sides() {
91        let mut h = hunk(vec![
92            (LineKind::Context, "def f():"),
93            (LineKind::Deleted, "    if x < y:"),
94            (LineKind::Added, "    if x <= y:"),
95        ]);
96        enrich_hunk(&mut h);
97        assert!(h.lines[1].emphasis.is_empty()); // deletion side: nothing removed, only insert
98        assert_eq!(h.lines[2].emphasis, vec![10..11]);
99    }
100
101    #[test]
102    fn dissimilar_pair_gets_no_emphasis() {
103        let mut h = hunk(vec![
104            (LineKind::Deleted, "totally_different_thing()"),
105            (LineKind::Added, "x = 1"),
106        ]);
107        enrich_hunk(&mut h);
108        assert!(h.lines[0].emphasis.is_empty());
109        assert!(h.lines[1].emphasis.is_empty());
110    }
111
112    #[test]
113    fn unbalanced_runs_pair_prefix_only() {
114        let mut h = hunk(vec![
115            (LineKind::Deleted, "alpha line one"),
116            (LineKind::Deleted, "beta line two"),
117            (LineKind::Added, "alpha line ONE"),
118        ]);
119        enrich_hunk(&mut h);
120        assert!(!h.lines[2].emphasis.is_empty()); // paired with first deletion
121        assert!(h.lines[1].emphasis.is_empty()); // unpaired deletion untouched
122    }
123
124    #[test]
125    fn separate_runs_pair_independently() {
126        let mut h = hunk(vec![
127            (LineKind::Deleted, "first old line"),
128            (LineKind::Added, "first new line"),
129            (LineKind::Context, "middle"),
130            (LineKind::Deleted, "second old line"),
131            (LineKind::Added, "second new line"),
132        ]);
133        enrich_hunk(&mut h);
134        assert!(!h.lines[0].emphasis.is_empty());
135        assert!(!h.lines[1].emphasis.is_empty());
136        assert!(!h.lines[3].emphasis.is_empty());
137        assert!(!h.lines[4].emphasis.is_empty());
138    }
139}