1use rayon::prelude::*;
4use rustc_hash::{FxHashMap, FxHashSet};
5use std::path::{Path, PathBuf};
6
7use crate::{
8 hash::{base_pow, hash_window, roll, token_hash},
9 models::{
10 CloneKind, CpdClone, DetectionToken, Fragment, Location, SimilarityMethod, SourceFile,
11 TokenKind,
12 },
13};
14
15type WindowStore = FxHashMap<u64, Occurrence>;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25struct Occurrence {
26 source_id: usize,
28 token_start: usize,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36struct CloneDedupKey {
37 a_id: String,
38 a_start_line: u32,
39 b_id: String,
40 b_start_line: u32,
41}
42
43impl CloneDedupKey {
44 fn from_clone(c: &CpdClone) -> Self {
45 let a_key = (&c.fragment_a.source_id, c.fragment_a.start.line);
47 let b_key = (&c.fragment_b.source_id, c.fragment_b.start.line);
48 if a_key <= b_key {
49 Self {
50 a_id: c.fragment_a.source_id.clone(),
51 a_start_line: c.fragment_a.start.line,
52 b_id: c.fragment_b.source_id.clone(),
53 b_start_line: c.fragment_b.start.line,
54 }
55 } else {
56 Self {
57 a_id: c.fragment_b.source_id.clone(),
58 a_start_line: c.fragment_b.start.line,
59 b_id: c.fragment_a.source_id.clone(),
60 b_start_line: c.fragment_a.start.line,
61 }
62 }
63 }
64}
65
66pub fn detect(files: &[SourceFile], min_tokens: usize) -> Vec<CpdClone> {
75 detect_with_options(files, min_tokens, 0, &PathFilters::default())
76}
77
78pub fn detect_with_options(
85 files: &[SourceFile],
86 min_tokens: usize,
87 min_lines: usize,
88 filters: &PathFilters,
89) -> Vec<CpdClone> {
90 if files.is_empty() || min_tokens == 0 {
91 return vec![];
92 }
93
94 let mut by_format: FxHashMap<&str, Vec<&SourceFile>> = FxHashMap::default();
96 for file in files {
97 by_format
98 .entry(file.format.as_str())
99 .or_default()
100 .push(file);
101 }
102 let mut format_groups: Vec<(&str, Vec<&SourceFile>)> = by_format.into_iter().collect();
103 format_groups.sort_unstable_by_key(|(fmt, _)| *fmt);
104 for (_, group) in &mut format_groups {
105 group.sort_unstable_by_key(|&file| file.id.as_str());
106 }
107
108 let mut clones: Vec<CpdClone> = format_groups
109 .into_par_iter()
110 .flat_map(|(_format, files)| {
111 let prepared: Vec<PreparedSource> = files
115 .into_iter()
116 .map(|file| {
117 let mut hashes = Vec::with_capacity(file.tokens.len());
118 let mut spans: Vec<(Location, Location)> =
119 Vec::with_capacity(file.tokens.len());
120 for t in &file.tokens {
121 if t.kind == TokenKind::Ignore {
122 continue;
123 }
124 hashes.push(token_hash(t.kind.discriminant(), &t.value));
125 spans.push((t.start.clone(), t.end.clone()));
126 }
127 PreparedSource {
128 id: file.id.clone(),
129 format: file.format.clone(),
130 hashes,
131 spans,
132 raw_hashes: Vec::new(),
133 functions: Vec::new(),
134 }
135 })
136 .collect();
137 detect_in_group(&prepared, min_tokens, min_lines, filters)
138 })
139 .collect();
140
141 finalize_clones(&mut clones);
142 clones
143}
144
145fn finalize_clones(clones: &mut Vec<CpdClone>) {
146 dedup_exact_clones(clones);
147 clones.sort_by(|a, b| {
148 (
149 &a.fragment_a.source_id,
150 a.fragment_a.start.line,
151 &a.fragment_b.source_id,
152 a.fragment_b.start.line,
153 )
154 .cmp(&(
155 &b.fragment_a.source_id,
156 b.fragment_a.start.line,
157 &b.fragment_b.source_id,
158 b.fragment_b.start.line,
159 ))
160 });
161}
162
163#[derive(Debug, Clone)]
172pub struct PreparedSource {
173 pub id: String,
174 pub format: String,
175 pub hashes: Vec<u64>,
176 pub spans: Vec<(Location, Location)>,
177 pub raw_hashes: Vec<u64>,
181 pub functions: Vec<crate::similarity::FunctionSig>,
184}
185
186impl PreparedSource {
187 pub fn from_detection_tokens(id: String, format: String, tokens: &[DetectionToken]) -> Self {
189 let mut hashes = Vec::with_capacity(tokens.len());
190 let mut spans = Vec::with_capacity(tokens.len());
191 let mut raw_hashes: Vec<u64> = Vec::new();
194 for (i, t) in tokens.iter().enumerate() {
195 hashes.push(t.hash);
196 spans.push((t.start.clone(), t.end.clone()));
197 if raw_hashes.is_empty() && t.raw_hash != t.hash {
198 raw_hashes.reserve(tokens.len());
199 raw_hashes.extend(hashes[..i].iter().copied());
200 }
201 if !raw_hashes.is_empty() {
202 raw_hashes.push(t.raw_hash);
203 }
204 }
205 Self {
206 id,
207 format,
208 hashes,
209 spans,
210 raw_hashes,
211 functions: Vec::new(),
212 }
213 }
214}
215
216pub fn detect_prepared(
221 format_groups: Vec<Vec<PreparedSource>>,
222 min_tokens: usize,
223 min_lines: usize,
224 filters: &PathFilters,
225) -> Vec<CpdClone> {
226 if format_groups.is_empty() || min_tokens == 0 {
227 return vec![];
228 }
229
230 let mut clones: Vec<CpdClone> = format_groups
231 .into_par_iter()
232 .flat_map(|group| detect_in_group(&group, min_tokens, min_lines, filters))
233 .collect();
234
235 finalize_clones(&mut clones);
236 clones
237}
238
239fn detect_in_group(
244 prepared: &[PreparedSource],
245 min_tokens: usize,
246 min_lines: usize,
247 filters: &PathFilters,
248) -> Vec<CpdClone> {
249 let window_power = base_pow(min_tokens.saturating_sub(1));
252
253 let total_windows: usize = prepared
255 .iter()
256 .map(|p| p.hashes.len().saturating_sub(min_tokens))
257 .sum();
258 let mut store: WindowStore =
259 FxHashMap::with_capacity_and_hasher(total_windows, Default::default());
260
261 let mut clones: Vec<CpdClone> = Vec::new();
262 const SECONDARY_OCCURRENCE_CAP: usize = 2;
266 let mut repeated_windows: FxHashMap<u64, Vec<Occurrence>> = FxHashMap::default();
267
268 for (file_idx, source) in prepared.iter().enumerate() {
269 let hashes = &source.hashes;
270 if hashes.len() < min_tokens {
271 continue;
272 }
273 let windows_len = hashes.len() - min_tokens + 1;
274
275 let mut open_clone: Option<OpenClone> = None;
280
281 let mut window_hash = hash_window(&hashes[..min_tokens]);
282
283 for token_start in 0..windows_len {
284 if token_start > 0 {
285 window_hash = roll(
286 window_hash,
287 hashes[token_start - 1],
288 hashes[token_start + min_tokens - 1],
289 window_power,
290 );
291 }
292
293 let current = Occurrence {
294 source_id: file_idx,
295 token_start,
296 };
297
298 match store.get(&window_hash).copied() {
299 Some(stored) if windows_match(stored, current, prepared, min_tokens) => {
300 if open_clone.is_none() {
301 open_clone = Some(OpenClone {
302 stored_occurrence: stored,
303 current_start: token_start,
304 match_len: min_tokens,
305 });
306 } else if let Some(ref mut oc) = open_clone {
307 oc.match_len += 1;
309 }
310 remember_repeated_window(
311 &mut repeated_windows,
312 window_hash,
313 stored,
314 SECONDARY_OCCURRENCE_CAP,
315 );
316 remember_repeated_window(
317 &mut repeated_windows,
318 window_hash,
319 current,
320 SECONDARY_OCCURRENCE_CAP,
321 );
322 }
325 _ => {
326 flush_clone(
328 open_clone.take(),
329 file_idx,
330 prepared,
331 min_lines,
332 filters,
333 &mut clones,
334 );
335 store.insert(window_hash, current);
336 }
337 }
338 }
339
340 flush_clone(
342 open_clone.take(),
343 file_idx,
344 prepared,
345 min_lines,
346 filters,
347 &mut clones,
348 );
349 }
350
351 add_secondary_clones(
352 repeated_windows,
353 prepared,
354 min_tokens,
355 min_lines,
356 filters,
357 &mut clones,
358 );
359
360 clones
361}
362
363#[derive(Debug, Default, Clone, Copy)]
372pub struct PathFilters<'a> {
373 pub skip_local: bool,
376 pub scan_roots: &'a [PathBuf],
378 pub isolated_groups: &'a [Vec<PathBuf>],
382}
383
384impl PathFilters<'_> {
385 fn should_skip(&self, file_a: &str, file_b: &str) -> bool {
387 (self.skip_local && should_skip_local(file_a, file_b, self.scan_roots))
388 || should_skip_isolated(file_a, file_b, self.isolated_groups)
389 }
390}
391
392fn should_skip_local(file_a: &str, file_b: &str, scan_roots: &[PathBuf]) -> bool {
396 scan_roots
397 .iter()
398 .any(|root| is_relative_to(file_a, root) && is_relative_to(file_b, root))
399}
400
401fn should_skip_isolated(file_a: &str, file_b: &str, isolated_groups: &[Vec<PathBuf>]) -> bool {
406 isolated_groups.iter().any(|group| {
407 let Some(dir_a) = group.iter().find(|dir| is_relative_to(file_a, dir)) else {
408 return false;
409 };
410 group
411 .iter()
412 .find(|dir| is_relative_to(file_b, dir))
413 .is_some_and(|dir_b| dir_a != dir_b)
414 })
415}
416
417fn is_relative_to(file_path: &str, dir: &PathBuf) -> bool {
421 let file = Path::new(file_path);
422 if let Ok(rel) = file.strip_prefix(dir) {
424 return !rel.as_os_str().is_empty();
425 }
426 if file.is_absolute() != dir.is_absolute() {
428 return false;
429 }
430 let mut ancestor = file;
432 loop {
433 if ancestor == dir.as_path() {
434 return false;
435 }
436 if ancestor.starts_with(dir) {
437 let rel = ancestor.strip_prefix(dir).unwrap_or(ancestor);
438 return !rel.as_os_str().is_empty();
439 }
440 ancestor = match ancestor.parent() {
441 Some(p) => p,
442 None => return false,
443 };
444 }
445}
446
447struct OpenClone {
448 stored_occurrence: Occurrence,
449 current_start: usize,
450 match_len: usize,
451}
452
453fn windows_match(
456 stored: Occurrence,
457 current: Occurrence,
458 prepared: &[PreparedSource],
459 min_tokens: usize,
460) -> bool {
461 if stored.source_id == current.source_id && stored.token_start == current.token_start {
462 return false;
463 }
464 let stored_hashes = &prepared[stored.source_id].hashes;
465 let current_hashes = &prepared[current.source_id].hashes;
466 if stored.token_start + min_tokens > stored_hashes.len()
467 || current.token_start + min_tokens > current_hashes.len()
468 {
469 return false;
470 }
471 stored_hashes[stored.token_start..stored.token_start + min_tokens]
472 == current_hashes[current.token_start..current.token_start + min_tokens]
473}
474
475fn flush_clone(
481 open: Option<OpenClone>,
482 current_file_idx: usize,
483 prepared: &[PreparedSource],
484 min_lines: usize,
485 filters: &PathFilters,
486 clones: &mut Vec<CpdClone>,
487) {
488 let oc = match open {
489 Some(o) => o,
490 None => return,
491 };
492
493 let existing = &oc.stored_occurrence;
494 let cur_start = oc.current_start;
495 let match_len = oc.match_len;
496
497 let existing_file = &prepared[existing.source_id];
498 let current_file = &prepared[current_file_idx];
499
500 let ex_start = existing.token_start;
501 let ex_end = ex_start + match_len - 1;
502 let cur_end = cur_start + match_len - 1;
503
504 if filters.should_skip(&existing_file.id, ¤t_file.id) {
507 return;
508 }
509
510 let fragment_a = match make_fragment(&existing_file.id, &existing_file.spans, ex_start, ex_end)
511 {
512 Some(f) => f,
513 None => return,
514 };
515 let fragment_b = match make_fragment(¤t_file.id, ¤t_file.spans, cur_start, cur_end)
516 {
517 Some(f) => f,
518 None => return,
519 };
520 let kind = clone_kind(
521 existing_file,
522 fragment_a.range,
523 current_file,
524 fragment_b.range,
525 );
526
527 if min_lines > 0 {
531 let lines = fragment_a.end.line as usize - fragment_a.start.line as usize;
532 if lines < min_lines {
533 return;
534 }
535 }
536
537 clones.push(CpdClone {
538 format: current_file.format.clone(),
539 fragment_a,
540 fragment_b,
541 token_count: match_len as u32,
542 is_new: false,
543 kind,
544 similarity: None,
545 similarity_method: None,
546 unmatched_lines: [0, 0],
547 });
548}
549
550fn clone_kind(
559 a: &PreparedSource,
560 a_range: [u32; 2],
561 b: &PreparedSource,
562 b_range: [u32; 2],
563) -> CloneKind {
564 if a.raw_hashes.is_empty() && b.raw_hashes.is_empty() {
565 return CloneKind::Exact;
566 }
567 let (na, ra) = range_slices(a, a_range);
568 let (nb, rb) = range_slices(b, b_range);
569 let matched = na.iter().zip(nb).take_while(|(x, y)| x == y).count();
570 if ra[..matched.min(ra.len())] == rb[..matched.min(rb.len())] {
571 CloneKind::Exact
572 } else {
573 CloneKind::Renamed
574 }
575}
576
577fn range_slices(p: &PreparedSource, range: [u32; 2]) -> (&[u64], &[u64]) {
579 let start = (range[0] as usize).min(p.hashes.len());
580 let end = (range[1] as usize + 1).min(p.hashes.len());
581 let raw = if p.raw_hashes.is_empty() {
582 &p.hashes
583 } else {
584 &p.raw_hashes
585 };
586 (&p.hashes[start..end], &raw[start..end])
587}
588
589fn make_fragment(
590 source_id: &str,
591 spans: &[(Location, Location)],
592 start_idx: usize,
593 end_idx: usize,
594) -> Option<Fragment> {
595 let (first_start, _) = spans.get(start_idx)?;
596 let (_, last_end) = spans.get(end_idx)?;
597 Some(Fragment {
598 source_id: source_id.to_string(),
599 source_root: None,
600 start: first_start.clone(),
601 end: last_end.clone(),
602 range: [start_idx as u32, end_idx as u32],
603 blame: None,
604 })
605}
606
607pub const MIN_GAP_SIMILARITY: f32 = 0.5;
615
616pub fn merge_gapped_clones(mut clones: Vec<CpdClone>, max_gap_lines: usize) -> Vec<CpdClone> {
629 if max_gap_lines == 0 || clones.len() < 2 {
630 return clones;
631 }
632 clones.sort_by(|x, y| {
633 pair_key(x)
634 .cmp(&pair_key(y))
635 .then(x.fragment_a.range[0].cmp(&y.fragment_a.range[0]))
636 .then(x.fragment_b.range[0].cmp(&y.fragment_b.range[0]))
637 });
638 let mut merged: Vec<CpdClone> = Vec::with_capacity(clones.len());
639 for clone in clones {
640 let extended = match merged.last_mut() {
641 Some(last) if pair_key(last) == pair_key(&clone) => {
642 merge_into(last, &clone, max_gap_lines)
643 }
644 _ => false,
645 };
646 if !extended {
647 merged.push(clone);
648 }
649 }
650 merged
651}
652
653fn merge_into(last: &mut CpdClone, next: &CpdClone, max_gap_lines: usize) -> bool {
658 let Some(step_a) = continuation(&last.fragment_a, &next.fragment_a, max_gap_lines) else {
659 return false;
660 };
661 let Some(step_b) = continuation(&last.fragment_b, &next.fragment_b, max_gap_lines) else {
662 return false;
663 };
664 let matched = last.token_count
667 + next
668 .token_count
669 .saturating_sub(step_a.overlap.max(step_b.overlap));
670 let span_a = next.fragment_a.range[1] - last.fragment_a.range[0] + 1;
671 let span_b = next.fragment_b.range[1] - last.fragment_b.range[0] + 1;
672 let similarity = matched as f32 / span_a.max(span_b) as f32;
673 if similarity < MIN_GAP_SIMILARITY {
674 return false;
675 }
676 last.fragment_a.end = next.fragment_a.end.clone();
677 last.fragment_a.range[1] = next.fragment_a.range[1];
678 last.fragment_b.end = next.fragment_b.end.clone();
679 last.fragment_b.range[1] = next.fragment_b.range[1];
680 last.token_count = matched;
681 last.similarity = Some(similarity);
682 last.similarity_method = Some(SimilarityMethod::Gap);
683 last.kind = CloneKind::Similar;
684 last.unmatched_lines[0] += step_a.gap_lines;
685 last.unmatched_lines[1] += step_b.gap_lines;
686 true
687}
688
689fn pair_key(c: &CpdClone) -> (&str, &str, &str) {
690 (
691 c.format.as_str(),
692 c.fragment_a.source_id.as_str(),
693 c.fragment_b.source_id.as_str(),
694 )
695}
696
697#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700struct Continuation {
701 overlap: u32,
702 gap_lines: u32,
703}
704
705fn continuation(prev: &Fragment, next: &Fragment, max_gap_lines: usize) -> Option<Continuation> {
708 if next.range[0] <= prev.range[0] || next.range[1] <= prev.range[1] {
709 return None;
710 }
711 let gap_lines = next.start.line.saturating_sub(prev.end.line + 1);
712 if gap_lines as usize > max_gap_lines {
713 return None;
714 }
715 Some(Continuation {
716 overlap: (prev.range[1] + 1).saturating_sub(next.range[0]),
717 gap_lines,
718 })
719}
720
721fn dedup_exact_clones(clones: &mut Vec<CpdClone>) {
722 for clone in clones.iter_mut() {
724 let a_key = (&clone.fragment_a.source_id, clone.fragment_a.start.line);
725 let b_key = (&clone.fragment_b.source_id, clone.fragment_b.start.line);
726 if a_key > b_key {
727 std::mem::swap(&mut clone.fragment_a, &mut clone.fragment_b);
728 }
729 }
730
731 let mut seen: FxHashSet<CloneDedupKey> = FxHashSet::default();
732 clones.retain(|c| seen.insert(CloneDedupKey::from_clone(c)));
733}
734
735fn remember_repeated_window(
740 repeated_windows: &mut FxHashMap<u64, Vec<Occurrence>>,
741 hash: u64,
742 occurrence: Occurrence,
743 cap: usize,
744) {
745 let bucket = repeated_windows.entry(hash).or_default();
746 if bucket
747 .iter()
748 .any(|s| s.source_id == occurrence.source_id && s.token_start == occurrence.token_start)
749 {
750 return;
751 }
752 if bucket.len() < cap {
753 bucket.push(occurrence);
754 }
755}
756
757struct SecondaryOpen {
758 clone: CpdClone,
759 source_a: usize,
760 source_b: usize,
761 last_token_start_a: usize,
762 last_token_start_b: usize,
763}
764
765#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
766struct Candidate {
767 source_a: usize,
768 source_b: usize,
769 token_a: usize,
770 token_b: usize,
771}
772
773impl SecondaryOpen {
774 fn is_continuation(&self, candidate: &Candidate) -> bool {
777 self.source_a == candidate.source_a
778 && self.source_b == candidate.source_b
779 && self.last_token_start_a + 1 == candidate.token_a
780 && self.last_token_start_b + 1 == candidate.token_b
781 }
782
783 fn grow(&mut self, candidate: &Candidate, prepared: &[PreparedSource], min_tokens: usize) {
786 self.clone.token_count += 1;
787 let end_a = candidate.token_a + min_tokens;
788 let end_b = candidate.token_b + min_tokens;
789 if let Some(span) = prepared[self.source_a].spans.get(end_a) {
790 self.clone.fragment_a.end = span.1.clone();
791 self.clone.fragment_a.range[1] = end_a as u32;
792 }
793 if let Some(span) = prepared[self.source_b].spans.get(end_b) {
794 self.clone.fragment_b.end = span.1.clone();
795 self.clone.fragment_b.range[1] = end_b as u32;
796 }
797 self.last_token_start_a = candidate.token_a;
798 self.last_token_start_b = candidate.token_b;
799 }
800}
801
802fn add_secondary_clones(
803 repeated_windows: FxHashMap<u64, Vec<Occurrence>>,
804 prepared: &[PreparedSource],
805 min_tokens: usize,
806 min_lines: usize,
807 filters: &PathFilters,
808 clones: &mut Vec<CpdClone>,
809) {
810 if repeated_windows.is_empty() {
811 return;
812 }
813
814 let mut candidates: Vec<Candidate> = Vec::new();
815 for occurrences in repeated_windows.values() {
816 if occurrences.len() < 2 {
817 continue;
818 }
819 for li in 0..occurrences.len() {
820 for ri in li + 1..occurrences.len() {
821 let left = &occurrences[li];
822 let right = &occurrences[ri];
823 if left.source_id == right.source_id && left.token_start == right.token_start {
824 continue;
825 }
826 let lh = &prepared[left.source_id].hashes;
827 let rh = &prepared[right.source_id].hashes;
828 let la = left.token_start;
829 let ra = right.token_start;
830 if la + min_tokens > lh.len() || ra + min_tokens > rh.len() {
831 continue;
832 }
833 if lh[la..la + min_tokens] != rh[ra..ra + min_tokens] {
834 continue;
835 }
836 let (sa, ta, sb, tb) =
837 if (left.source_id, left.token_start) <= (right.source_id, right.token_start) {
838 (
839 left.source_id,
840 left.token_start,
841 right.source_id,
842 right.token_start,
843 )
844 } else {
845 (
846 right.source_id,
847 right.token_start,
848 left.source_id,
849 left.token_start,
850 )
851 };
852 candidates.push(Candidate {
853 source_a: sa,
854 source_b: sb,
855 token_a: ta,
856 token_b: tb,
857 });
858 }
859 }
860 }
861 if candidates.is_empty() {
862 return;
863 }
864 candidates.sort_unstable();
865 candidates.dedup();
866
867 let mut coverage = LineCoverage::from_clones(prepared, clones);
869 let mut open: Option<SecondaryOpen> = None;
870
871 for candidate in candidates {
872 if let Some(current) = open.as_mut()
873 && current.is_continuation(&candidate)
874 {
875 current.grow(&candidate, prepared, min_tokens);
876 continue;
877 }
878
879 flush_secondary_clone(
880 open.take(),
881 prepared,
882 min_lines,
883 filters,
884 clones,
885 &mut coverage,
886 );
887
888 let start_a = candidate.token_a;
890 let end_a = start_a + min_tokens - 1;
891 let start_b = candidate.token_b;
892 let end_b = start_b + min_tokens - 1;
893
894 let frag_a = match make_fragment(
895 &prepared[candidate.source_a].id,
896 &prepared[candidate.source_a].spans,
897 start_a,
898 end_a,
899 ) {
900 Some(f) => f,
901 None => continue,
902 };
903 let frag_b = match make_fragment(
904 &prepared[candidate.source_b].id,
905 &prepared[candidate.source_b].spans,
906 start_b,
907 end_b,
908 ) {
909 Some(f) => f,
910 None => continue,
911 };
912
913 open = Some(SecondaryOpen {
914 clone: CpdClone {
915 format: prepared[candidate.source_a].format.clone(),
916 fragment_a: frag_a,
917 fragment_b: frag_b,
918 token_count: min_tokens as u32,
919 is_new: false,
920 kind: Default::default(),
921 similarity: None,
922 similarity_method: None,
923 unmatched_lines: [0, 0],
924 },
925 source_a: candidate.source_a,
926 source_b: candidate.source_b,
927 last_token_start_a: candidate.token_a,
928 last_token_start_b: candidate.token_b,
929 });
930 }
931
932 flush_secondary_clone(
933 open.take(),
934 prepared,
935 min_lines,
936 filters,
937 clones,
938 &mut coverage,
939 );
940}
941
942fn flush_secondary_clone(
943 open: Option<SecondaryOpen>,
944 prepared: &[PreparedSource],
945 min_lines: usize,
946 filters: &PathFilters,
947 clones: &mut Vec<CpdClone>,
948 coverage: &mut LineCoverage,
949) {
950 let Some(oc) = open else {
951 return;
952 };
953
954 let range_a = fragment_line_range(&oc.clone.fragment_a);
955 let range_b = fragment_line_range(&oc.clone.fragment_b);
956
957 if filters.should_skip(&prepared[oc.source_a].id, &prepared[oc.source_b].id) {
959 return;
960 }
961
962 if min_lines > 0 {
964 let lines = oc.clone.fragment_a.end.line as usize - oc.clone.fragment_a.start.line as usize;
965 if lines < min_lines {
966 return;
967 }
968 }
969
970 if !coverage.extends(oc.source_a, range_a) || !coverage.extends(oc.source_b, range_b) {
974 return;
975 }
976
977 let before = clones.len();
978 let mut clone = oc.clone;
979 clone.kind = clone_kind(
980 &prepared[oc.source_a],
981 clone.fragment_a.range,
982 &prepared[oc.source_b],
983 clone.fragment_b.range,
984 );
985 clones.push(clone);
986
987 if clones.len() > before {
989 coverage.insert(oc.source_a, range_a);
990 coverage.insert(oc.source_b, range_b);
991 }
992}
993
994fn fragment_line_range(fragment: &Fragment) -> (usize, usize) {
995 let start = fragment.start.line as usize;
996 let end = fragment.end.line as usize;
997 (start.min(end), start.max(end))
998}
999
1000struct LineCoverage {
1005 ranges_by_source: Vec<Vec<(usize, usize)>>,
1006}
1007
1008impl LineCoverage {
1009 fn from_clones(prepared: &[PreparedSource], clones: &[CpdClone]) -> Self {
1010 let mut source_lookup: FxHashMap<&str, usize> = FxHashMap::default();
1011 for (idx, source) in prepared.iter().enumerate() {
1012 source_lookup.insert(source.id.as_str(), idx);
1013 }
1014 let mut coverage = Self {
1015 ranges_by_source: vec![Vec::new(); prepared.len()],
1016 };
1017 for clone in clones {
1018 if let Some(idx) = source_lookup.get(clone.fragment_a.source_id.as_str()) {
1019 coverage.insert(*idx, fragment_line_range(&clone.fragment_a));
1020 }
1021 if let Some(idx) = source_lookup.get(clone.fragment_b.source_id.as_str()) {
1022 coverage.insert(*idx, fragment_line_range(&clone.fragment_b));
1023 }
1024 }
1025 coverage
1026 }
1027
1028 fn extends(&self, source_idx: usize, range: (usize, usize)) -> bool {
1029 let Some(intervals) = self.ranges_by_source.get(source_idx) else {
1033 return true;
1034 };
1035 let mut cursor = range.0;
1036 for &(start, end) in intervals {
1037 if end < cursor {
1038 continue;
1039 }
1040 if start > cursor {
1041 return true;
1042 }
1043 cursor = cursor.max(end.saturating_add(1));
1044 if cursor > range.1 {
1045 return false;
1046 }
1047 }
1048 cursor <= range.1
1049 }
1050
1051 fn insert(&mut self, source_idx: usize, range: (usize, usize)) {
1052 let Some(intervals) = self.ranges_by_source.get_mut(source_idx) else {
1057 return;
1058 };
1059 let mut folded = Vec::with_capacity(intervals.len() + 1);
1060 let mut pending = Some(range);
1061 for &(start, end) in intervals.iter() {
1062 let p = match pending.take() {
1063 None => {
1064 folded.push((start, end));
1065 continue;
1066 }
1067 Some(p) => p,
1068 };
1069 if p.1.saturating_add(1) < start {
1071 folded.push(p);
1072 folded.push((start, end));
1073 }
1074 else if end.saturating_add(1) < p.0 {
1076 folded.push((start, end));
1077 pending = Some(p);
1078 }
1079 else {
1081 pending = Some((p.0.min(start), p.1.max(end)));
1082 }
1083 }
1084 if let Some(p) = pending {
1085 folded.push(p);
1086 }
1087 *intervals = folded;
1088 }
1089}
1090
1091#[cfg(test)]
1096mod tests {
1097 use super::*;
1098
1099 fn tok(hash: u64, raw_hash: u64, line: u32) -> DetectionToken {
1100 let loc = Location {
1101 line,
1102 column: 0,
1103 offset: line,
1104 };
1105 DetectionToken {
1106 hash,
1107 raw_hash,
1108 start: loc.clone(),
1109 end: loc,
1110 range: [line as usize, line as usize + 1],
1111 }
1112 }
1113
1114 fn gap_clone(
1117 a: &str,
1118 a_tok: [u32; 2],
1119 a_lines: [u32; 2],
1120 b: &str,
1121 b_tok: [u32; 2],
1122 b_lines: [u32; 2],
1123 ) -> CpdClone {
1124 let frag = |id: &str, tok: [u32; 2], lines: [u32; 2]| Fragment {
1125 source_id: id.to_string(),
1126 source_root: None,
1127 start: Location {
1128 line: lines[0],
1129 column: 1,
1130 offset: tok[0],
1131 },
1132 end: Location {
1133 line: lines[1],
1134 column: 1,
1135 offset: tok[1],
1136 },
1137 range: tok,
1138 blame: None,
1139 };
1140 CpdClone {
1141 format: "javascript".to_string(),
1142 fragment_a: frag(a, a_tok, a_lines),
1143 fragment_b: frag(b, b_tok, b_lines),
1144 token_count: a_tok[1] - a_tok[0] + 1,
1145 is_new: false,
1146 kind: CloneKind::Exact,
1147 similarity: None,
1148 similarity_method: None,
1149 unmatched_lines: [0, 0],
1150 }
1151 }
1152
1153 #[test]
1154 fn merge_gapped_is_a_no_op_at_zero() {
1155 let clones = vec![
1156 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1157 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1158 ];
1159 let out = merge_gapped_clones(clones.clone(), 0);
1160 assert_eq!(out, clones);
1161 }
1162
1163 #[test]
1164 fn merge_gapped_joins_adjacent_fragments_within_gap() {
1165 let clones = vec![
1167 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1168 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1169 ];
1170 let out = merge_gapped_clones(clones, 1);
1171 assert_eq!(out.len(), 1);
1172 let c = &out[0];
1173 assert_eq!(c.kind, CloneKind::Similar);
1174 assert_eq!(c.token_count, 20, "matched tokens");
1175 assert_eq!(c.fragment_a.range, [0, 19]);
1176 assert_eq!(c.fragment_b.range, [0, 21]);
1177 assert_eq!(c.fragment_a.end.line, 8);
1178 assert_eq!(c.fragment_b.end.line, 9);
1179 assert!((c.similarity.unwrap() - 20.0 / 22.0).abs() < 1e-6);
1181 }
1182
1183 #[test]
1184 fn merge_gapped_respects_the_line_limit_and_file_pair() {
1185 let far = vec![
1186 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1187 gap_clone("a", [10, 19], [5, 8], "b", [20, 29], [8, 11]), ];
1189 assert_eq!(merge_gapped_clones(far.clone(), 2).len(), 2);
1190 assert_eq!(merge_gapped_clones(far, 3).len(), 1);
1191
1192 let other_pair = vec![
1193 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1194 gap_clone("a", [10, 19], [5, 8], "c", [10, 19], [5, 8]),
1195 ];
1196 assert_eq!(merge_gapped_clones(other_pair, 5).len(), 2);
1197 }
1198
1199 #[test]
1200 fn merge_gapped_counts_a_shared_boundary_token_once_and_chains() {
1201 let clones = vec![
1203 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1204 gap_clone("a", [9, 18], [4, 8], "b", [11, 20], [6, 9]),
1205 gap_clone("a", [19, 28], [9, 12], "b", [22, 31], [10, 13]),
1206 ];
1207 let out = merge_gapped_clones(clones, 1);
1208 assert_eq!(out.len(), 1);
1209 assert_eq!(out[0].token_count, 29, "10 + (10 - 1 overlap) + 10");
1210 assert_eq!(out[0].fragment_a.range, [0, 28]);
1211 assert_eq!(out[0].fragment_b.range, [0, 31]);
1212 }
1213
1214 #[test]
1215 fn merge_gapped_never_merges_overlapping_or_reordered_fragments() {
1216 let nested = vec![
1217 gap_clone("a", [0, 19], [1, 8], "b", [0, 19], [1, 8]),
1218 gap_clone("a", [5, 9], [3, 4], "b", [5, 9], [3, 4]),
1219 ];
1220 assert_eq!(merge_gapped_clones(nested, 5).len(), 2);
1221 let crossed = vec![
1222 gap_clone("a", [0, 9], [1, 4], "b", [20, 29], [10, 13]),
1223 gap_clone("a", [10, 19], [5, 8], "b", [0, 9], [1, 4]),
1224 ];
1225 assert_eq!(merge_gapped_clones(crossed, 5).len(), 2);
1226 }
1227
1228 #[test]
1229 fn merge_gapped_refuses_a_gap_wider_than_the_match() {
1230 let wide = vec![
1233 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1234 gap_clone("a", [10, 19], [5, 8], "b", [40, 49], [6, 9]),
1235 ];
1236 let out = merge_gapped_clones(wide, 1);
1237 assert_eq!(out.len(), 2);
1238 assert!(out.iter().all(|c| c.kind == CloneKind::Exact));
1239 assert!(out.iter().all(|c| c.similarity.is_none()));
1240 let at_floor = vec![
1242 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1243 gap_clone("a", [10, 19], [5, 8], "b", [30, 39], [6, 9]),
1244 ];
1245 let out = merge_gapped_clones(at_floor, 1);
1246 assert_eq!(out.len(), 1);
1247 assert!((out[0].similarity.unwrap() - MIN_GAP_SIMILARITY).abs() < 1e-6);
1248 }
1249
1250 #[test]
1251 fn merge_gapped_records_unmatched_lines_per_fragment() {
1252 let clones = vec![
1254 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1255 gap_clone("a", [10, 19], [5, 8], "b", [14, 23], [7, 10]),
1256 ];
1257 let out = merge_gapped_clones(clones, 2);
1258 assert_eq!(out.len(), 1);
1259 assert_eq!(out[0].unmatched_lines, [0, 2]);
1260 assert_eq!(out[0].fragment_b.end.line, 10);
1261 }
1262
1263 #[test]
1264 fn merge_gapped_reports_renamed_halves_as_similar() {
1265 let mut clones = vec![
1266 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1267 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1268 ];
1269 for c in &mut clones {
1270 c.kind = CloneKind::Renamed;
1271 }
1272 let out = merge_gapped_clones(clones, 1);
1273 assert_eq!(out.len(), 1);
1274 assert_eq!(
1275 out[0].kind,
1276 CloneKind::Similar,
1277 "similar takes precedence over renamed"
1278 );
1279 }
1280
1281 #[test]
1282 fn prepared_source_skips_raw_hashes_when_nothing_was_normalized() {
1283 let tokens = vec![tok(1, 1, 1), tok(2, 2, 2)];
1284 let p = PreparedSource::from_detection_tokens("a".into(), "js".into(), &tokens);
1285 assert!(p.raw_hashes.is_empty());
1286 }
1287
1288 #[test]
1289 fn prepared_source_backfills_raw_hashes_from_first_normalized_token() {
1290 let tokens = vec![tok(1, 1, 1), tok(2, 2, 2), tok(3, 30, 3), tok(4, 4, 4)];
1291 let p = PreparedSource::from_detection_tokens("a".into(), "js".into(), &tokens);
1292 assert_eq!(p.raw_hashes, vec![1, 2, 30, 4]);
1293 }
1294
1295 #[test]
1296 fn clone_kind_is_exact_without_raw_hashes_and_renamed_when_raw_differs() {
1297 let a = PreparedSource::from_detection_tokens(
1298 "a".into(),
1299 "js".into(),
1300 &[tok(1, 1, 1), tok(2, 2, 2), tok(3, 3, 3)],
1301 );
1302 let b = PreparedSource::from_detection_tokens(
1303 "b".into(),
1304 "js".into(),
1305 &[tok(1, 1, 1), tok(2, 20, 2), tok(3, 3, 3)],
1306 );
1307 assert_eq!(clone_kind(&a, [0, 2], &a, [0, 2]), CloneKind::Exact);
1308 assert_eq!(clone_kind(&a, [0, 2], &b, [0, 2]), CloneKind::Renamed);
1309 assert_eq!(clone_kind(&a, [2, 2], &b, [2, 2]), CloneKind::Exact);
1311 let c = PreparedSource::from_detection_tokens(
1313 "c".into(),
1314 "js".into(),
1315 &[tok(1, 1, 1), tok(2, 2, 2), tok(9, 90, 3)],
1316 );
1317 assert_eq!(clone_kind(&a, [0, 2], &c, [0, 2]), CloneKind::Exact);
1318 }
1319 use crate::models::{Location, Token, TokenKind};
1320
1321 fn loc(line: u32, col: u32, offset: u32) -> Location {
1322 Location {
1323 line,
1324 column: col,
1325 offset,
1326 }
1327 }
1328
1329 fn make_token(kind: TokenKind, value: &str, line: u32, col: u32, offset: u32) -> Token {
1330 let end_col = col + value.len() as u32;
1331 let end_off = offset + value.len() as u32;
1332 Token {
1333 kind,
1334 value: value.to_string(),
1335 start: loc(line, col, offset),
1336 end: loc(line, end_col, end_off),
1337 }
1338 }
1339
1340 fn make_file(id: &str, format: &str, tokens: Vec<Token>) -> SourceFile {
1341 SourceFile {
1342 bytes: 0,
1343 id: id.to_string(),
1344 format: format.to_string(),
1345 tokens,
1346 }
1347 }
1348
1349 fn js_tokens_ab() -> Vec<Token> {
1350 vec![
1351 make_token(TokenKind::Keyword, "function", 1, 0, 0),
1352 make_token(TokenKind::Other, "hello", 1, 9, 9),
1353 make_token(TokenKind::Operator, "(", 1, 14, 14),
1354 make_token(TokenKind::Operator, ")", 1, 15, 15),
1355 make_token(TokenKind::Operator, "{", 1, 16, 16),
1356 make_token(TokenKind::Keyword, "return", 2, 0, 18),
1357 make_token(TokenKind::Literal, "42", 2, 7, 25),
1358 make_token(TokenKind::Operator, ";", 2, 9, 27),
1359 make_token(TokenKind::Operator, "}", 3, 0, 29),
1360 ]
1361 }
1362
1363 #[test]
1364 fn empty_input_returns_empty() {
1365 let result = detect(&[], 10);
1366 assert!(result.is_empty());
1367 }
1368
1369 fn pair_with_js_tokens(min_tokens: usize) -> Vec<CpdClone> {
1370 let tokens = js_tokens_ab();
1371 let file_a = make_file("a.js", "javascript", tokens.clone());
1372 let file_b = make_file("b.js", "javascript", tokens);
1373 detect(&[file_a, file_b], min_tokens)
1374 }
1375
1376 #[test]
1377 fn identical_files_detected_as_clone() {
1378 assert!(
1379 !pair_with_js_tokens(5).is_empty(),
1380 "identical files must produce at least one clone"
1381 );
1382 }
1383
1384 #[test]
1385 fn min_tokens_threshold_respected() {
1386 assert!(
1387 pair_with_js_tokens(100).is_empty(),
1388 "no clones when min_tokens exceeds file length"
1389 );
1390 }
1391
1392 #[test]
1393 fn deduplication_ab_ba_collapse() {
1394 assert_eq!(
1395 pair_with_js_tokens(5).len(),
1396 1,
1397 "symmetric pairs must collapse to 1"
1398 );
1399 }
1400
1401 #[test]
1402 fn different_formats_not_cross_detected() {
1403 let tokens = js_tokens_ab();
1404 let file_js = make_file("a.js", "javascript", tokens.clone());
1405 let file_py = make_file("a.py", "python", tokens);
1406 let clones = detect(&[file_js, file_py], 5);
1407 assert!(
1408 clones.is_empty(),
1409 "tokens from different formats must not match"
1410 );
1411 }
1412
1413 #[test]
1414 fn cross_format_group_detected() {
1415 let to_prepared = |id: &str, format: &str| {
1419 let tokens = js_tokens_ab();
1420 let mut hashes = Vec::new();
1421 let mut spans = Vec::new();
1422 for t in &tokens {
1423 hashes.push(token_hash(t.kind.discriminant(), &t.value));
1424 spans.push((t.start.clone(), t.end.clone()));
1425 }
1426 PreparedSource {
1427 id: id.to_string(),
1428 format: format.to_string(),
1429 hashes,
1430 spans,
1431 raw_hashes: Vec::new(),
1432 functions: Vec::new(),
1433 }
1434 };
1435 let group = vec![
1436 to_prepared("a.js", "javascript"),
1437 to_prepared("a.ts", "typescript"),
1438 ];
1439 let clones = detect_prepared(vec![group], 5, 0, &PathFilters::default());
1440 assert_eq!(
1441 clones.len(),
1442 1,
1443 "identical token streams in one pool must match across formats"
1444 );
1445 }
1446
1447 #[test]
1448 fn identical_files_maximal_clone() {
1449 let tokens = js_tokens_ab();
1452 let file_a = make_file("a.js", "javascript", tokens.clone());
1453 let file_b = make_file("b.js", "javascript", tokens);
1454 let clones = detect(&[file_a, file_b], 5);
1455 assert_eq!(
1456 clones.len(),
1457 1,
1458 "open_clone SM must produce one maximal clone"
1459 );
1460 assert_eq!(
1461 clones[0].token_count, 9,
1462 "maximal clone must cover all 9 tokens"
1463 );
1464 }
1465
1466 #[test]
1467 fn three_identical_files_secondary_pass_adds_missing_pair() {
1468 let tokens = js_tokens_ab();
1469 let file_a = make_file("a.js", "javascript", tokens.clone());
1470 let file_b = make_file("b.js", "javascript", tokens.clone());
1471 let file_c = make_file("c.js", "javascript", tokens);
1472 let clones = detect(&[file_a, file_b, file_c], 5);
1473 assert!(
1474 clones.len() >= 2,
1475 "three identical files must yield at least 2 clone pairs, got {}",
1476 clones.len()
1477 );
1478 }
1479
1480 #[test]
1481 fn clones_sorted_by_source_and_line() {
1482 let tokens = js_tokens_ab();
1483 let file_a = make_file("a.js", "javascript", tokens.clone());
1484 let file_b = make_file("b.js", "javascript", tokens);
1485 let clones = detect(&[file_a, file_b], 5);
1486 for i in 1..clones.len() {
1487 let prev = &clones[i - 1];
1488 let curr = &clones[i];
1489 assert!(
1490 (
1491 &prev.fragment_a.source_id,
1492 prev.fragment_a.start.line,
1493 &prev.fragment_b.source_id,
1494 prev.fragment_b.start.line,
1495 ) <= (
1496 &curr.fragment_a.source_id,
1497 curr.fragment_a.start.line,
1498 &curr.fragment_b.source_id,
1499 curr.fragment_b.start.line,
1500 ),
1501 "clones must be sorted"
1502 );
1503 }
1504 }
1505
1506 fn isolated(groups: &[&[&str]]) -> Vec<Vec<PathBuf>> {
1507 groups
1508 .iter()
1509 .map(|g| g.iter().map(PathBuf::from).collect())
1510 .collect()
1511 }
1512
1513 #[test]
1514 fn skip_isolated_drops_pairs_across_group_folders() {
1515 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1516 assert!(should_skip_isolated(
1517 "/repo/packages/a/src/x.js",
1518 "/repo/packages/b/src/y.js",
1519 &groups
1520 ));
1521 }
1522
1523 #[test]
1524 fn skip_isolated_keeps_pairs_inside_one_folder() {
1525 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1526 assert!(!should_skip_isolated(
1527 "/repo/packages/a/src/x.js",
1528 "/repo/packages/a/lib/y.js",
1529 &groups
1530 ));
1531 }
1532
1533 #[test]
1534 fn skip_isolated_keeps_pairs_with_one_file_outside_group() {
1535 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1536 assert!(!should_skip_isolated(
1537 "/repo/packages/a/src/x.js",
1538 "/repo/globals/y.js",
1539 &groups
1540 ));
1541 assert!(!should_skip_isolated(
1542 "/repo/globals/x.js",
1543 "/repo/infra/y.js",
1544 &groups
1545 ));
1546 }
1547
1548 #[test]
1549 fn skip_isolated_folders_in_different_groups_do_not_isolate() {
1550 let groups = isolated(&[
1551 &["/repo/packages/a", "/repo/packages/b"],
1552 &["/repo/libs/a", "/repo/libs/b"],
1553 ]);
1554 assert!(!should_skip_isolated(
1555 "/repo/packages/a/x.js",
1556 "/repo/libs/b/y.js",
1557 &groups
1558 ));
1559 assert!(should_skip_isolated(
1560 "/repo/libs/a/x.js",
1561 "/repo/libs/b/y.js",
1562 &groups
1563 ));
1564 }
1565
1566 #[test]
1567 fn path_filters_combine_skip_local_and_skip_isolated() {
1568 let scan_roots = vec![PathBuf::from("/repo/shared")];
1569 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1570 let filters = PathFilters {
1571 skip_local: true,
1572 scan_roots: &scan_roots,
1573 isolated_groups: &groups,
1574 };
1575 assert!(filters.should_skip("/repo/shared/x.js", "/repo/shared/y.js"));
1576 assert!(filters.should_skip("/repo/packages/a/x.js", "/repo/packages/b/y.js"));
1577 assert!(!filters.should_skip("/repo/shared/x.js", "/repo/packages/a/y.js"));
1578 }
1579}