1use similar::TextDiff;
6
7use crate::diff::intraline;
8use crate::model::{DiffLine, FileDiff, Hunk, LineKind};
9
10const MAX_EMPHASIS_SHARE: f32 = 0.7;
13
14const MAX_EMPHASIS_RUNS: usize = 3;
17
18pub(crate) fn emphasis_is_punctual(text: &str, ranges: &[std::ops::Range<usize>]) -> bool {
21 #[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 content > 0
40 && ranges.len() <= MAX_EMPHASIS_RUNS
41 && (emphasized <= 2 || share(emphasized) < share(content) * MAX_EMPHASIS_SHARE)
42}
43
44pub(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
78const MIN_PAIR_RATIO: f32 = 0.5;
81
82const MAX_PAIR_TABLE: usize = 1024;
85
86pub(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#[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 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 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
165const MAX_PAIR_LINE_BYTES: usize = 1024;
168
169fn 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 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 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}