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}