Skip to main content

lattice_diff/
refine.rs

1//! DR.1 (2026-08-12): **intra-line refinement** — which *part* of a
2//! changed line changed.
3//!
4//! Design: `docs/dev/architecture/diff-refinement.md`. Slice plan:
5//! `docs/dev/operations/slice-plans/diff-refinement.md`.
6//!
7//! Every diff surface colours a changed line uniformly, so a
8//! one-character change reads exactly like a rewritten one. This
9//! computes the byte ranges that actually differ between a removed
10//! line and the added line that replaced it; the presentation layers
11//! (DR.2–DR.4) tint those ranges more strongly.
12//!
13//! Pure and consumer-agnostic. It lives here rather than in
14//! `lattice-magit` because `diff-mode`'s side-by-side panes have the
15//! identical gap — magit is the first consumer, not the owner.
16//!
17//! ## Word-level, deliberately
18//!
19//! Character-level diffing of source code produces confetti: matching
20//! brackets and single letters scatter through a rename and read worse
21//! than no refinement at all. Word-level is what magit, delta and
22//! GitHub use.
23
24use std::ops::Range;
25
26use imara_diff::intern::{InternedInput, Interner, Token};
27use imara_diff::{Algorithm, Sink};
28
29/// Above this share of a **line**, that line's refinement is noise
30/// rather than signal.
31///
32/// If nearly all of a line changed, the uniform row tint has already
33/// said so, and marking almost all of it adds a second colour saying
34/// the same thing. Applied per line and per side — see
35/// [`drop_noisy_lines`] for why the pair-coupled form DR.1 used cannot
36/// survive region refinement.
37const MAX_REFINED_SHARE: f64 = 0.70;
38
39/// Above this many bytes on either side, refinement is skipped.
40///
41/// DR.5 diffs whole regions rather than line pairs, so a single
42/// enormous hunk is now one token diff instead of many small ones. The
43/// cap keeps that bounded. It costs nothing in practice: a hunk this
44/// size is a wholesale rewrite, which [`MAX_REFINED_SHARE`] would
45/// almost always decline anyway — this just declines it without doing
46/// the work first.
47const MAX_REGION_BYTES: usize = 64 * 1024;
48
49/// The byte ranges that differ, per line, on each side of one hunk.
50///
51/// **Per side, not per pair** (DR.5). A hunk that removes one line and
52/// adds twelve has no line pairing to speak of, so the two sides carry
53/// independent per-line range lists: `removed[i]` describes the *i*-th
54/// removed line, `added[j]` the *j*-th added one, and neither implies
55/// the other's length.
56///
57/// This replaced a `Vec<Option<LineRefinement>>` that was indexed by
58/// pair. That shape could not represent *n* removed against *m* added
59/// at all, which is why the old code declined those hunks outright
60/// rather than rendering them badly.
61#[derive(Debug, Clone, PartialEq, Eq, Default)]
62pub struct RegionRefinement {
63    /// One entry per removed line, in order.
64    pub removed: Vec<Vec<Range<usize>>>,
65    /// One entry per added line, in order.
66    pub added: Vec<Vec<Range<usize>>>,
67}
68
69impl RegionRefinement {
70    /// True when neither side has a single refined range — the value a
71    /// declined region carries, and the one that renders exactly as it
72    /// did before refinement existed.
73    pub fn is_empty(&self) -> bool {
74        self.removed.iter().all(Vec::is_empty) && self.added.iter().all(Vec::is_empty)
75    }
76
77    /// Refined ranges on the *i*-th removed line. Empty — never a
78    /// panic — for an index past the end, so a consumer walking a
79    /// baseline range that outruns the refinement degrades to "no
80    /// refinement here" instead of falling over.
81    pub fn removed_line(&self, i: usize) -> &[Range<usize>] {
82        self.removed.get(i).map(Vec::as_slice).unwrap_or(&[])
83    }
84
85    /// Refined ranges on the *j*-th added line. Same tolerance as
86    /// [`Self::removed_line`].
87    pub fn added_line(&self, j: usize) -> &[Range<usize>] {
88        self.added.get(j).map(Vec::as_slice).unwrap_or(&[])
89    }
90}
91
92/// One token of a line: a byte range plus its text.
93///
94/// A "word" is a maximal run of `[A-Za-z0-9_]`; every other character
95/// is its own token. That keeps identifiers whole — the common rename
96/// case — while still letting a punctuation-only change refine.
97///
98/// Tokenising on `char_indices` means every boundary is a character
99/// boundary, so the ranges this produces can always be sliced from the
100/// original string.
101fn tokenize(line: &str) -> Vec<(Range<usize>, &str)> {
102    let mut out = Vec::new();
103    let mut chars = line.char_indices().peekable();
104    while let Some((start, c)) = chars.next() {
105        if is_word_char(c) {
106            let mut end = start + c.len_utf8();
107            while let Some(&(i, next)) = chars.peek() {
108                if is_word_char(next) {
109                    end = i + next.len_utf8();
110                    chars.next();
111                } else {
112                    break;
113                }
114            }
115            out.push((start..end, &line[start..end]));
116        } else {
117            let end = start + c.len_utf8();
118            out.push((start..end, &line[start..end]));
119        }
120    }
121    out
122}
123
124fn is_word_char(c: char) -> bool {
125    c.is_alphanumeric() || c == '_'
126}
127
128/// Collects changed token index ranges from `imara-diff`.
129struct TokenSink {
130    before: Vec<Range<u32>>,
131    after: Vec<Range<u32>>,
132}
133
134impl Sink for TokenSink {
135    type Out = (Vec<Range<u32>>, Vec<Range<u32>>);
136
137    fn process_change(&mut self, before: Range<u32>, after: Range<u32>) {
138        if !before.is_empty() {
139            self.before.push(before);
140        }
141        if !after.is_empty() {
142            self.after.push(after);
143        }
144    }
145
146    fn finish(self) -> Self::Out {
147        (self.before, self.after)
148    }
149}
150
151/// One token of a whole *region* — a run of lines — remembering which
152/// line inside the region it came from.
153///
154/// Diffing the region as one token stream is the whole of DR.5: it is
155/// what lets an *n*-removed / *m*-added hunk refine without anyone
156/// having to decide which added line "replaced" which removed one.
157struct RegionToken<'a> {
158    /// Index into the region's line slice.
159    line: usize,
160    /// Byte range within *that* line.
161    range: Range<usize>,
162    text: &'a str,
163    /// A synthetic line break between two lines.
164    ///
165    /// Present so the matcher sees the line structure — without it, the
166    /// last word of one line and the first of the next are adjacent
167    /// tokens and can match across the boundary. Skipped when ranges
168    /// are mapped back, since a line break is not a byte anyone tints.
169    separator: bool,
170}
171
172/// Tokenise a region: every line's tokens in order, separated by a
173/// synthetic line break.
174fn tokenize_region<'a>(lines: &[&'a str]) -> Vec<RegionToken<'a>> {
175    let mut out = Vec::new();
176    for (line, text) in lines.iter().enumerate() {
177        if line > 0 {
178            out.push(RegionToken {
179                line,
180                range: 0..0,
181                text: "\n",
182                separator: true,
183            });
184        }
185        for (range, tok) in tokenize(text) {
186            out.push(RegionToken {
187                line,
188                range,
189                text: tok,
190                separator: false,
191            });
192        }
193    }
194    out
195}
196
197/// Scatter changed token-index ranges back onto their lines as byte
198/// ranges, coalescing tokens that touch *within a line* so adjacent
199/// changed tokens render as one highlight rather than a dotted line.
200///
201/// A changed range that spans a line break simply contributes to both
202/// lines: each token knows its own line, so no range ever straddles
203/// one.
204fn to_line_ranges(
205    tokens: &[RegionToken<'_>],
206    idx: &[Range<u32>],
207    line_count: usize,
208) -> Vec<Vec<Range<usize>>> {
209    let mut out: Vec<Vec<Range<usize>>> = vec![Vec::new(); line_count];
210    for r in idx {
211        let lo = r.start as usize;
212        let hi = (r.end as usize).min(tokens.len());
213        let Some(slice) = tokens.get(lo..hi) else {
214            continue;
215        };
216        for token in slice {
217            if token.separator {
218                continue;
219            }
220            let Some(dst) = out.get_mut(token.line) else {
221                continue;
222            };
223            match dst.last_mut() {
224                Some(prev) if prev.end >= token.range.start => {
225                    prev.end = prev.end.max(token.range.end)
226                }
227                _ => dst.push(token.range.clone()),
228            }
229        }
230    }
231    out
232}
233
234/// Refine one hunk's removed region against its added region.
235///
236/// **Region-to-region, not line-paired** (DR.5). The two runs are
237/// tokenised whole, diffed as single token streams, and the changed
238/// ranges scattered back onto whichever lines they fell on. Nothing
239/// decides which added line "replaced" which removed one, because
240/// nothing has to — which is exactly why an *n*-removed / *m*-added
241/// hunk refines here and declined under the old pairing rule.
242///
243/// This is what the reference implementation does:
244/// `magit-diff-update-hunk-refinement` hands the hunk's whole removed
245/// and added regions to `smerge-refine-regions`. The predecessor's
246/// claim that "magit declines the same case" was simply wrong.
247///
248/// Returns an empty [`RegionRefinement`] — which renders exactly as it
249/// did before refinement existed, the direction this feature must fail
250/// in — when refinement would be noise rather than signal:
251///
252/// - either side is absent (a pure `Add` or `Remove` has nothing to
253///   compare against);
254/// - the two regions are identical (nothing to say);
255/// - either side is wholly changed past [`MAX_REFINED_SHARE`] — the
256///   uniform row tint already conveys "this changed", and marking
257///   nearly all of it adds a second colour saying the same thing;
258/// - either side exceeds [`MAX_REGION_BYTES`] (see that constant).
259pub fn refine_regions(removed: &[&str], added: &[&str]) -> RegionRefinement {
260    if removed.is_empty() || added.is_empty() || removed == added {
261        return RegionRefinement::default();
262    }
263    let rm_bytes: usize = removed.iter().map(|l| l.len()).sum();
264    let add_bytes: usize = added.iter().map(|l| l.len()).sum();
265    if rm_bytes > MAX_REGION_BYTES || add_bytes > MAX_REGION_BYTES {
266        return RegionRefinement::default();
267    }
268
269    let rm_tokens = tokenize_region(removed);
270    let add_tokens = tokenize_region(added);
271    if rm_tokens.is_empty() || add_tokens.is_empty() {
272        return RegionRefinement::default();
273    }
274
275    // Intern by hand rather than through `TokenSource`: that trait is
276    // implemented for whole-text sources (lines, chars), and our tokens
277    // are already computed. `InternedInput`'s fields are public for
278    // exactly this — "while you can intern tokens yourself" in its own
279    // docs — and it avoids a wrapper type existing only to satisfy a
280    // trait we do not otherwise need.
281    let mut interner: Interner<&str> = Interner::new(rm_tokens.len() + add_tokens.len());
282    let before: Vec<Token> = rm_tokens.iter().map(|t| interner.intern(t.text)).collect();
283    let after: Vec<Token> = add_tokens.iter().map(|t| interner.intern(t.text)).collect();
284    let input = InternedInput {
285        before,
286        after,
287        interner,
288    };
289    let (before_idx, after_idx) = imara_diff::diff(
290        Algorithm::Histogram,
291        &input,
292        TokenSink {
293            before: Vec::new(),
294            after: Vec::new(),
295        },
296    );
297
298    let mut refinement = RegionRefinement {
299        removed: to_line_ranges(&rm_tokens, &before_idx, removed.len()),
300        added: to_line_ranges(&add_tokens, &after_idx, added.len()),
301    };
302    drop_noisy_lines(&mut refinement.removed, removed);
303    drop_noisy_lines(&mut refinement.added, added);
304    if refinement.is_empty() {
305        return RegionRefinement::default();
306    }
307    refinement
308}
309
310/// Clear the refinement of any line more than [`MAX_REFINED_SHARE`]
311/// changed, leaving its neighbours alone.
312///
313/// **Per line, and per side** — DR.1 applied this per *pair* and
314/// required BOTH sides to come in under the bar, declining the pair
315/// outright otherwise. That coupling was an artifact of the pair being
316/// its unit, and carrying it into DR.5 actively breaks the case DR.5
317/// exists to fix: in an *n*-removed / *m*-added hunk the surplus added
318/// lines are wholly new **by definition**, so any region-wide or
319/// cross-side measure is dragged over the bar by lines that were never
320/// candidates for refinement in the first place.
321///
322/// Per line is also simply the right question. Refinement is *rendered*
323/// per line, so "does this emphasis tell the reader anything?" is asked
324/// of one line at a time: a wholly-new line is already fully tinted by
325/// its row, and whether some other line on the opposite side is mostly
326/// changed has no bearing on the line in front of you.
327fn drop_noisy_lines(per_line: &mut [Vec<Range<usize>>], lines: &[&str]) {
328    for (i, ranges) in per_line.iter_mut().enumerate() {
329        let len = lines.get(i).map(|l| l.len()).unwrap_or(0);
330        if len == 0 {
331            ranges.clear();
332            continue;
333        }
334        let covered: usize = ranges.iter().map(|r| r.end - r.start).sum();
335        if (covered as f64) / (len as f64) > MAX_REFINED_SHARE {
336            ranges.clear();
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn slice<'a>(line: &'a str, ranges: &[Range<usize>]) -> Vec<&'a str> {
346        ranges.iter().map(|r| &line[r.clone()]).collect()
347    }
348
349    /// Refine a one-line-against-one-line region and read both sides.
350    /// The balanced case is now just the degenerate region.
351    fn pair(removed: &str, added: &str) -> RegionRefinement {
352        refine_regions(&[removed], &[added])
353    }
354
355    // ── the single-line cases DR.1 established ───────────────────────
356    //
357    // Kept verbatim in intent: DR.5 must be a SUPERSET, so every
358    // balanced result these pinned has to survive the algorithm change.
359
360    #[test]
361    fn a_one_word_change_refines_to_that_word() {
362        let r = pair("let x = compute(a);", "let x = derive(a);");
363        assert_eq!(
364            slice("let x = compute(a);", r.removed_line(0)),
365            vec!["compute"]
366        );
367        assert_eq!(slice("let x = derive(a);", r.added_line(0)), vec!["derive"]);
368    }
369
370    /// The rename case: only the identifier moves, not the punctuation
371    /// around it. This is what word-level buys over character-level.
372    #[test]
373    fn a_rename_does_not_bleed_into_neighbours() {
374        let before = "foo(bar, baz)";
375        let after = "foo(qux, baz)";
376        let r = pair(before, after);
377        assert_eq!(slice(before, r.removed_line(0)), vec!["bar"]);
378        assert_eq!(slice(after, r.added_line(0)), vec!["qux"]);
379    }
380
381    #[test]
382    fn identical_lines_refine_to_nothing() {
383        assert!(pair("same", "same").is_empty());
384    }
385
386    /// If nearly everything changed, the uniform tint already said so.
387    #[test]
388    fn a_wholly_different_line_declines_refinement() {
389        assert!(pair("alpha beta gamma", "one two three four").is_empty());
390    }
391
392    /// A punctuation-only change still refines — the tokenizer gives
393    /// each non-word char its own token precisely so this works.
394    #[test]
395    fn a_punctuation_only_change_refines() {
396        let r = pair("a[i]", "a(i)");
397        assert!(!r.removed_line(0).is_empty() && !r.added_line(0).is_empty());
398    }
399
400    /// Adjacent changed tokens coalesce into one range rather than a
401    /// dotted line of separate highlights.
402    #[test]
403    fn adjacent_changed_tokens_coalesce() {
404        let r = pair("let value = 1;", "let other_name = 1;");
405        assert_eq!(
406            r.added_line(0).len(),
407            1,
408            "one contiguous highlight, got {:?}",
409            r.added_line(0)
410        );
411    }
412
413    /// Ranges must be sliceable from the original string — a panic
414    /// here would mean a boundary landed mid-codepoint.
415    #[test]
416    fn multibyte_ranges_land_on_char_boundaries() {
417        let before = "let gruß = 1;";
418        let after = "let grüße = 1;";
419        let r = pair(before, after);
420        // Slicing is the assertion: it panics on a bad boundary.
421        let _ = slice(before, r.removed_line(0));
422        let _ = slice(after, r.added_line(0));
423    }
424
425    #[test]
426    fn a_pure_addition_or_removal_refines_nothing() {
427        assert!(refine_regions(&[], &["brand new line"]).is_empty());
428        assert!(refine_regions(&["deleted line"], &[]).is_empty());
429    }
430
431    // ── DR.5: unbalanced regions ─────────────────────────────────────
432
433    /// **The reported case, verbatim.** One line rewritten with a
434    /// doc-comment block added above it — 1 removed against 12 added.
435    /// The old pairing rule declined this outright; it is the shape
436    /// "rewrite a line and document it" produces every time.
437    #[test]
438    fn one_removed_against_twelve_added_still_refines() {
439        let removed = ["#[derive(Debug, Clone, PartialEq, Eq, Serialize)]"];
440        let added = [
441            "/// Doc line one.",
442            "///",
443            "/// Doc line two.",
444            "/// Doc line three.",
445            "/// Doc line four.",
446            "/// Doc line five.",
447            "/// Doc line six.",
448            "/// Doc line seven.",
449            "/// Doc line eight.",
450            "/// Doc line nine.",
451            "/// Doc line ten.",
452            "#[derive(Debug, Clone, PartialEq, Serialize)]",
453        ];
454        let r = refine_regions(&removed, &added);
455        assert!(
456            !r.is_empty(),
457            "an unbalanced hunk must refine — this is the DR.5 bug"
458        );
459        // Which side of the comma the matcher attributes the deletion
460        // to (`Eq, ` vs `, Eq`) is a legitimate tokenisation choice and
461        // not worth pinning; that it marks the dropped derive, and only
462        // a few bytes of the line, is the claim.
463        let marked = slice(removed[0], r.removed_line(0)).concat();
464        assert!(
465            marked.contains("Eq"),
466            "the removed side marks the dropped derive, got {marked:?}"
467        );
468        assert!(
469            marked.len() <= 6,
470            "and marks only it, not the whole derive: {marked:?}"
471        );
472    }
473
474    /// The other captured case: 6 removed against 2 added still
475    /// refines, on both sides.
476    #[test]
477    fn six_removed_against_two_added_refines_on_both_sides() {
478        let removed = [
479            "/// Build the sources + excerpts for a set of changed files.",
480            "///",
481            "/// `files` is `(path, baseline_text)`; the working-tree text is read",
482            "/// from disk. Returns `None` when there is nothing to show — no",
483            "/// changed files, or every one unreadable.",
484            "///",
485        ];
486        let added = [
487            "/// One changed file, read and diffed: the working-tree text plus the",
488            "/// post-image ranges its hunks occupy.",
489        ];
490        let r = refine_regions(&removed, &added);
491        assert!(!r.is_empty(), "unbalanced region refines");
492        assert!(
493            r.removed.iter().any(|l| !l.is_empty()),
494            "the removed side carries ranges"
495        );
496        assert!(
497            r.added.iter().any(|l| !l.is_empty()),
498            "the added side carries ranges"
499        );
500    }
501
502    /// Every side's vec is exactly as long as its own line count —
503    /// consumers index by `line - range.start`, so a short vec would
504    /// silently drop the tail's refinement.
505    #[test]
506    fn each_side_is_indexed_by_its_own_line_count() {
507        let r = refine_regions(&["a = 1;"], &["a = 2;", "b = 3;", "c = 4;"]);
508        assert_eq!(r.removed.len(), 1);
509        assert_eq!(r.added.len(), 3);
510    }
511
512    /// A range must never straddle a line break: each token carries its
513    /// own line, and the synthetic separator is dropped on the way out.
514    #[test]
515    fn ranges_never_straddle_a_line_boundary() {
516        let removed = ["alpha one;", "beta two;"];
517        let added = ["alpha ONE;", "beta TWO;"];
518        let r = refine_regions(&removed, &added);
519        for (i, line) in removed.iter().enumerate() {
520            for range in r.removed_line(i) {
521                assert!(
522                    range.end <= line.len(),
523                    "range {range:?} runs past line {i} ({line:?})"
524                );
525            }
526        }
527        for (i, line) in added.iter().enumerate() {
528            for range in r.added_line(i) {
529                assert!(range.end <= line.len(), "range {range:?} past line {i}");
530            }
531        }
532    }
533
534    /// Indexing past either side is empty, not a panic — a consumer
535    /// walking a baseline range wider than the refinement degrades.
536    #[test]
537    fn indexing_past_the_end_is_empty_not_a_panic() {
538        let r = refine_regions(&["a = 1;"], &["a = 2;"]);
539        assert!(r.removed_line(99).is_empty());
540        assert!(r.added_line(99).is_empty());
541    }
542
543    /// A wholly-rewritten region still declines, measured over the
544    /// region rather than per line.
545    #[test]
546    fn a_wholly_rewritten_region_declines() {
547        let r = refine_regions(
548            &["alpha beta gamma", "delta epsilon zeta"],
549            &["one two three", "four five six"],
550        );
551        assert!(r.is_empty());
552    }
553
554    /// A line barely touched keeps its refinement even when a
555    /// neighbour in the same region changed a lot — the region-level
556    /// threshold must not be an all-or-nothing per-line gate.
557    #[test]
558    fn a_small_change_survives_beside_a_larger_one() {
559        let removed = [
560            "let alpha = compute(a);",
561            "let beta = compute(b);",
562            "let gamma = compute(c);",
563        ];
564        let added = [
565            "let alpha = derive(a);",
566            "let beta = compute(b);",
567            "let gamma = compute(c);",
568        ];
569        let r = refine_regions(&removed, &added);
570        assert_eq!(slice(removed[0], r.removed_line(0)), vec!["compute"]);
571    }
572
573    /// The size cap declines rather than diffing an enormous region.
574    #[test]
575    fn an_enormous_region_declines_without_diffing() {
576        let huge = "x".repeat(MAX_REGION_BYTES + 1);
577        let r = refine_regions(&[huge.as_str()], &["small"]);
578        assert!(r.is_empty());
579    }
580}