Skip to main content

diffler_core/
pairing.rs

1//! Pair deleted/added line runs inside a hunk and attach intra-line
2//! emphasis. Within a run, lines pair by best total similarity (delta's
3//! homologous-line model): an unbalanced run pairs each line with its true
4//! counterpart, and lines with no counterpart stay unpaired and render
5//! plain: emphasis only ever contrasts a line against its homolog.
6
7use similar::TextDiff;
8
9use crate::diff::intraline;
10use crate::model::{DiffLine, FileDiff, Hunk, LineKind};
11
12/// Emphasis above this share of a line's content is noise, not signal:
13/// highlights are for punctual edits, not rewrites. Word-level emphasis
14/// legitimately covers whole tokens (`old_name` → `new_name` is most of its
15/// line), so the ceiling sits above one-substituted-word territory; true
16/// rewrites already fall out at the token-ratio gate.
17const MAX_EMPHASIS_SHARE: f32 = 0.7;
18
19/// More separate emphasis runs than this and the line reads as confetti:
20/// scattered small edits render better as plain +/- lines (jj draws the
21/// same line at 3 inline alternations). Counted after near-adjacent runs
22/// merge under `diff::MAX_GAP_CHARS`. Tune the two together.
23const MAX_EMPHASIS_RUNS: usize = 3;
24
25/// True when the emphasized ranges cover a minority of the line's
26/// non-whitespace content in a few contiguous runs: a punctual edit worth
27/// highlighting. A line that changed (nearly) everywhere, or in many
28/// scattered places, reads better as a plain +/- line.
29pub(crate) fn emphasis_is_punctual(text: &str, ranges: &[std::ops::Range<usize>]) -> bool {
30    // usize→f32 precision loss is irrelevant at line lengths
31    #[allow(clippy::cast_precision_loss)]
32    fn share(n: usize) -> f32 {
33        n as f32
34    }
35    let mut content = 0usize;
36    let mut emphasized = 0usize;
37    for (i, &b) in text.as_bytes().iter().enumerate() {
38        if b == b' ' || b == b'\t' {
39            continue;
40        }
41        content += 1;
42        if ranges.iter().any(|r| r.start <= i && i < r.end) {
43            emphasized += 1;
44        }
45    }
46    // a couple of changed characters is always signal, whatever the ratio:
47    // short lines ("41" → "42") would otherwise lose their only highlight;
48    // the run cap stands regardless (whitespace-only runs count zero chars
49    // and would ride the shortcut into confetti)
50    content > 0
51        && ranges.len() <= MAX_EMPHASIS_RUNS
52        && (emphasized <= 2 || share(emphasized) < share(content) * MAX_EMPHASIS_SHARE)
53}
54
55/// Intra-line emphasis for a paired old/new line, gated as a pair: both
56/// sides punctual, or neither side gets any.
57pub(crate) fn gated_pair_emphasis(
58    old: &str,
59    new: &str,
60) -> (Vec<std::ops::Range<usize>>, Vec<std::ops::Range<usize>>) {
61    let (old_emphasis, new_emphasis) = intraline(old, new);
62    if emphasis_is_punctual(old, &old_emphasis) && emphasis_is_punctual(new, &new_emphasis) {
63        (old_emphasis, new_emphasis)
64    } else {
65        (Vec::new(), Vec::new())
66    }
67}
68
69/// Attach intra-line emphasis to one file's hunks. Pairing is a render-time
70/// concern (only the TUI reads `.emphasis`), so callers enrich the file they
71/// are about to display rather than enriching whole models up front.
72pub fn enrich_file(file: &mut FileDiff) {
73    for hunk in &mut file.hunks {
74        enrich_hunk(hunk);
75    }
76}
77
78fn enrich_hunk(hunk: &mut Hunk) {
79    for (del_idx, add_idx) in paired_run_indices(&hunk.lines) {
80        let (Some(old), Some(new)) = (hunk.lines.get(del_idx), hunk.lines.get(add_idx)) else {
81            continue;
82        };
83        let (old_emphasis, new_emphasis) = gated_pair_emphasis(&old.text, &new.text);
84        if let Some(line) = hunk.lines.get_mut(del_idx) {
85            line.emphasis = old_emphasis;
86        }
87        if let Some(line) = hunk.lines.get_mut(add_idx) {
88            line.emphasis = new_emphasis;
89        }
90    }
91}
92
93/// Below this token similarity two lines never pair as homologs; a line
94/// with no partner above the floor renders plain rather than being
95/// contrasted against an unrelated neighbor. Keep at or above the engine's
96/// `diff::MIN_INLINE_RATIO`, or pairs form whose emphasis it always
97/// suppresses, wasting a real homolog candidate.
98const MIN_PAIR_RATIO: f32 = 0.5;
99
100/// Runs whose candidate table exceeds this fall back to positional prefix
101/// pairing: a run that big is a rewrite, and the quadratic alignment would
102/// buy nothing but latency. Fallback pairs skip the ratio floor and lean on
103/// the downstream emphasis gates instead.
104const MAX_PAIR_TABLE: usize = 1024;
105
106/// `(deleted, added)` index pairs for a hunk's del/add runs: the shared
107/// homologous-line model.
108pub(crate) fn paired_run_indices(lines: &[DiffLine]) -> Vec<(usize, usize)> {
109    let kind_at = |i: usize| lines.get(i).map(|l| l.kind);
110    let mut pairs = Vec::new();
111    let mut i = 0;
112    while i < lines.len() {
113        if kind_at(i) != Some(LineKind::Deleted) {
114            i += 1;
115            continue;
116        }
117        let del_start = i;
118        while kind_at(i) == Some(LineKind::Deleted) {
119            i += 1;
120        }
121        let add_start = i;
122        while kind_at(i) == Some(LineKind::Added) {
123            i += 1;
124        }
125        pair_runs(lines, del_start..add_start, add_start..i, &mut pairs);
126    }
127    pairs
128}
129
130/// Monotonic best-total-similarity alignment of a deleted run against its
131/// added run (a weighted LCS over line pairs): positional pairing mismatches
132/// as soon as a run inserts or drops one line, contrasting unrelated lines.
133// the DP tables are allocated (d+1)×(a+1) and every index below stays
134// inside those bounds
135#[allow(clippy::indexing_slicing)]
136fn pair_runs(
137    lines: &[DiffLine],
138    dels: std::ops::Range<usize>,
139    adds: std::ops::Range<usize>,
140    pairs: &mut Vec<(usize, usize)>,
141) {
142    let (d, a) = (dels.len(), adds.len());
143    if d == 0 || a == 0 {
144        return;
145    }
146    if d * a > MAX_PAIR_TABLE {
147        pairs.extend((0..d.min(a)).map(|p| (dels.start + p, adds.start + p)));
148        return;
149    }
150    let text = |i: usize| lines.get(i).map_or("", |l| l.text.as_str());
151    // score[i][j]: best total ratio pairing the first i dels with the first
152    // j adds; step[i][j] records the move that produced it for traceback
153    let mut score = vec![vec![0f32; a + 1]; d + 1];
154    let mut step = vec![vec![0u8; a + 1]; d + 1];
155    for i in 1..=d {
156        for j in 1..=a {
157            let ratio = line_ratio(text(dels.start + i - 1), text(adds.start + j - 1));
158            let (mut best, mut chose) = (score[i - 1][j], 1u8);
159            if score[i][j - 1] > best {
160                (best, chose) = (score[i][j - 1], 2);
161            }
162            // >= so an exact tie prefers pairing (the positional alignment):
163            // shifted single-token columns land exactly on the ratio floor
164            if ratio >= MIN_PAIR_RATIO && score[i - 1][j - 1] + ratio >= best {
165                (best, chose) = (score[i - 1][j - 1] + ratio, 3);
166            }
167            score[i][j] = best;
168            step[i][j] = chose;
169        }
170    }
171    let (mut i, mut j) = (d, a);
172    let mut aligned = Vec::new();
173    while i > 0 && j > 0 {
174        match step[i][j] {
175            3 => {
176                aligned.push((dels.start + i - 1, adds.start + j - 1));
177                i -= 1;
178                j -= 1;
179            }
180            2 => j -= 1,
181            _ => i -= 1,
182        }
183    }
184    pairs.extend(aligned.into_iter().rev());
185}
186
187/// Lines longer than this never pair: the token diff per DP cell is
188/// quadratic on dissimilar lines, and a run of huge lines would stall the
189/// render path for emphasis that reads as noise anyway.
190const MAX_PAIR_LINE_BYTES: usize = 1024;
191
192/// Token-level similarity of two lines. Indentation counts: a shared indent
193/// is what keeps short single-token pairs (`41` → `42`) above the floor, and
194/// the alignment already prefers a real homolog over an indent-only match.
195fn line_ratio(old: &str, new: &str) -> f32 {
196    if old.len() > MAX_PAIR_LINE_BYTES || new.len() > MAX_PAIR_LINE_BYTES {
197        return 0.0;
198    }
199    // blank and whitespace-only lines match anything of their kind at full
200    // ratio yet carry no signal; scoring them zero keeps a stray blank from
201    // stealing a real homolog's slot in the alignment
202    if old.trim().is_empty() || new.trim().is_empty() {
203        return 0.0;
204    }
205    TextDiff::from_unicode_words(old, new).ratio()
206}
207
208#[cfg(test)]
209mod tests {
210    use crate::model::{DiffLine, HunkId, LineKind};
211
212    use super::*;
213
214    fn hunk(lines: Vec<(LineKind, &str)>) -> Hunk {
215        Hunk {
216            id: HunkId("test".into()),
217            old_start: 1,
218            old_lines: 1,
219            new_start: 1,
220            new_lines: 1,
221            context: String::new(),
222            lines: lines
223                .into_iter()
224                .map(|(k, t)| DiffLine::new(k, None, None, t.to_owned()))
225                .collect(),
226        }
227    }
228
229    #[test]
230    fn similar_pair_gets_emphasis_on_both_sides() {
231        let mut h = hunk(vec![
232            (LineKind::Context, "def f():"),
233            (LineKind::Deleted, "    if x < y:"),
234            (LineKind::Added, "    if x <= y:"),
235        ]);
236        enrich_hunk(&mut h);
237        assert!(h.lines[1].emphasis.is_empty()); // deletion side: nothing removed, only insert
238        assert_eq!(h.lines[2].emphasis, vec![10..11]);
239    }
240
241    #[test]
242    fn emphasis_is_punctual_separates_edits_from_rewrites() {
243        // minority coverage is signal
244        assert!(emphasis_is_punctual(
245            "let x = compute();",
246            std::slice::from_ref(&(8..15))
247        ));
248        // a tiny edit always qualifies, whatever the ratio
249        assert!(emphasis_is_punctual("41", std::slice::from_ref(&(1..2))));
250        // majority coverage is a rewrite: no char highlights
251        assert!(!emphasis_is_punctual(
252            "let x = compute();",
253            std::slice::from_ref(&(0..14))
254        ));
255        assert!(!emphasis_is_punctual("", &[]));
256    }
257
258    #[test]
259    fn scattered_runs_beyond_the_cap_are_not_punctual() {
260        let text = "alpha one beta two gamma three delta four epsilon";
261        // three runs under the coverage ceiling: still an edit
262        let three = vec![6..9, 15..18, 25..30];
263        assert!(emphasis_is_punctual(text, &three));
264        // a fourth scattered run tips it into confetti
265        let four = vec![6..9, 15..18, 25..30, 37..41];
266        assert!(!emphasis_is_punctual(text, &four));
267    }
268
269    #[test]
270    fn whitespace_only_runs_do_not_ride_the_tiny_edit_shortcut() {
271        // alignment-only edits emphasize zero content chars; four scattered
272        // space runs must still fail the cap, not pass as a "tiny edit"
273        let text = "a   = 1; b   = 2; c   = 3; d   = 4";
274        let runs = vec![1..4, 10..13, 19..22, 28..31];
275        assert!(!emphasis_is_punctual(text, &runs));
276    }
277
278    #[test]
279    fn shifted_single_token_columns_still_pair_positionally() {
280        // "    1" vs "    2" sits exactly on the ratio floor; a renumber
281        // shift must not collapse onto the lone identity pair and go plain
282        let mut h = hunk(vec![
283            (LineKind::Deleted, "    1"),
284            (LineKind::Deleted, "    2"),
285            (LineKind::Added, "    2"),
286            (LineKind::Added, "    3"),
287        ]);
288        enrich_hunk(&mut h);
289        assert_eq!(paired_run_indices(&h.lines), vec![(0, 2), (1, 3)]);
290        assert_eq!(h.lines[0].emphasis, vec![4..5]);
291        assert_eq!(h.lines[3].emphasis, vec![4..5]);
292    }
293
294    #[test]
295    fn blank_lines_never_steal_a_homolog_slot() {
296        let mut h = hunk(vec![
297            (LineKind::Deleted, "foo();"),
298            (LineKind::Deleted, ""),
299            (LineKind::Added, ""),
300            (LineKind::Added, "foo(x);"),
301        ]);
302        enrich_hunk(&mut h);
303        // a blank-to-blank identity pair would cross and unpair the real edit
304        assert_eq!(paired_run_indices(&h.lines), vec![(0, 3)]);
305        assert!(!h.lines[3].emphasis.is_empty(), "the edit keeps emphasis");
306    }
307
308    #[test]
309    fn huge_lines_never_pair() {
310        let long_old = format!("data,{}", "x,".repeat(1024));
311        let long_new = format!("data,{}", "y,".repeat(1024));
312        let mut h = hunk(vec![
313            (LineKind::Deleted, long_old.as_str()),
314            (LineKind::Added, long_new.as_str()),
315        ]);
316        enrich_hunk(&mut h);
317        assert!(paired_run_indices(&h.lines).is_empty());
318        assert!(h.lines.iter().all(|l| l.emphasis.is_empty()));
319    }
320
321    #[test]
322    fn dissimilar_pair_gets_no_emphasis() {
323        let mut h = hunk(vec![
324            (LineKind::Deleted, "totally_different_thing()"),
325            (LineKind::Added, "x = 1"),
326        ]);
327        enrich_hunk(&mut h);
328        assert!(h.lines[0].emphasis.is_empty());
329        assert!(h.lines[1].emphasis.is_empty());
330    }
331
332    #[test]
333    fn unbalanced_runs_pair_by_similarity_not_position() {
334        let mut h = hunk(vec![
335            (LineKind::Deleted, "alpha line one"),
336            (LineKind::Deleted, "beta line two"),
337            (LineKind::Added, "beta line TWO"),
338        ]);
339        enrich_hunk(&mut h);
340        // positional pairing would contrast the add with "alpha line one";
341        // the alignment finds its real homolog on the second deletion
342        assert_eq!(
343            paired_run_indices(&h.lines),
344            vec![(1, 2)],
345            "pairs the beta lines, leaves alpha unpaired"
346        );
347        assert!(!h.lines[2].emphasis.is_empty());
348        assert!(h.lines[0].emphasis.is_empty());
349    }
350
351    /// A 4-deleted/3-added run where positional pairing would contrast
352    /// `email` with `permissions` and `permissions` with `states`, painting
353    /// identifier "renames" that never happened.
354    #[test]
355    fn misaligned_type_hunk_pairs_fields_with_their_homologs() {
356        let mut h = hunk(vec![
357            (
358                LineKind::Deleted,
359                "export function buildTokenClaims(user: {",
360            ),
361            (LineKind::Deleted, "    email: string;"),
362            (LineKind::Deleted, "    permissions?: string[];"),
363            (LineKind::Deleted, "    states?: string[];"),
364            (LineKind::Added, "type Entitlements = {"),
365            (LineKind::Added, "    permissions?: string[] | null;"),
366            (LineKind::Added, "    states?: string[] | null;"),
367        ]);
368        enrich_hunk(&mut h);
369        assert_eq!(paired_run_indices(&h.lines), vec![(2, 5), (3, 6)]);
370        for (index, expected) in [(5, "| null"), (6, "| null")] {
371            let line = &h.lines[index];
372            let covered: String = line
373                .emphasis
374                .iter()
375                .map(|r| &line.text[r.clone()])
376                .collect();
377            assert_eq!(
378                covered.trim(),
379                expected,
380                "only the added union arm lights up: {covered:?}"
381            );
382        }
383        for index in [0, 1, 4] {
384            assert!(
385                h.lines[index].emphasis.is_empty(),
386                "unpaired line {index} renders plain"
387            );
388        }
389    }
390
391    #[test]
392    fn separate_runs_pair_independently() {
393        let mut h = hunk(vec![
394            (LineKind::Deleted, "first old line"),
395            (LineKind::Added, "first new line"),
396            (LineKind::Context, "middle"),
397            (LineKind::Deleted, "second old line"),
398            (LineKind::Added, "second new line"),
399        ]);
400        enrich_hunk(&mut h);
401        assert!(!h.lines[0].emphasis.is_empty());
402        assert!(!h.lines[1].emphasis.is_empty());
403        assert!(!h.lines[3].emphasis.is_empty());
404        assert!(!h.lines[4].emphasis.is_empty());
405    }
406}