1use std::cmp::Reverse;
14use std::collections::HashMap;
15
16use sha1::{Digest, Sha1};
17
18use crate::lang::{Language, LanguageRegistry};
19use crate::model::{DiffView, Hunk};
20
21pub fn norm_lines(lines: &[Vec<u8>], sigil: u8, lang: &dyn Language) -> Vec<Vec<u8>> {
23 let mut out: Vec<Vec<u8>> = lines
24 .iter()
25 .map(|l| {
26 let norm = lang.normalize_line(l);
27 let mut v = Vec::with_capacity(norm.len() + 1);
28 v.push(sigil);
29 v.extend_from_slice(&norm);
30 v
31 })
32 .collect();
33 out.sort_unstable();
34 out
35}
36
37pub fn shape_hash(
62 hunk: &Hunk,
63 disposition_letter: u8,
64 generated: bool,
65 lang: &dyn Language,
66) -> String {
67 let mut parts = norm_lines(&hunk.removed, b'-', lang);
68 parts.extend(norm_lines(&hunk.added, b'+', lang));
69 let mut hasher = Sha1::new();
70 for (i, p) in parts.iter().enumerate() {
71 if i > 0 {
72 hasher.update(b"\n");
73 }
74 hasher.update(p);
75 }
76 hasher.update(b"|");
77 hasher.update([disposition_letter]);
78 hasher.update(b"|");
79 hasher.update([if generated { b'g' } else { b'.' }]);
80 hex::encode(hasher.finalize())[..12].to_string()
81}
82
83pub fn hunk_digest(hunk: &Hunk) -> String {
87 let mut hasher = Sha1::new();
88 for l in &hunk.removed {
89 hasher.update(b"-");
90 hasher.update(l);
91 hasher.update(b"\n");
92 }
93 for l in &hunk.added {
94 hasher.update(b"+");
95 hasher.update(l);
96 hasher.update(b"\n");
97 }
98 hasher.update([hunk.nonl_old as u8, hunk.nonl_new as u8]);
99 hex::encode(hasher.finalize())
100}
101
102pub fn pure_substitution(hunk: &Hunk, lang: &dyn Language) -> bool {
107 if hunk.removed.is_empty() || hunk.added.is_empty() {
108 return false;
109 }
110 norm_lines(&hunk.removed, b' ', lang) == norm_lines(&hunk.added, b' ', lang)
112}
113
114pub struct Partition {
119 pub classes: Vec<Vec<usize>>,
121 pub class_of: Vec<usize>,
123 pub pure: Vec<bool>,
125}
126
127pub fn partition(view: &DiffView, langs: &LanguageRegistry) -> Partition {
128 let mut by_hash: HashMap<String, Vec<usize>> = HashMap::new();
129 let mut first_seen: HashMap<String, usize> = HashMap::new();
130 for (i, h) in view.hunks.iter().enumerate() {
131 let file = view.file_of(h);
132 let lang = langs.detect(&file.path);
133 let key = shape_hash(h, file.disposition.letter(), file.generated.is_some(), lang);
134 first_seen.entry(key.clone()).or_insert(i);
135 by_hash.entry(key).or_default().push(i);
136 }
137
138 let mut keys: Vec<&String> = by_hash.keys().collect();
139 keys.sort_by_key(|k| (Reverse(by_hash[*k].len()), first_seen[*k]));
140
141 let mut classes = Vec::with_capacity(keys.len());
142 let mut class_of = vec![0usize; view.hunks.len()];
143 let mut pure = Vec::with_capacity(keys.len());
144 for (ci, k) in keys.iter().enumerate() {
145 let members = by_hash[*k].clone();
146 for &hi in &members {
147 class_of[hi] = ci;
148 }
149 let exemplar = &view.hunks[members[0]];
150 let lang = langs.detect(&view.file_of(exemplar).path);
151 pure.push(pure_substitution(exemplar, lang));
152 classes.push(members);
153 }
154 Partition {
155 classes,
156 class_of,
157 pure,
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::lang::Generic;
165 use crate::model::Hunk;
166
167 const G: &Generic = &Generic;
168
169 fn hunk(removed: &[&[u8]], added: &[&[u8]]) -> Hunk {
170 Hunk {
171 file: 0,
172 old_start: 1,
173 old_count: removed.len() as u32,
174 new_start: 1,
175 new_count: added.len() as u32,
176 removed: removed.iter().map(|l| l.to_vec()).collect(),
177 added: added.iter().map(|l| l.to_vec()).collect(),
178 nonl_old: false,
179 nonl_new: false,
180 }
181 }
182
183 #[test]
184 fn identifier_renames_share_a_shape() {
185 let a = hunk(
186 &[b" let total_count = compute_total(items);"],
187 &[b" let total_count = compute_sum(items);"],
188 );
189 let b = hunk(
190 &[b" let grand_total = derive_total(rows);"],
191 &[b" let grand_total = derive_result(rows);"],
192 );
193 assert_eq!(
194 shape_hash(&a, b'M', false, G),
195 shape_hash(&b, b'M', false, G)
196 );
197 }
198
199 #[test]
200 fn literals_are_normalised() {
201 let a = hunk(
202 &[br#" retry(5, "backoff")"#],
203 &[br#" retry(9, "linear")"#],
204 );
205 let b = hunk(&[br#" retry(12, "other")"#], &[br#" retry(3, "words")"#]);
206 assert_eq!(
207 shape_hash(&a, b'M', false, G),
208 shape_hash(&b, b'M', false, G)
209 );
210 }
211
212 #[test]
213 fn deletion_only_hunks_with_different_content_differ() {
214 let a = hunk(&[b"fn compute_interest(rate: f64) -> f64 {"], &[]);
216 let b = hunk(&[b"const RETRY_LIMIT: usize = 5;"], &[]);
217 assert_ne!(
218 shape_hash(&a, b'M', false, G),
219 shape_hash(&b, b'M', false, G)
220 );
221 }
222
223 #[test]
224 fn disposition_is_part_of_the_key() {
225 let a = hunk(&[], &[b"content line here"]);
226 assert_ne!(
227 shape_hash(&a, b'A', false, G),
228 shape_hash(&a, b'M', false, G)
229 );
230 }
231
232 #[test]
233 fn a_generated_file_never_shares_a_class_with_a_source_file() {
234 let a = hunk(&[b"old = 1"], &[b"new = 2"]);
238 assert_ne!(
239 shape_hash(&a, b'M', true, G),
240 shape_hash(&a, b'M', false, G)
241 );
242 }
243
244 #[test]
245 fn crlf_agnostic_normalisation() {
246 let unix = hunk(&[b"old_value_name = 1"], &[b"new_value_name = 1"]);
247 let dos = hunk(&[b"old_value_name = 1\r"], &[b"new_value_name = 1\r"]);
248 assert_eq!(
249 shape_hash(&unix, b'M', false, G),
250 shape_hash(&dos, b'M', false, G)
251 );
252 }
253
254 #[test]
255 fn short_identifiers_survive_normalisation() {
256 let a = hunk(&[b"x = 1"], &[b"y = 1"]);
258 let b = hunk(&[b"y = 1"], &[b"x = 1"]);
259 assert_ne!(
260 shape_hash(&a, b'M', false, G),
261 shape_hash(&b, b'M', false, G)
262 );
263 }
264
265 #[test]
266 fn pure_substitution_detects_rename() {
267 let h = hunk(
268 &[b"if !self.mail_service.is_enabled() {"],
269 &[b"if !self.system_notifier.is_enabled() {"],
270 );
271 assert!(pure_substitution(&h, G));
272 }
273
274 #[test]
275 fn structural_change_is_not_pure() {
276 let h = hunk(
277 &[b"send(user_address)"],
278 &[b"send(user_address, RetryPolicy::default())"],
279 );
280 assert!(!pure_substitution(&h, G));
281 }
282
283 #[test]
284 fn insertion_only_is_never_pure() {
285 let h = hunk(&[], &[b"brand_new_line()"]);
286 assert!(!pure_substitution(&h, G));
287 }
288
289 #[test]
290 fn digest_is_exact_not_normalised() {
291 let a = hunk(&[b"alpha_name = 1"], &[b"beta_name = 1"]);
292 let b = hunk(&[b"gamma_name = 1"], &[b"delta_name = 1"]);
293 assert_eq!(
295 shape_hash(&a, b'M', false, G),
296 shape_hash(&b, b'M', false, G)
297 );
298 assert_ne!(hunk_digest(&a), hunk_digest(&b));
299 }
300
301 #[test]
302 fn digest_covers_nonl_flags() {
303 let a = hunk(&[b"line"], &[b"line"]);
304 let mut b = a.clone();
305 b.nonl_new = true;
306 assert_ne!(hunk_digest(&a), hunk_digest(&b));
307 }
308
309 #[test]
310 fn language_override_changes_classification_but_not_digest() {
311 use crate::lang::Language;
312 struct Flattener;
313 impl Language for Flattener {
314 fn id(&self) -> &'static str {
315 "flatten-v1"
316 }
317 fn claims(&self, _p: &[u8]) -> bool {
318 true
319 }
320 fn normalize_line(&self, _l: &[u8]) -> Vec<u8> {
321 b"X".to_vec()
322 }
323 }
324 let a = hunk(&[b"completely unlike"], &[b"anything else at all"]);
325 let b = hunk(&[b"nothing shared here"], &[b"with the other hunk"]);
326 assert_ne!(
328 shape_hash(&a, b'M', false, G),
329 shape_hash(&b, b'M', false, G)
330 );
331 assert_eq!(
332 shape_hash(&a, b'M', false, &Flattener),
333 shape_hash(&b, b'M', false, &Flattener)
334 );
335 assert_ne!(hunk_digest(&a), hunk_digest(&b));
336 }
337}