1use rustc_hash::{FxHashMap, FxHashSet};
5
6#[derive(Debug, Clone, PartialEq, Eq, Hash)]
8pub struct CloneSpan {
9 pub path: String,
11 pub start_line: u32,
13 pub end_line: u32,
15}
16
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct BaseCloneGroup {
21 pub instances: Vec<CloneSpan>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct DivergedClone {
28 pub path: String,
30 pub line: Option<u32>,
33 pub siblings: Vec<CloneSpan>,
35}
36
37pub struct CloneTexts<'a> {
39 pub base: &'a mut dyn FnMut(&str) -> Option<String>,
41 pub head: &'a mut dyn FnMut(&str) -> Option<String>,
43}
44
45#[must_use]
52#[expect(
53 clippy::implicit_hasher,
54 reason = "fallow standardizes on FxHashSet across the workspace"
55)]
56pub fn diverged_clone_siblings(
57 base_groups: &[BaseCloneGroup],
58 head_groups: &[Vec<CloneSpan>],
59 changed: &FxHashSet<String>,
60 renames: &FxHashMap<String, String>,
61 texts: &mut CloneTexts<'_>,
62) -> Vec<DivergedClone> {
63 let mut base_cache: FxHashMap<String, Option<String>> = FxHashMap::default();
64 let mut head_cache: FxHashMap<String, Option<String>> = FxHashMap::default();
65 let mut found = Vec::new();
66 for group in base_groups {
67 if group.instances.len() < 2 {
68 continue;
69 }
70 let mut edited: Vec<(CloneSpan, Option<u32>)> = Vec::new();
71 let mut siblings: Vec<CloneSpan> = Vec::new();
72 let mut skip_group = false;
73 for instance in &group.instances {
74 let head_path = renames
75 .get(&instance.path)
76 .cloned()
77 .unwrap_or_else(|| instance.path.clone());
78 let head_span = CloneSpan {
79 path: head_path.clone(),
80 ..instance.clone()
81 };
82 if !changed.contains(&head_path) {
83 siblings.push(head_span);
84 continue;
85 }
86 let base_text = base_cache
87 .entry(instance.path.clone())
88 .or_insert_with(|| (texts.base)(&instance.path));
89 let Some(fragment) = base_text
90 .as_deref()
91 .map(|text| fragment_lines(text, instance))
92 else {
93 skip_group = true;
94 break;
95 };
96 let head_text = head_cache
97 .entry(head_path.clone())
98 .or_insert_with(|| (texts.head)(&head_path));
99 let Some(head_text) = head_text.as_deref() else {
100 skip_group = true;
102 break;
103 };
104 match classify_copy(&fragment, head_text) {
105 CopyState::Unchanged(lines) => siblings.push(match lines {
106 Some((start_line, end_line)) => CloneSpan {
107 start_line,
108 end_line,
109 ..head_span
110 },
111 None => head_span,
112 }),
113 CopyState::Edited(line) => edited.push((head_span, line)),
114 CopyState::Removed => {
115 skip_group = true;
116 break;
117 }
118 }
119 }
120 if skip_group || edited.len() != 1 || siblings.is_empty() {
121 continue;
122 }
123 let (copy, line) = edited.remove(0);
124 if still_clones(head_groups, ©.path, &siblings) {
125 continue;
126 }
127 found.push(DivergedClone {
128 path: copy.path,
129 line,
130 siblings,
131 });
132 }
133 merge_same_copy(found)
134}
135
136fn merge_same_copy(found: Vec<DivergedClone>) -> Vec<DivergedClone> {
139 let mut merged: Vec<DivergedClone> = Vec::new();
140 for diverged in found {
141 if let Some(existing) = merged
142 .iter_mut()
143 .find(|existing| existing.path == diverged.path && existing.line == diverged.line)
144 {
145 existing.siblings.extend(diverged.siblings);
146 } else {
147 merged.push(diverged);
148 }
149 }
150 for diverged in &mut merged {
151 diverged
152 .siblings
153 .sort_by(|a, b| a.path.cmp(&b.path).then(a.start_line.cmp(&b.start_line)));
154 diverged
155 .siblings
156 .dedup_by(|a, b| a.path == b.path && a.start_line == b.start_line);
157 }
158 merged.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
159 merged
160}
161
162enum CopyState {
163 Unchanged(Option<(u32, u32)>),
167 Edited(Option<u32>),
170 Removed,
172}
173
174fn fragment_lines(text: &str, span: &CloneSpan) -> Vec<String> {
176 let start = usize::try_from(span.start_line.saturating_sub(1)).unwrap_or(usize::MAX);
177 let count = usize::try_from(span.end_line.saturating_sub(span.start_line) + 1).unwrap_or(0);
178 text.lines()
179 .skip(start)
180 .take(count)
181 .map(str::trim)
182 .filter(|line| !line.is_empty())
183 .map(str::to_owned)
184 .collect()
185}
186
187fn classify_copy(fragment: &[String], head_text: &str) -> CopyState {
188 if fragment.is_empty() {
189 return CopyState::Unchanged(None);
190 }
191 let numbered: Vec<(u32, &str)> = head_text
193 .lines()
194 .enumerate()
195 .map(|(index, line)| (index, line.trim()))
196 .filter(|(_, line)| !line.is_empty())
197 .filter_map(|(index, line)| u32::try_from(index + 1).ok().map(|number| (number, line)))
198 .collect();
199 let head_lines: Vec<&str> = numbered.iter().map(|(_, line)| *line).collect();
200 let verbatim = head_lines.windows(fragment.len()).position(|window| {
201 window
202 .iter()
203 .zip(fragment)
204 .all(|(head, base)| *head == base)
205 });
206 if let Some(start) = verbatim {
207 let first = numbered[start].0;
208 let last = numbered[start + fragment.len() - 1].0;
209 return CopyState::Unchanged(Some((first, last)));
210 }
211 let head_set: FxHashSet<&str> = head_lines.iter().copied().collect();
212 let surviving = fragment
213 .iter()
214 .filter(|line| head_set.contains(line.as_str()))
215 .count();
216 if surviving * 2 < fragment.len() {
217 return CopyState::Removed;
218 }
219 let first_line = fragment
220 .iter()
221 .find(|line| head_set.contains(line.as_str()))
222 .and_then(|line| {
223 head_text
224 .lines()
225 .position(|head| head.trim() == line)
226 .and_then(|index| u32::try_from(index + 1).ok())
227 });
228 CopyState::Edited(first_line)
229}
230
231fn still_clones(head_groups: &[Vec<CloneSpan>], copy_path: &str, siblings: &[CloneSpan]) -> bool {
235 head_groups.iter().any(|group| {
236 group.iter().any(|instance| instance.path == copy_path)
237 && group.iter().any(|instance| {
238 siblings.iter().any(|sibling| {
239 sibling.path == instance.path
240 && instance.start_line <= sibling.start_line
241 && sibling.end_line <= instance.end_line
242 })
243 })
244 })
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 const COPY: &str = "export function total(items) {\n let sum = 0;\n for (const item of items) {\n sum += item.price * item.quantity;\n }\n return sum;\n}\n";
252 const EDITED: &str = "export function total(items) {\n let sum = 0;\n for (const item of items) {\n sum += item.price * item.quantity * (1 - item.discount);\n }\n return sum;\n}\n";
253
254 fn span(path: &str) -> CloneSpan {
255 CloneSpan {
256 path: path.to_owned(),
257 start_line: 1,
258 end_line: 7,
259 }
260 }
261
262 fn group() -> Vec<BaseCloneGroup> {
263 vec![BaseCloneGroup {
264 instances: vec![span("src/a.ts"), span("src/b.ts")],
265 }]
266 }
267
268 fn run(
269 changed: &[&str],
270 head_a: Option<&str>,
271 head_b: &str,
272 head_groups: &[Vec<CloneSpan>],
273 ) -> Vec<DivergedClone> {
274 let changed: FxHashSet<String> = changed.iter().map(|path| (*path).to_owned()).collect();
275 let head_a = head_a.map(str::to_owned);
276 let head_b = head_b.to_owned();
277 let mut base = |_: &str| Some(COPY.to_owned());
278 let mut head = move |path: &str| match path {
279 "src/a.ts" => head_a.clone(),
280 _ => Some(head_b.clone()),
281 };
282 diverged_clone_siblings(
283 &group(),
284 head_groups,
285 &changed,
286 &FxHashMap::default(),
287 &mut CloneTexts {
288 base: &mut base,
289 head: &mut head,
290 },
291 )
292 }
293
294 #[test]
295 fn edited_copy_with_unchanged_sibling_is_reported() {
296 let found = run(&["src/a.ts"], Some(EDITED), COPY, &[]);
297 assert_eq!(found.len(), 1);
298 assert_eq!(found[0].path, "src/a.ts");
299 assert_eq!(found[0].line, Some(1));
300 assert_eq!(found[0].siblings, vec![span("src/b.ts")]);
301 }
302
303 #[test]
304 fn both_copies_edited_is_not_reported() {
305 assert!(run(&["src/a.ts", "src/b.ts"], Some(EDITED), EDITED, &[]).is_empty());
306 }
307
308 #[test]
309 fn edit_that_keeps_the_clone_is_not_reported() {
310 let head_groups = vec![vec![span("src/a.ts"), span("src/b.ts")]];
311 assert!(run(&["src/a.ts"], Some(EDITED), COPY, &head_groups).is_empty());
312 }
313
314 #[test]
315 fn edit_that_shrinks_the_clone_to_a_prefix_is_reported() {
316 let prefix = |path: &str| CloneSpan {
317 path: path.to_owned(),
318 start_line: 1,
319 end_line: 3,
320 };
321 let head_groups = vec![vec![prefix("src/a.ts"), prefix("src/b.ts")]];
322 assert_eq!(
323 run(&["src/a.ts"], Some(EDITED), COPY, &head_groups).len(),
324 1
325 );
326 }
327
328 #[test]
329 fn two_groups_on_one_edited_copy_report_once_with_every_sibling() {
330 let short = |path: &str| CloneSpan {
331 path: path.to_owned(),
332 start_line: 1,
333 end_line: 6,
334 };
335 let groups = vec![
336 BaseCloneGroup {
337 instances: vec![span("src/a.ts"), span("src/b.ts")],
338 },
339 BaseCloneGroup {
340 instances: vec![short("src/a.ts"), short("src/c.ts")],
341 },
342 ];
343 let changed: FxHashSet<String> = std::iter::once("src/a.ts".to_owned()).collect();
344 let mut base = |_: &str| Some(COPY.to_owned());
345 let mut head = |path: &str| Some(if path == "src/a.ts" { EDITED } else { COPY }.to_owned());
346 let found = diverged_clone_siblings(
347 &groups,
348 &[],
349 &changed,
350 &FxHashMap::default(),
351 &mut CloneTexts {
352 base: &mut base,
353 head: &mut head,
354 },
355 );
356 assert_eq!(found.len(), 1);
357 let paths: Vec<&str> = found[0].siblings.iter().map(|s| s.path.as_str()).collect();
358 assert_eq!(paths, vec!["src/b.ts", "src/c.ts"]);
359 }
360
361 const SHIFTED: &str = "import { round } from './round';\n";
362
363 fn shifted(path: &str) -> CloneSpan {
364 CloneSpan {
365 path: path.to_owned(),
366 start_line: 2,
367 end_line: 8,
368 }
369 }
370
371 #[test]
372 fn sibling_shifted_in_a_changed_file_that_still_clones_is_not_reported() {
373 let head_b = format!("{SHIFTED}{COPY}");
374 let head_groups = vec![vec![span("src/a.ts"), shifted("src/b.ts")]];
375 assert!(
376 run(
377 &["src/a.ts", "src/b.ts"],
378 Some(EDITED),
379 &head_b,
380 &head_groups
381 )
382 .is_empty()
383 );
384 }
385
386 #[test]
387 fn sibling_shifted_in_a_changed_file_reports_its_head_lines() {
388 let head_b = format!("{SHIFTED}{COPY}");
389 let found = run(&["src/a.ts", "src/b.ts"], Some(EDITED), &head_b, &[]);
390 assert_eq!(found.len(), 1);
391 assert_eq!(found[0].siblings, vec![shifted("src/b.ts")]);
392 }
393
394 #[test]
395 fn change_elsewhere_in_the_file_is_not_reported() {
396 let head = format!("{COPY}export const other = 1;\n");
397 assert!(run(&["src/a.ts"], Some(&head), COPY, &[]).is_empty());
398 }
399
400 #[test]
401 fn removed_or_deleted_copy_is_not_reported() {
402 assert!(run(&["src/a.ts"], Some("export const x = 1;\n"), COPY, &[]).is_empty());
403 assert!(run(&["src/a.ts"], None, COPY, &[]).is_empty());
404 }
405}