Skip to main content

diffler_core/
pairing.rs

1//! Pair deleted/added line runs by best total similarity (delta's
2//! homologous-line model) and attach intra-line emphasis. Unpaired lines
3//! render plain.
4
5use similar::TextDiff;
6
7use crate::diff::intraline;
8use crate::model::{DiffLine, FileDiff, Hunk, LineKind};
9
10/// Word-level emphasis covers whole tokens (`old_name` → `new_name` is most
11/// of its line), so we set this above one substituted word.
12const MAX_EMPHASIS_SHARE: f32 = 0.7;
13
14/// Counted after near-adjacent runs merge under `diff::MAX_GAP_CHARS`, so we
15/// tune the two together. jj uses the same cap.
16const MAX_EMPHASIS_RUNS: usize = 3;
17
18/// True when the emphasized ranges cover a minority of the line's
19/// non-whitespace content in a few runs.
20pub(crate) fn emphasis_is_punctual(text: &str, ranges: &[std::ops::Range<usize>]) -> bool {
21    // usize→f32 precision loss is irrelevant at line lengths
22    #[allow(clippy::cast_precision_loss)]
23    fn share(n: usize) -> f32 {
24        n as f32
25    }
26    let mut content = 0usize;
27    let mut emphasized = 0usize;
28    for (i, &b) in text.as_bytes().iter().enumerate() {
29        if b == b' ' || b == b'\t' {
30            continue;
31        }
32        content += 1;
33        if ranges.iter().any(|r| r.start <= i && i < r.end) {
34            emphasized += 1;
35        }
36    }
37    // we always keep two changed characters so "41" → "42" stays lit, and
38    // keep the run cap regardless since whitespace runs count zero chars
39    content > 0
40        && ranges.len() <= MAX_EMPHASIS_RUNS
41        && (emphasized <= 2 || share(emphasized) < share(content) * MAX_EMPHASIS_SHARE)
42}
43
44/// Both sides punctual, or neither side gets emphasis.
45pub(crate) fn gated_pair_emphasis(
46    old: &str,
47    new: &str,
48) -> (Vec<std::ops::Range<usize>>, Vec<std::ops::Range<usize>>) {
49    let (old_emphasis, new_emphasis) = intraline(old, new);
50    if emphasis_is_punctual(old, &old_emphasis) && emphasis_is_punctual(new, &new_emphasis) {
51        (old_emphasis, new_emphasis)
52    } else {
53        (Vec::new(), Vec::new())
54    }
55}
56
57pub fn enrich_file(file: &mut FileDiff) {
58    for hunk in &mut file.hunks {
59        enrich_hunk(hunk);
60    }
61}
62
63fn enrich_hunk(hunk: &mut Hunk) {
64    for (del_idx, add_idx) in paired_run_indices(&hunk.lines) {
65        let (Some(old), Some(new)) = (hunk.lines.get(del_idx), hunk.lines.get(add_idx)) else {
66            continue;
67        };
68        let (old_emphasis, new_emphasis) = gated_pair_emphasis(&old.text, &new.text);
69        if let Some(line) = hunk.lines.get_mut(del_idx) {
70            line.emphasis = old_emphasis;
71        }
72        if let Some(line) = hunk.lines.get_mut(add_idx) {
73            line.emphasis = new_emphasis;
74        }
75    }
76}
77
78/// Keep at or above `diff::MIN_INLINE_RATIO`, or pairs form whose emphasis
79/// it always suppresses.
80const MIN_PAIR_RATIO: f32 = 0.5;
81
82/// A run past this is a rewrite, so we pair it by position and skip the
83/// quadratic alignment.
84const MAX_PAIR_TABLE: usize = 1024;
85
86/// `(deleted, added)` index pairs for a hunk's del/add runs.
87pub(crate) fn paired_run_indices(lines: &[DiffLine]) -> Vec<(usize, usize)> {
88    let kind_at = |i: usize| lines.get(i).map(|l| l.kind);
89    let mut pairs = Vec::new();
90    let mut i = 0;
91    while i < lines.len() {
92        if kind_at(i) != Some(LineKind::Deleted) {
93            i += 1;
94            continue;
95        }
96        let del_start = i;
97        while kind_at(i) == Some(LineKind::Deleted) {
98            i += 1;
99        }
100        let add_start = i;
101        while kind_at(i) == Some(LineKind::Added) {
102            i += 1;
103        }
104        pair_runs(lines, del_start..add_start, add_start..i, &mut pairs);
105    }
106    pairs
107}
108
109/// Weighted LCS over line pairs. Positional pairing mismatches as soon as a
110/// run inserts or drops one line.
111// the DP tables are allocated (d+1)×(a+1) and every index below stays
112// inside those bounds
113#[allow(clippy::indexing_slicing)]
114fn pair_runs(
115    lines: &[DiffLine],
116    dels: std::ops::Range<usize>,
117    adds: std::ops::Range<usize>,
118    pairs: &mut Vec<(usize, usize)>,
119) {
120    let (d, a) = (dels.len(), adds.len());
121    if d == 0 || a == 0 {
122        return;
123    }
124    if d * a > MAX_PAIR_TABLE {
125        pairs.extend((0..d.min(a)).map(|p| (dels.start + p, adds.start + p)));
126        return;
127    }
128    let text = |i: usize| lines.get(i).map_or("", |l| l.text.as_str());
129    // score[i][j]: best total ratio pairing the first i dels with the first
130    // j adds; step[i][j] records the move that produced it for traceback
131    let mut score = vec![vec![0f32; a + 1]; d + 1];
132    let mut step = vec![vec![0u8; a + 1]; d + 1];
133    for i in 1..=d {
134        for j in 1..=a {
135            let ratio = line_ratio(text(dels.start + i - 1), text(adds.start + j - 1));
136            let (mut best, mut chose) = (score[i - 1][j], 1u8);
137            if score[i][j - 1] > best {
138                (best, chose) = (score[i][j - 1], 2);
139            }
140            // >= so an exact tie prefers pairing (the positional alignment):
141            // shifted single-token columns land exactly on the ratio floor
142            if ratio >= MIN_PAIR_RATIO && score[i - 1][j - 1] + ratio >= best {
143                (best, chose) = (score[i - 1][j - 1] + ratio, 3);
144            }
145            score[i][j] = best;
146            step[i][j] = chose;
147        }
148    }
149    let (mut i, mut j) = (d, a);
150    let mut aligned = Vec::new();
151    while i > 0 && j > 0 {
152        match step[i][j] {
153            3 => {
154                aligned.push((dels.start + i - 1, adds.start + j - 1));
155                i -= 1;
156                j -= 1;
157            }
158            2 => j -= 1,
159            _ => i -= 1,
160        }
161    }
162    pairs.extend(aligned.into_iter().rev());
163}
164
165/// The token diff per DP cell is quadratic on dissimilar lines, so longer
166/// lines never pair.
167const MAX_PAIR_LINE_BYTES: usize = 1024;
168
169/// Indentation counts, since a shared indent keeps short pairs like
170/// `41` → `42` above the floor.
171fn line_ratio(old: &str, new: &str) -> f32 {
172    if old.len() > MAX_PAIR_LINE_BYTES || new.len() > MAX_PAIR_LINE_BYTES {
173        return 0.0;
174    }
175    // we score blank lines zero so a stray blank cannot take a real
176    // homolog's slot
177    if old.trim().is_empty() || new.trim().is_empty() {
178        return 0.0;
179    }
180    TextDiff::from_unicode_words(old, new).ratio()
181}
182
183#[cfg(test)]
184mod tests {
185    use crate::model::{DiffLine, HunkId, LineKind};
186
187    use super::*;
188
189    fn hunk(lines: Vec<(LineKind, &str)>) -> Hunk {
190        Hunk {
191            id: HunkId("test".into()),
192            old_start: 1,
193            old_lines: 1,
194            new_start: 1,
195            new_lines: 1,
196            context: String::new(),
197            lines: lines
198                .into_iter()
199                .map(|(k, t)| DiffLine::new(k, None, None, t.to_owned()))
200                .collect(),
201        }
202    }
203
204    #[test]
205    fn similar_pair_gets_emphasis_on_both_sides() {
206        let mut h = hunk(vec![
207            (LineKind::Context, "def f():"),
208            (LineKind::Deleted, "    if x < y:"),
209            (LineKind::Added, "    if x <= y:"),
210        ]);
211        enrich_hunk(&mut h);
212        assert!(h.lines[1].emphasis.is_empty());
213        assert_eq!(h.lines[2].emphasis, vec![10..11]);
214    }
215
216    #[test]
217    fn emphasis_is_punctual_separates_edits_from_rewrites() {
218        assert!(emphasis_is_punctual(
219            "let x = compute();",
220            std::slice::from_ref(&(8..15))
221        ));
222        assert!(emphasis_is_punctual("41", std::slice::from_ref(&(1..2))));
223        assert!(!emphasis_is_punctual(
224            "let x = compute();",
225            std::slice::from_ref(&(0..14))
226        ));
227        assert!(!emphasis_is_punctual("", &[]));
228    }
229
230    #[test]
231    fn scattered_runs_beyond_the_cap_are_not_punctual() {
232        let text = "alpha one beta two gamma three delta four epsilon";
233        let three = vec![6..9, 15..18, 25..30];
234        assert!(emphasis_is_punctual(text, &three));
235        let four = vec![6..9, 15..18, 25..30, 37..41];
236        assert!(!emphasis_is_punctual(text, &four));
237    }
238
239    #[test]
240    fn whitespace_only_runs_do_not_ride_the_tiny_edit_shortcut() {
241        let text = "a   = 1; b   = 2; c   = 3; d   = 4";
242        let runs = vec![1..4, 10..13, 19..22, 28..31];
243        assert!(!emphasis_is_punctual(text, &runs));
244    }
245
246    #[test]
247    fn shifted_single_token_columns_still_pair_positionally() {
248        // "    1" vs "    2" sits exactly on the ratio floor
249        let mut h = hunk(vec![
250            (LineKind::Deleted, "    1"),
251            (LineKind::Deleted, "    2"),
252            (LineKind::Added, "    2"),
253            (LineKind::Added, "    3"),
254        ]);
255        enrich_hunk(&mut h);
256        assert_eq!(paired_run_indices(&h.lines), vec![(0, 2), (1, 3)]);
257        assert_eq!(h.lines[0].emphasis, vec![4..5]);
258        assert_eq!(h.lines[3].emphasis, vec![4..5]);
259    }
260
261    #[test]
262    fn blank_lines_never_steal_a_homolog_slot() {
263        let mut h = hunk(vec![
264            (LineKind::Deleted, "foo();"),
265            (LineKind::Deleted, ""),
266            (LineKind::Added, ""),
267            (LineKind::Added, "foo(x);"),
268        ]);
269        enrich_hunk(&mut h);
270        assert_eq!(paired_run_indices(&h.lines), vec![(0, 3)]);
271        assert!(!h.lines[3].emphasis.is_empty(), "the edit keeps emphasis");
272    }
273
274    #[test]
275    fn huge_lines_never_pair() {
276        let long_old = format!("data,{}", "x,".repeat(1024));
277        let long_new = format!("data,{}", "y,".repeat(1024));
278        let mut h = hunk(vec![
279            (LineKind::Deleted, long_old.as_str()),
280            (LineKind::Added, long_new.as_str()),
281        ]);
282        enrich_hunk(&mut h);
283        assert!(paired_run_indices(&h.lines).is_empty());
284        assert!(h.lines.iter().all(|l| l.emphasis.is_empty()));
285    }
286
287    #[test]
288    fn dissimilar_pair_gets_no_emphasis() {
289        let mut h = hunk(vec![
290            (LineKind::Deleted, "totally_different_thing()"),
291            (LineKind::Added, "x = 1"),
292        ]);
293        enrich_hunk(&mut h);
294        assert!(h.lines[0].emphasis.is_empty());
295        assert!(h.lines[1].emphasis.is_empty());
296    }
297
298    #[test]
299    fn unbalanced_runs_pair_by_similarity_not_position() {
300        let mut h = hunk(vec![
301            (LineKind::Deleted, "alpha line one"),
302            (LineKind::Deleted, "beta line two"),
303            (LineKind::Added, "beta line TWO"),
304        ]);
305        enrich_hunk(&mut h);
306        assert_eq!(
307            paired_run_indices(&h.lines),
308            vec![(1, 2)],
309            "pairs the beta lines, leaves alpha unpaired"
310        );
311        assert!(!h.lines[2].emphasis.is_empty());
312        assert!(h.lines[0].emphasis.is_empty());
313    }
314
315    #[test]
316    fn misaligned_type_hunk_pairs_fields_with_their_homologs() {
317        let mut h = hunk(vec![
318            (
319                LineKind::Deleted,
320                "export function buildTokenClaims(user: {",
321            ),
322            (LineKind::Deleted, "    email: string;"),
323            (LineKind::Deleted, "    permissions?: string[];"),
324            (LineKind::Deleted, "    states?: string[];"),
325            (LineKind::Added, "type Entitlements = {"),
326            (LineKind::Added, "    permissions?: string[] | null;"),
327            (LineKind::Added, "    states?: string[] | null;"),
328        ]);
329        enrich_hunk(&mut h);
330        assert_eq!(paired_run_indices(&h.lines), vec![(2, 5), (3, 6)]);
331        for (index, expected) in [(5, "| null"), (6, "| null")] {
332            let line = &h.lines[index];
333            let covered: String = line
334                .emphasis
335                .iter()
336                .map(|r| &line.text[r.clone()])
337                .collect();
338            assert_eq!(
339                covered.trim(),
340                expected,
341                "only the added union arm lights up: {covered:?}"
342            );
343        }
344        for index in [0, 1, 4] {
345            assert!(
346                h.lines[index].emphasis.is_empty(),
347                "unpaired line {index} renders plain"
348            );
349        }
350    }
351
352    #[test]
353    fn separate_runs_pair_independently() {
354        let mut h = hunk(vec![
355            (LineKind::Deleted, "first old line"),
356            (LineKind::Added, "first new line"),
357            (LineKind::Context, "middle"),
358            (LineKind::Deleted, "second old line"),
359            (LineKind::Added, "second new line"),
360        ]);
361        enrich_hunk(&mut h);
362        assert!(!h.lines[0].emphasis.is_empty());
363        assert!(!h.lines[1].emphasis.is_empty());
364        assert!(!h.lines[3].emphasis.is_empty());
365        assert!(!h.lines[4].emphasis.is_empty());
366    }
367}