1use similar::TextDiff;
8
9use crate::diff::intraline;
10use crate::model::{DiffLine, FileDiff, Hunk, LineKind};
11
12const MAX_EMPHASIS_SHARE: f32 = 0.7;
18
19const MAX_EMPHASIS_RUNS: usize = 3;
24
25pub(crate) fn emphasis_is_punctual(text: &str, ranges: &[std::ops::Range<usize>]) -> bool {
30 #[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 content > 0
51 && ranges.len() <= MAX_EMPHASIS_RUNS
52 && (emphasized <= 2 || share(emphasized) < share(content) * MAX_EMPHASIS_SHARE)
53}
54
55pub(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
69pub 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
93const MIN_PAIR_RATIO: f32 = 0.5;
99
100const MAX_PAIR_TABLE: usize = 1024;
105
106pub(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#[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 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 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
187const MAX_PAIR_LINE_BYTES: usize = 1024;
191
192fn 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 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()); assert_eq!(h.lines[2].emphasis, vec![10..11]);
239 }
240
241 #[test]
242 fn emphasis_is_punctual_separates_edits_from_rewrites() {
243 assert!(emphasis_is_punctual(
245 "let x = compute();",
246 std::slice::from_ref(&(8..15))
247 ));
248 assert!(emphasis_is_punctual("41", std::slice::from_ref(&(1..2))));
250 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 let three = vec![6..9, 15..18, 25..30];
263 assert!(emphasis_is_punctual(text, &three));
264 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 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 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 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 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 #[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}