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, covered_lines,
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 real_path: String::new(),
135 embedded: false,
136 }
137 })
138 .collect();
139 detect_in_group(&prepared, min_tokens, min_lines, filters)
140 })
141 .collect();
142
143 finalize_clones(&mut clones);
144 clones
145}
146
147fn finalize_clones(clones: &mut Vec<CpdClone>) {
148 dedup_exact_clones(clones);
149 clones.sort_by(|a, b| {
150 (
151 &a.fragment_a.source_id,
152 a.fragment_a.start.line,
153 &a.fragment_b.source_id,
154 a.fragment_b.start.line,
155 )
156 .cmp(&(
157 &b.fragment_a.source_id,
158 b.fragment_a.start.line,
159 &b.fragment_b.source_id,
160 b.fragment_b.start.line,
161 ))
162 });
163}
164
165#[derive(Debug, Clone)]
174pub struct PreparedSource {
175 pub id: String,
176 pub format: String,
177 pub hashes: Vec<u64>,
178 pub spans: Vec<(Location, Location)>,
179 pub raw_hashes: Vec<u64>,
183 pub functions: Vec<crate::similarity::FunctionSig>,
186 pub real_path: String,
192 pub embedded: bool,
198}
199
200impl PreparedSource {
201 pub fn filter_path(&self) -> &str {
203 if self.real_path.is_empty() {
204 &self.id
205 } else {
206 &self.real_path
207 }
208 }
209
210 pub fn from_detection_tokens(id: String, format: String, tokens: &[DetectionToken]) -> Self {
212 let mut hashes = Vec::with_capacity(tokens.len());
213 let mut spans = Vec::with_capacity(tokens.len());
214 let mut raw_hashes: Vec<u64> = Vec::new();
217 for (i, t) in tokens.iter().enumerate() {
218 hashes.push(t.hash);
219 spans.push((t.start.clone(), t.end.clone()));
220 if raw_hashes.is_empty() && t.raw_hash != t.hash {
221 raw_hashes.reserve(tokens.len());
222 raw_hashes.extend(hashes[..i].iter().copied());
223 }
224 if !raw_hashes.is_empty() {
225 raw_hashes.push(t.raw_hash);
226 }
227 }
228 Self {
229 id,
230 format,
231 hashes,
232 spans,
233 raw_hashes,
234 functions: Vec::new(),
235 real_path: String::new(),
236 embedded: false,
237 }
238 }
239}
240
241pub fn detect_prepared(
246 format_groups: Vec<Vec<PreparedSource>>,
247 min_tokens: usize,
248 min_lines: usize,
249 filters: &PathFilters,
250) -> Vec<CpdClone> {
251 if format_groups.is_empty() || min_tokens == 0 {
252 return vec![];
253 }
254
255 let mut clones: Vec<CpdClone> = format_groups
256 .into_par_iter()
257 .flat_map(|group| detect_in_group(&group, min_tokens, min_lines, filters))
258 .collect();
259
260 finalize_clones(&mut clones);
261 clones
262}
263
264fn detect_in_group(
269 prepared: &[PreparedSource],
270 min_tokens: usize,
271 min_lines: usize,
272 filters: &PathFilters,
273) -> Vec<CpdClone> {
274 let window_power = base_pow(min_tokens.saturating_sub(1));
277
278 let total_windows: usize = prepared
280 .iter()
281 .map(|p| p.hashes.len().saturating_sub(min_tokens))
282 .sum();
283 let mut store: WindowStore =
284 FxHashMap::with_capacity_and_hasher(total_windows, Default::default());
285
286 let mut clones: Vec<CpdClone> = Vec::new();
287 const SECONDARY_OCCURRENCE_CAP: usize = 2;
291 let mut repeated_windows: FxHashMap<u64, Vec<Occurrence>> = FxHashMap::default();
292
293 for (file_idx, source) in prepared.iter().enumerate() {
294 let hashes = &source.hashes;
295 if hashes.len() < min_tokens {
296 continue;
297 }
298 let windows_len = hashes.len() - min_tokens + 1;
299
300 let mut open_clone: Option<OpenClone> = None;
305
306 let mut window_hash = hash_window(&hashes[..min_tokens]);
307
308 for token_start in 0..windows_len {
309 if token_start > 0 {
310 window_hash = roll(
311 window_hash,
312 hashes[token_start - 1],
313 hashes[token_start + min_tokens - 1],
314 window_power,
315 );
316 }
317
318 let current = Occurrence {
319 source_id: file_idx,
320 token_start,
321 };
322
323 let stored = store
324 .get(&window_hash)
325 .copied()
326 .filter(|stored| windows_match(*stored, current, prepared, min_tokens));
327
328 let anchor_continues = open_clone.as_ref().is_some_and(|oc| {
336 let anchor = Occurrence {
337 source_id: oc.stored_occurrence.source_id,
338 token_start: oc.stored_occurrence.token_start
339 + (token_start - oc.current_start),
340 };
341 windows_match(anchor, current, prepared, min_tokens)
342 });
343
344 if anchor_continues {
345 if let Some(oc) = open_clone.as_mut() {
346 oc.match_len += 1;
347 }
348 } else {
349 flush_clone(
352 open_clone.take(),
353 file_idx,
354 prepared,
355 min_lines,
356 filters,
357 &mut clones,
358 );
359 match stored {
360 Some(stored) => {
361 open_clone = Some(OpenClone {
362 stored_occurrence: stored,
363 current_start: token_start,
364 match_len: min_tokens,
365 });
366 }
367 None => {
368 store.insert(window_hash, current);
369 }
370 }
371 }
372 if let Some(stored) = stored {
373 remember_repeated_window(
374 &mut repeated_windows,
375 window_hash,
376 stored,
377 SECONDARY_OCCURRENCE_CAP,
378 );
379 remember_repeated_window(
380 &mut repeated_windows,
381 window_hash,
382 current,
383 SECONDARY_OCCURRENCE_CAP,
384 );
385 }
388 }
389
390 flush_clone(
392 open_clone.take(),
393 file_idx,
394 prepared,
395 min_lines,
396 filters,
397 &mut clones,
398 );
399 }
400
401 add_secondary_clones(
402 repeated_windows,
403 prepared,
404 min_tokens,
405 min_lines,
406 filters,
407 &mut clones,
408 );
409
410 clones
411}
412
413#[derive(Debug, Default, Clone, Copy)]
422pub struct PathFilters<'a> {
423 pub skip_local: bool,
426 pub scan_roots: &'a [PathBuf],
428 pub isolated_groups: &'a [Vec<PathBuf>],
432}
433
434impl PathFilters<'_> {
435 fn should_skip(&self, file_a: &str, file_b: &str) -> bool {
437 (self.skip_local && should_skip_local(file_a, file_b, self.scan_roots))
438 || should_skip_isolated(file_a, file_b, self.isolated_groups)
439 }
440
441 fn should_skip_pair(&self, a: &PreparedSource, b: &PreparedSource) -> bool {
448 self.should_skip(&a.id, &b.id)
449 || ((!a.real_path.is_empty() || !b.real_path.is_empty())
450 && should_skip_isolated(a.filter_path(), b.filter_path(), self.isolated_groups))
451 }
452}
453
454fn should_skip_local(file_a: &str, file_b: &str, scan_roots: &[PathBuf]) -> bool {
458 scan_roots
459 .iter()
460 .any(|root| is_relative_to(file_a, root) && is_relative_to(file_b, root))
461}
462
463fn should_skip_isolated(file_a: &str, file_b: &str, isolated_groups: &[Vec<PathBuf>]) -> bool {
468 isolated_groups.iter().any(|group| {
469 let Some(dir_a) = group.iter().find(|dir| is_relative_to(file_a, dir)) else {
470 return false;
471 };
472 group
473 .iter()
474 .find(|dir| is_relative_to(file_b, dir))
475 .is_some_and(|dir_b| dir_a != dir_b)
476 })
477}
478
479fn is_relative_to(file_path: &str, dir: &PathBuf) -> bool {
483 let file = Path::new(file_path);
484 if let Ok(rel) = file.strip_prefix(dir) {
486 return !rel.as_os_str().is_empty();
487 }
488 if file.is_absolute() != dir.is_absolute() {
490 return false;
491 }
492 let mut ancestor = file;
494 loop {
495 if ancestor == dir.as_path() {
496 return false;
497 }
498 if ancestor.starts_with(dir) {
499 let rel = ancestor.strip_prefix(dir).unwrap_or(ancestor);
500 return !rel.as_os_str().is_empty();
501 }
502 ancestor = match ancestor.parent() {
503 Some(p) => p,
504 None => return false,
505 };
506 }
507}
508
509struct OpenClone {
510 stored_occurrence: Occurrence,
511 current_start: usize,
512 match_len: usize,
513}
514
515fn windows_match(
518 stored: Occurrence,
519 current: Occurrence,
520 prepared: &[PreparedSource],
521 min_tokens: usize,
522) -> bool {
523 if stored.source_id == current.source_id && stored.token_start == current.token_start {
524 return false;
525 }
526 let stored_hashes = &prepared[stored.source_id].hashes;
527 let current_hashes = &prepared[current.source_id].hashes;
528 if stored.token_start + min_tokens > stored_hashes.len()
529 || current.token_start + min_tokens > current_hashes.len()
530 {
531 return false;
532 }
533 stored_hashes[stored.token_start..stored.token_start + min_tokens]
534 == current_hashes[current.token_start..current.token_start + min_tokens]
535}
536
537fn flush_clone(
543 open: Option<OpenClone>,
544 current_file_idx: usize,
545 prepared: &[PreparedSource],
546 min_lines: usize,
547 filters: &PathFilters,
548 clones: &mut Vec<CpdClone>,
549) {
550 let oc = match open {
551 Some(o) => o,
552 None => return,
553 };
554
555 let existing = &oc.stored_occurrence;
556 let cur_start = oc.current_start;
557 let match_len = oc.match_len;
558
559 let existing_file = &prepared[existing.source_id];
560 let current_file = &prepared[current_file_idx];
561
562 let ex_start = existing.token_start;
563 let ex_end = ex_start + match_len - 1;
564 let cur_end = cur_start + match_len - 1;
565
566 if filters.should_skip_pair(existing_file, current_file) {
569 return;
570 }
571
572 let fragment_a = match make_fragment(&existing_file.id, &existing_file.spans, ex_start, ex_end)
573 {
574 Some(f) => f,
575 None => return,
576 };
577 let fragment_b = match make_fragment(¤t_file.id, ¤t_file.spans, cur_start, cur_end)
578 {
579 Some(f) => f,
580 None => return,
581 };
582 let kind = clone_kind(
583 existing_file,
584 fragment_a.range,
585 current_file,
586 fragment_b.range,
587 );
588
589 if min_lines > 0 {
593 let lines = fragment_a.end.line as usize - fragment_a.start.line as usize;
594 if lines < min_lines {
595 return;
596 }
597 }
598
599 let unmatched_lines = [
600 host_lines_in(existing_file, &fragment_a),
601 host_lines_in(current_file, &fragment_b),
602 ];
603
604 clones.push(CpdClone {
605 format: current_file.format.clone(),
606 fragment_a,
607 fragment_b,
608 token_count: match_len as u32,
609 is_new: false,
610 kind,
611 similarity: None,
612 similarity_method: None,
613 unmatched_lines,
614 });
615}
616
617fn host_lines_in(source: &PreparedSource, fragment: &Fragment) -> u32 {
625 if !source.embedded {
626 return 0;
627 }
628 let (first, last) = (fragment.range[0] as usize, fragment.range[1] as usize);
629 let Some(spans) = source.spans.get(first..=last) else {
630 return 0;
631 };
632 let span = fragment.end.line.saturating_sub(fragment.start.line) + 1;
633 let covered = covered_lines(spans.iter().map(|(s, e)| (s.line, e.line)));
634 span.saturating_sub(covered)
635}
636
637fn clone_kind(
646 a: &PreparedSource,
647 a_range: [u32; 2],
648 b: &PreparedSource,
649 b_range: [u32; 2],
650) -> CloneKind {
651 if a.raw_hashes.is_empty() && b.raw_hashes.is_empty() {
652 return CloneKind::Exact;
653 }
654 let (na, ra) = range_slices(a, a_range);
655 let (nb, rb) = range_slices(b, b_range);
656 let matched = na.iter().zip(nb).take_while(|(x, y)| x == y).count();
657 if ra[..matched.min(ra.len())] == rb[..matched.min(rb.len())] {
658 CloneKind::Exact
659 } else {
660 CloneKind::Renamed
661 }
662}
663
664fn range_slices(p: &PreparedSource, range: [u32; 2]) -> (&[u64], &[u64]) {
666 let start = (range[0] as usize).min(p.hashes.len());
667 let end = (range[1] as usize + 1).min(p.hashes.len());
668 let raw = if p.raw_hashes.is_empty() {
669 &p.hashes
670 } else {
671 &p.raw_hashes
672 };
673 (&p.hashes[start..end], &raw[start..end])
674}
675
676fn make_fragment(
677 source_id: &str,
678 spans: &[(Location, Location)],
679 start_idx: usize,
680 end_idx: usize,
681) -> Option<Fragment> {
682 let (first_start, _) = spans.get(start_idx)?;
683 let (_, last_end) = spans.get(end_idx)?;
684 Some(Fragment {
685 source_id: source_id.to_string(),
686 source_root: None,
687 start: first_start.clone(),
688 end: last_end.clone(),
689 range: [start_idx as u32, end_idx as u32],
690 blame: None,
691 })
692}
693
694pub const MIN_GAP_SIMILARITY: f32 = 0.5;
702
703pub fn merge_gapped_clones(mut clones: Vec<CpdClone>, max_gap_lines: usize) -> Vec<CpdClone> {
716 if max_gap_lines == 0 || clones.len() < 2 {
717 return clones;
718 }
719 clones.sort_by(|x, y| {
720 pair_key(x)
721 .cmp(&pair_key(y))
722 .then(x.fragment_a.range[0].cmp(&y.fragment_a.range[0]))
723 .then(x.fragment_b.range[0].cmp(&y.fragment_b.range[0]))
724 });
725 let mut merged: Vec<CpdClone> = Vec::with_capacity(clones.len());
726 for clone in clones {
727 let extended = match merged.last_mut() {
728 Some(last) if pair_key(last) == pair_key(&clone) => {
729 merge_into(last, &clone, max_gap_lines)
730 }
731 _ => false,
732 };
733 if !extended {
734 merged.push(clone);
735 }
736 }
737 merged
738}
739
740fn merge_into(last: &mut CpdClone, next: &CpdClone, max_gap_lines: usize) -> bool {
745 let Some(step_a) = continuation(&last.fragment_a, &next.fragment_a, max_gap_lines) else {
746 return false;
747 };
748 let Some(step_b) = continuation(&last.fragment_b, &next.fragment_b, max_gap_lines) else {
749 return false;
750 };
751 let matched = last.token_count
754 + next
755 .token_count
756 .saturating_sub(step_a.overlap.max(step_b.overlap));
757 let span_a = next.fragment_a.range[1] - last.fragment_a.range[0] + 1;
758 let span_b = next.fragment_b.range[1] - last.fragment_b.range[0] + 1;
759 let similarity = matched as f32 / span_a.max(span_b) as f32;
760 if similarity < MIN_GAP_SIMILARITY {
761 return false;
762 }
763 last.fragment_a.end = next.fragment_a.end.clone();
764 last.fragment_a.range[1] = next.fragment_a.range[1];
765 last.fragment_b.end = next.fragment_b.end.clone();
766 last.fragment_b.range[1] = next.fragment_b.range[1];
767 last.token_count = matched;
768 last.similarity = Some(similarity);
769 last.similarity_method = Some(SimilarityMethod::Gap);
770 last.kind = CloneKind::Similar;
771 last.unmatched_lines[0] += next.unmatched_lines[0] + step_a.gap_lines;
774 last.unmatched_lines[1] += next.unmatched_lines[1] + step_b.gap_lines;
775 true
776}
777
778fn pair_key(c: &CpdClone) -> (&str, &str, &str) {
779 (
780 c.format.as_str(),
781 c.fragment_a.source_id.as_str(),
782 c.fragment_b.source_id.as_str(),
783 )
784}
785
786#[derive(Debug, Clone, Copy, PartialEq, Eq)]
789struct Continuation {
790 overlap: u32,
791 gap_lines: u32,
792}
793
794fn continuation(prev: &Fragment, next: &Fragment, max_gap_lines: usize) -> Option<Continuation> {
797 if next.range[0] <= prev.range[0] || next.range[1] <= prev.range[1] {
798 return None;
799 }
800 let gap_lines = next.start.line.saturating_sub(prev.end.line + 1);
801 if gap_lines as usize > max_gap_lines {
802 return None;
803 }
804 Some(Continuation {
805 overlap: (prev.range[1] + 1).saturating_sub(next.range[0]),
806 gap_lines,
807 })
808}
809
810fn dedup_exact_clones(clones: &mut Vec<CpdClone>) {
811 for clone in clones.iter_mut() {
813 let a_key = (&clone.fragment_a.source_id, clone.fragment_a.start.line);
814 let b_key = (&clone.fragment_b.source_id, clone.fragment_b.start.line);
815 if a_key > b_key {
816 std::mem::swap(&mut clone.fragment_a, &mut clone.fragment_b);
817 }
818 }
819
820 let mut seen: FxHashSet<CloneDedupKey> = FxHashSet::default();
821 clones.retain(|c| seen.insert(CloneDedupKey::from_clone(c)));
822}
823
824fn remember_repeated_window(
829 repeated_windows: &mut FxHashMap<u64, Vec<Occurrence>>,
830 hash: u64,
831 occurrence: Occurrence,
832 cap: usize,
833) {
834 let bucket = repeated_windows.entry(hash).or_default();
835 if bucket
836 .iter()
837 .any(|s| s.source_id == occurrence.source_id && s.token_start == occurrence.token_start)
838 {
839 return;
840 }
841 if bucket.len() < cap {
842 bucket.push(occurrence);
843 }
844}
845
846struct SecondaryOpen {
847 clone: CpdClone,
848 source_a: usize,
849 source_b: usize,
850 last_token_start_a: usize,
851 last_token_start_b: usize,
852}
853
854#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
855struct Candidate {
856 source_a: usize,
857 source_b: usize,
858 token_a: usize,
859 token_b: usize,
860}
861
862impl SecondaryOpen {
863 fn is_continuation(&self, candidate: &Candidate) -> bool {
866 self.source_a == candidate.source_a
867 && self.source_b == candidate.source_b
868 && self.last_token_start_a + 1 == candidate.token_a
869 && self.last_token_start_b + 1 == candidate.token_b
870 }
871
872 fn grow(&mut self, candidate: &Candidate, prepared: &[PreparedSource], min_tokens: usize) {
875 self.clone.token_count += 1;
876 let end_a = candidate.token_a + min_tokens;
877 let end_b = candidate.token_b + min_tokens;
878 if let Some(span) = prepared[self.source_a].spans.get(end_a) {
879 self.clone.fragment_a.end = span.1.clone();
880 self.clone.fragment_a.range[1] = end_a as u32;
881 }
882 if let Some(span) = prepared[self.source_b].spans.get(end_b) {
883 self.clone.fragment_b.end = span.1.clone();
884 self.clone.fragment_b.range[1] = end_b as u32;
885 }
886 self.last_token_start_a = candidate.token_a;
887 self.last_token_start_b = candidate.token_b;
888 }
889}
890
891fn add_secondary_clones(
892 repeated_windows: FxHashMap<u64, Vec<Occurrence>>,
893 prepared: &[PreparedSource],
894 min_tokens: usize,
895 min_lines: usize,
896 filters: &PathFilters,
897 clones: &mut Vec<CpdClone>,
898) {
899 if repeated_windows.is_empty() {
900 return;
901 }
902
903 let mut candidates: Vec<Candidate> = Vec::new();
904 for occurrences in repeated_windows.values() {
905 if occurrences.len() < 2 {
906 continue;
907 }
908 for li in 0..occurrences.len() {
909 for ri in li + 1..occurrences.len() {
910 let left = &occurrences[li];
911 let right = &occurrences[ri];
912 if left.source_id == right.source_id && left.token_start == right.token_start {
913 continue;
914 }
915 let lh = &prepared[left.source_id].hashes;
916 let rh = &prepared[right.source_id].hashes;
917 let la = left.token_start;
918 let ra = right.token_start;
919 if la + min_tokens > lh.len() || ra + min_tokens > rh.len() {
920 continue;
921 }
922 if lh[la..la + min_tokens] != rh[ra..ra + min_tokens] {
923 continue;
924 }
925 let (sa, ta, sb, tb) =
926 if (left.source_id, left.token_start) <= (right.source_id, right.token_start) {
927 (
928 left.source_id,
929 left.token_start,
930 right.source_id,
931 right.token_start,
932 )
933 } else {
934 (
935 right.source_id,
936 right.token_start,
937 left.source_id,
938 left.token_start,
939 )
940 };
941 candidates.push(Candidate {
942 source_a: sa,
943 source_b: sb,
944 token_a: ta,
945 token_b: tb,
946 });
947 }
948 }
949 }
950 if candidates.is_empty() {
951 return;
952 }
953 candidates.sort_unstable();
954 candidates.dedup();
955
956 let mut coverage = LineCoverage::from_clones(prepared, clones);
958 let mut open: Option<SecondaryOpen> = None;
959
960 for candidate in candidates {
961 if let Some(current) = open.as_mut()
962 && current.is_continuation(&candidate)
963 {
964 current.grow(&candidate, prepared, min_tokens);
965 continue;
966 }
967
968 flush_secondary_clone(
969 open.take(),
970 prepared,
971 min_lines,
972 filters,
973 clones,
974 &mut coverage,
975 );
976
977 let start_a = candidate.token_a;
979 let end_a = start_a + min_tokens - 1;
980 let start_b = candidate.token_b;
981 let end_b = start_b + min_tokens - 1;
982
983 let frag_a = match make_fragment(
984 &prepared[candidate.source_a].id,
985 &prepared[candidate.source_a].spans,
986 start_a,
987 end_a,
988 ) {
989 Some(f) => f,
990 None => continue,
991 };
992 let frag_b = match make_fragment(
993 &prepared[candidate.source_b].id,
994 &prepared[candidate.source_b].spans,
995 start_b,
996 end_b,
997 ) {
998 Some(f) => f,
999 None => continue,
1000 };
1001
1002 open = Some(SecondaryOpen {
1003 clone: CpdClone {
1004 format: prepared[candidate.source_a].format.clone(),
1005 fragment_a: frag_a,
1006 fragment_b: frag_b,
1007 token_count: min_tokens as u32,
1008 is_new: false,
1009 kind: Default::default(),
1010 similarity: None,
1011 similarity_method: None,
1012 unmatched_lines: [0, 0],
1013 },
1014 source_a: candidate.source_a,
1015 source_b: candidate.source_b,
1016 last_token_start_a: candidate.token_a,
1017 last_token_start_b: candidate.token_b,
1018 });
1019 }
1020
1021 flush_secondary_clone(
1022 open.take(),
1023 prepared,
1024 min_lines,
1025 filters,
1026 clones,
1027 &mut coverage,
1028 );
1029}
1030
1031fn flush_secondary_clone(
1032 open: Option<SecondaryOpen>,
1033 prepared: &[PreparedSource],
1034 min_lines: usize,
1035 filters: &PathFilters,
1036 clones: &mut Vec<CpdClone>,
1037 coverage: &mut LineCoverage,
1038) {
1039 let Some(oc) = open else {
1040 return;
1041 };
1042
1043 let range_a = fragment_line_range(&oc.clone.fragment_a);
1044 let range_b = fragment_line_range(&oc.clone.fragment_b);
1045
1046 if filters.should_skip_pair(&prepared[oc.source_a], &prepared[oc.source_b]) {
1048 return;
1049 }
1050
1051 if min_lines > 0 {
1053 let lines = oc.clone.fragment_a.end.line as usize - oc.clone.fragment_a.start.line as usize;
1054 if lines < min_lines {
1055 return;
1056 }
1057 }
1058
1059 if !coverage.extends(oc.source_a, range_a) || !coverage.extends(oc.source_b, range_b) {
1063 return;
1064 }
1065
1066 let before = clones.len();
1067 let mut clone = oc.clone;
1068 clone.kind = clone_kind(
1069 &prepared[oc.source_a],
1070 clone.fragment_a.range,
1071 &prepared[oc.source_b],
1072 clone.fragment_b.range,
1073 );
1074 clone.unmatched_lines = [
1077 host_lines_in(&prepared[oc.source_a], &clone.fragment_a),
1078 host_lines_in(&prepared[oc.source_b], &clone.fragment_b),
1079 ];
1080 clones.push(clone);
1081
1082 if clones.len() > before {
1084 coverage.insert(oc.source_a, range_a);
1085 coverage.insert(oc.source_b, range_b);
1086 }
1087}
1088
1089fn fragment_line_range(fragment: &Fragment) -> (usize, usize) {
1090 let start = fragment.start.line as usize;
1091 let end = fragment.end.line as usize;
1092 (start.min(end), start.max(end))
1093}
1094
1095struct LineCoverage {
1100 ranges_by_source: Vec<Vec<(usize, usize)>>,
1101}
1102
1103impl LineCoverage {
1104 fn from_clones(prepared: &[PreparedSource], clones: &[CpdClone]) -> Self {
1105 let mut source_lookup: FxHashMap<&str, usize> = FxHashMap::default();
1106 for (idx, source) in prepared.iter().enumerate() {
1107 source_lookup.insert(source.id.as_str(), idx);
1108 }
1109 let mut coverage = Self {
1110 ranges_by_source: vec![Vec::new(); prepared.len()],
1111 };
1112 for clone in clones {
1113 if let Some(idx) = source_lookup.get(clone.fragment_a.source_id.as_str()) {
1114 coverage.insert(*idx, fragment_line_range(&clone.fragment_a));
1115 }
1116 if let Some(idx) = source_lookup.get(clone.fragment_b.source_id.as_str()) {
1117 coverage.insert(*idx, fragment_line_range(&clone.fragment_b));
1118 }
1119 }
1120 coverage
1121 }
1122
1123 fn extends(&self, source_idx: usize, range: (usize, usize)) -> bool {
1124 let Some(intervals) = self.ranges_by_source.get(source_idx) else {
1128 return true;
1129 };
1130 let mut cursor = range.0;
1131 for &(start, end) in intervals {
1132 if end < cursor {
1133 continue;
1134 }
1135 if start > cursor {
1136 return true;
1137 }
1138 cursor = cursor.max(end.saturating_add(1));
1139 if cursor > range.1 {
1140 return false;
1141 }
1142 }
1143 cursor <= range.1
1144 }
1145
1146 fn insert(&mut self, source_idx: usize, range: (usize, usize)) {
1147 let Some(intervals) = self.ranges_by_source.get_mut(source_idx) else {
1152 return;
1153 };
1154 let mut folded = Vec::with_capacity(intervals.len() + 1);
1155 let mut pending = Some(range);
1156 for &(start, end) in intervals.iter() {
1157 let p = match pending.take() {
1158 None => {
1159 folded.push((start, end));
1160 continue;
1161 }
1162 Some(p) => p,
1163 };
1164 if p.1.saturating_add(1) < start {
1166 folded.push(p);
1167 folded.push((start, end));
1168 }
1169 else if end.saturating_add(1) < p.0 {
1171 folded.push((start, end));
1172 pending = Some(p);
1173 }
1174 else {
1176 pending = Some((p.0.min(start), p.1.max(end)));
1177 }
1178 }
1179 if let Some(p) = pending {
1180 folded.push(p);
1181 }
1182 *intervals = folded;
1183 }
1184}
1185
1186#[cfg(test)]
1191mod tests {
1192 use super::*;
1193
1194 fn tok(hash: u64, raw_hash: u64, line: u32) -> DetectionToken {
1195 let loc = Location {
1196 line,
1197 column: 0,
1198 offset: line,
1199 };
1200 DetectionToken {
1201 hash,
1202 raw_hash,
1203 start: loc.clone(),
1204 end: loc,
1205 range: [line as usize, line as usize + 1],
1206 }
1207 }
1208
1209 fn gap_clone(
1212 a: &str,
1213 a_tok: [u32; 2],
1214 a_lines: [u32; 2],
1215 b: &str,
1216 b_tok: [u32; 2],
1217 b_lines: [u32; 2],
1218 ) -> CpdClone {
1219 let frag = |id: &str, tok: [u32; 2], lines: [u32; 2]| Fragment {
1220 source_id: id.to_string(),
1221 source_root: None,
1222 start: Location {
1223 line: lines[0],
1224 column: 1,
1225 offset: tok[0],
1226 },
1227 end: Location {
1228 line: lines[1],
1229 column: 1,
1230 offset: tok[1],
1231 },
1232 range: tok,
1233 blame: None,
1234 };
1235 CpdClone {
1236 format: "javascript".to_string(),
1237 fragment_a: frag(a, a_tok, a_lines),
1238 fragment_b: frag(b, b_tok, b_lines),
1239 token_count: a_tok[1] - a_tok[0] + 1,
1240 is_new: false,
1241 kind: CloneKind::Exact,
1242 similarity: None,
1243 similarity_method: None,
1244 unmatched_lines: [0, 0],
1245 }
1246 }
1247
1248 #[test]
1249 fn merge_gapped_is_a_no_op_at_zero() {
1250 let clones = vec![
1251 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1252 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1253 ];
1254 let out = merge_gapped_clones(clones.clone(), 0);
1255 assert_eq!(out, clones);
1256 }
1257
1258 #[test]
1259 fn merge_gapped_joins_adjacent_fragments_within_gap() {
1260 let clones = vec![
1262 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1263 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1264 ];
1265 let out = merge_gapped_clones(clones, 1);
1266 assert_eq!(out.len(), 1);
1267 let c = &out[0];
1268 assert_eq!(c.kind, CloneKind::Similar);
1269 assert_eq!(c.token_count, 20, "matched tokens");
1270 assert_eq!(c.fragment_a.range, [0, 19]);
1271 assert_eq!(c.fragment_b.range, [0, 21]);
1272 assert_eq!(c.fragment_a.end.line, 8);
1273 assert_eq!(c.fragment_b.end.line, 9);
1274 assert!((c.similarity.unwrap() - 20.0 / 22.0).abs() < 1e-6);
1276 }
1277
1278 #[test]
1279 fn merge_gapped_respects_the_line_limit_and_file_pair() {
1280 let far = vec![
1281 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1282 gap_clone("a", [10, 19], [5, 8], "b", [20, 29], [8, 11]), ];
1284 assert_eq!(merge_gapped_clones(far.clone(), 2).len(), 2);
1285 assert_eq!(merge_gapped_clones(far, 3).len(), 1);
1286
1287 let other_pair = vec![
1288 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1289 gap_clone("a", [10, 19], [5, 8], "c", [10, 19], [5, 8]),
1290 ];
1291 assert_eq!(merge_gapped_clones(other_pair, 5).len(), 2);
1292 }
1293
1294 #[test]
1295 fn merge_gapped_counts_a_shared_boundary_token_once_and_chains() {
1296 let clones = vec![
1298 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1299 gap_clone("a", [9, 18], [4, 8], "b", [11, 20], [6, 9]),
1300 gap_clone("a", [19, 28], [9, 12], "b", [22, 31], [10, 13]),
1301 ];
1302 let out = merge_gapped_clones(clones, 1);
1303 assert_eq!(out.len(), 1);
1304 assert_eq!(out[0].token_count, 29, "10 + (10 - 1 overlap) + 10");
1305 assert_eq!(out[0].fragment_a.range, [0, 28]);
1306 assert_eq!(out[0].fragment_b.range, [0, 31]);
1307 }
1308
1309 #[test]
1310 fn merge_gapped_never_merges_overlapping_or_reordered_fragments() {
1311 let nested = vec![
1312 gap_clone("a", [0, 19], [1, 8], "b", [0, 19], [1, 8]),
1313 gap_clone("a", [5, 9], [3, 4], "b", [5, 9], [3, 4]),
1314 ];
1315 assert_eq!(merge_gapped_clones(nested, 5).len(), 2);
1316 let crossed = vec![
1317 gap_clone("a", [0, 9], [1, 4], "b", [20, 29], [10, 13]),
1318 gap_clone("a", [10, 19], [5, 8], "b", [0, 9], [1, 4]),
1319 ];
1320 assert_eq!(merge_gapped_clones(crossed, 5).len(), 2);
1321 }
1322
1323 #[test]
1324 fn merge_gapped_refuses_a_gap_wider_than_the_match() {
1325 let wide = vec![
1328 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1329 gap_clone("a", [10, 19], [5, 8], "b", [40, 49], [6, 9]),
1330 ];
1331 let out = merge_gapped_clones(wide, 1);
1332 assert_eq!(out.len(), 2);
1333 assert!(out.iter().all(|c| c.kind == CloneKind::Exact));
1334 assert!(out.iter().all(|c| c.similarity.is_none()));
1335 let at_floor = vec![
1337 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1338 gap_clone("a", [10, 19], [5, 8], "b", [30, 39], [6, 9]),
1339 ];
1340 let out = merge_gapped_clones(at_floor, 1);
1341 assert_eq!(out.len(), 1);
1342 assert!((out[0].similarity.unwrap() - MIN_GAP_SIMILARITY).abs() < 1e-6);
1343 }
1344
1345 #[test]
1346 fn merge_gapped_records_unmatched_lines_per_fragment() {
1347 let clones = vec![
1349 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1350 gap_clone("a", [10, 19], [5, 8], "b", [14, 23], [7, 10]),
1351 ];
1352 let out = merge_gapped_clones(clones, 2);
1353 assert_eq!(out.len(), 1);
1354 assert_eq!(out[0].unmatched_lines, [0, 2]);
1355 assert_eq!(out[0].fragment_b.end.line, 10);
1356 }
1357
1358 #[test]
1359 fn merge_gapped_reports_renamed_halves_as_similar() {
1360 let mut clones = vec![
1361 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1362 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1363 ];
1364 for c in &mut clones {
1365 c.kind = CloneKind::Renamed;
1366 }
1367 let out = merge_gapped_clones(clones, 1);
1368 assert_eq!(out.len(), 1);
1369 assert_eq!(
1370 out[0].kind,
1371 CloneKind::Similar,
1372 "similar takes precedence over renamed"
1373 );
1374 }
1375
1376 #[test]
1377 fn prepared_source_skips_raw_hashes_when_nothing_was_normalized() {
1378 let tokens = vec![tok(1, 1, 1), tok(2, 2, 2)];
1379 let p = PreparedSource::from_detection_tokens("a".into(), "js".into(), &tokens);
1380 assert!(p.raw_hashes.is_empty());
1381 }
1382
1383 #[test]
1384 fn prepared_source_backfills_raw_hashes_from_first_normalized_token() {
1385 let tokens = vec![tok(1, 1, 1), tok(2, 2, 2), tok(3, 30, 3), tok(4, 4, 4)];
1386 let p = PreparedSource::from_detection_tokens("a".into(), "js".into(), &tokens);
1387 assert_eq!(p.raw_hashes, vec![1, 2, 30, 4]);
1388 }
1389
1390 #[test]
1391 fn clone_kind_is_exact_without_raw_hashes_and_renamed_when_raw_differs() {
1392 let a = PreparedSource::from_detection_tokens(
1393 "a".into(),
1394 "js".into(),
1395 &[tok(1, 1, 1), tok(2, 2, 2), tok(3, 3, 3)],
1396 );
1397 let b = PreparedSource::from_detection_tokens(
1398 "b".into(),
1399 "js".into(),
1400 &[tok(1, 1, 1), tok(2, 20, 2), tok(3, 3, 3)],
1401 );
1402 assert_eq!(clone_kind(&a, [0, 2], &a, [0, 2]), CloneKind::Exact);
1403 assert_eq!(clone_kind(&a, [0, 2], &b, [0, 2]), CloneKind::Renamed);
1404 assert_eq!(clone_kind(&a, [2, 2], &b, [2, 2]), CloneKind::Exact);
1406 let c = PreparedSource::from_detection_tokens(
1408 "c".into(),
1409 "js".into(),
1410 &[tok(1, 1, 1), tok(2, 2, 2), tok(9, 90, 3)],
1411 );
1412 assert_eq!(clone_kind(&a, [0, 2], &c, [0, 2]), CloneKind::Exact);
1413 }
1414 use crate::models::{Location, Token, TokenKind};
1415
1416 fn loc(line: u32, col: u32, offset: u32) -> Location {
1417 Location {
1418 line,
1419 column: col,
1420 offset,
1421 }
1422 }
1423
1424 fn make_token(kind: TokenKind, value: &str, line: u32, col: u32, offset: u32) -> Token {
1425 let end_col = col + value.len() as u32;
1426 let end_off = offset + value.len() as u32;
1427 Token {
1428 kind,
1429 value: value.to_string(),
1430 start: loc(line, col, offset),
1431 end: loc(line, end_col, end_off),
1432 }
1433 }
1434
1435 fn make_file(id: &str, format: &str, tokens: Vec<Token>) -> SourceFile {
1436 SourceFile {
1437 bytes: 0,
1438 id: id.to_string(),
1439 format: format.to_string(),
1440 tokens,
1441 }
1442 }
1443
1444 fn make_prepared(
1447 id: &str,
1448 format: &str,
1449 hashes: Vec<u64>,
1450 spans: Vec<(Location, Location)>,
1451 ) -> PreparedSource {
1452 PreparedSource {
1453 id: id.to_string(),
1454 format: format.to_string(),
1455 hashes,
1456 spans,
1457 raw_hashes: Vec::new(),
1458 functions: Vec::new(),
1459 real_path: String::new(),
1460 embedded: false,
1461 }
1462 }
1463
1464 fn js_tokens_ab() -> Vec<Token> {
1465 vec![
1466 make_token(TokenKind::Keyword, "function", 1, 0, 0),
1467 make_token(TokenKind::Other, "hello", 1, 9, 9),
1468 make_token(TokenKind::Operator, "(", 1, 14, 14),
1469 make_token(TokenKind::Operator, ")", 1, 15, 15),
1470 make_token(TokenKind::Operator, "{", 1, 16, 16),
1471 make_token(TokenKind::Keyword, "return", 2, 0, 18),
1472 make_token(TokenKind::Literal, "42", 2, 7, 25),
1473 make_token(TokenKind::Operator, ";", 2, 9, 27),
1474 make_token(TokenKind::Operator, "}", 3, 0, 29),
1475 ]
1476 }
1477
1478 #[test]
1479 fn empty_input_returns_empty() {
1480 let result = detect(&[], 10);
1481 assert!(result.is_empty());
1482 }
1483
1484 fn pair_with_js_tokens(min_tokens: usize) -> Vec<CpdClone> {
1485 let tokens = js_tokens_ab();
1486 let file_a = make_file("a.js", "javascript", tokens.clone());
1487 let file_b = make_file("b.js", "javascript", tokens);
1488 detect(&[file_a, file_b], min_tokens)
1489 }
1490
1491 #[test]
1492 fn identical_files_detected_as_clone() {
1493 assert!(
1494 !pair_with_js_tokens(5).is_empty(),
1495 "identical files must produce at least one clone"
1496 );
1497 }
1498
1499 #[test]
1500 fn min_tokens_threshold_respected() {
1501 assert!(
1502 pair_with_js_tokens(100).is_empty(),
1503 "no clones when min_tokens exceeds file length"
1504 );
1505 }
1506
1507 #[test]
1508 fn deduplication_ab_ba_collapse() {
1509 assert_eq!(
1510 pair_with_js_tokens(5).len(),
1511 1,
1512 "symmetric pairs must collapse to 1"
1513 );
1514 }
1515
1516 #[test]
1517 fn different_formats_not_cross_detected() {
1518 let tokens = js_tokens_ab();
1519 let file_js = make_file("a.js", "javascript", tokens.clone());
1520 let file_py = make_file("a.py", "python", tokens);
1521 let clones = detect(&[file_js, file_py], 5);
1522 assert!(
1523 clones.is_empty(),
1524 "tokens from different formats must not match"
1525 );
1526 }
1527
1528 #[test]
1529 fn cross_format_group_detected() {
1530 let to_prepared = |id: &str, format: &str| {
1534 let tokens = js_tokens_ab();
1535 let mut hashes = Vec::new();
1536 let mut spans = Vec::new();
1537 for t in &tokens {
1538 hashes.push(token_hash(t.kind.discriminant(), &t.value));
1539 spans.push((t.start.clone(), t.end.clone()));
1540 }
1541 make_prepared(id, format, hashes, spans)
1542 };
1543 let group = vec![
1544 to_prepared("a.js", "javascript"),
1545 to_prepared("a.ts", "typescript"),
1546 ];
1547 let clones = detect_prepared(vec![group], 5, 0, &PathFilters::default());
1548 assert_eq!(
1549 clones.len(),
1550 1,
1551 "identical token streams in one pool must match across formats"
1552 );
1553 }
1554
1555 #[test]
1565 fn open_clone_extends_only_while_its_anchor_continues() {
1566 let min_tokens = 5;
1567 let x: Vec<u64> = (100..112).collect(); let t: Vec<u64> = vec![900, 901, 902, 903, 904, 905];
1569 let z: Vec<u64> = vec![700, 701, 702, 703, 704, 705, 706];
1570 let renamed = |first: u64| {
1571 let mut v = x.clone();
1572 v[0] = first;
1573 v
1574 };
1575 let stream = |parts: &[&[u64]]| -> Vec<u64> { parts.concat() };
1576 let a = stream(&[&x]);
1577 let b = stream(&[&renamed(1), &t, &renamed(2)]);
1578 let c = stream(&[&x, &t, &z]);
1579 let streams: Vec<(&str, Vec<u64>)> =
1580 vec![("a", a.clone()), ("b", b.clone()), ("c", c.clone())];
1581 let to_prepared = |id: &str, hashes: Vec<u64>| {
1582 let spans = (0..hashes.len())
1583 .map(|i| {
1584 let loc = Location {
1585 line: i as u32 + 1,
1586 column: 1,
1587 offset: i as u32,
1588 };
1589 (loc.clone(), loc)
1590 })
1591 .collect();
1592 make_prepared(id, "javascript", hashes, spans)
1593 };
1594 let group = vec![
1595 to_prepared("a", a),
1596 to_prepared("b", b),
1597 to_prepared("c", c),
1598 ];
1599 let clones = detect_prepared(vec![group], min_tokens, 0, &PathFilters::default());
1600 let a_c: Vec<&CpdClone> = clones
1601 .iter()
1602 .filter(|cl| cl.fragment_a.source_id == "a" && cl.fragment_b.source_id == "c")
1603 .collect();
1604 assert_eq!(
1605 a_c.len(),
1606 1,
1607 "a↔c must be reported once, got {:?}",
1608 clones
1609 .iter()
1610 .map(|cl| (
1611 cl.fragment_a.source_id.as_str(),
1612 cl.fragment_a.range,
1613 cl.fragment_b.source_id.as_str(),
1614 cl.fragment_b.range,
1615 cl.token_count
1616 ))
1617 .collect::<Vec<_>>()
1618 );
1619 assert_eq!(
1620 a_c[0].token_count,
1621 x.len() as u32,
1622 "exactly X, not X plus T"
1623 );
1624 assert_eq!(a_c[0].fragment_a.range, [0, x.len() as u32 - 1]);
1625 assert_eq!(a_c[0].fragment_b.range, [0, x.len() as u32 - 1]);
1626 let run = |frag: &Fragment| -> &[u64] {
1628 let hashes = &streams
1629 .iter()
1630 .find(|(id, _)| *id == frag.source_id)
1631 .unwrap()
1632 .1;
1633 &hashes[frag.range[0] as usize..=frag.range[1] as usize]
1634 };
1635 for cl in &clones {
1636 assert_eq!(
1637 run(&cl.fragment_a),
1638 run(&cl.fragment_b),
1639 "{}{:?} and {}{:?} must hold the same tokens",
1640 cl.fragment_a.source_id,
1641 cl.fragment_a.range,
1642 cl.fragment_b.source_id,
1643 cl.fragment_b.range
1644 );
1645 }
1646 }
1647
1648 #[test]
1649 fn identical_files_maximal_clone() {
1650 let tokens = js_tokens_ab();
1653 let file_a = make_file("a.js", "javascript", tokens.clone());
1654 let file_b = make_file("b.js", "javascript", tokens);
1655 let clones = detect(&[file_a, file_b], 5);
1656 assert_eq!(
1657 clones.len(),
1658 1,
1659 "open_clone SM must produce one maximal clone"
1660 );
1661 assert_eq!(
1662 clones[0].token_count, 9,
1663 "maximal clone must cover all 9 tokens"
1664 );
1665 }
1666
1667 #[test]
1668 fn three_identical_files_secondary_pass_adds_missing_pair() {
1669 let tokens = js_tokens_ab();
1670 let file_a = make_file("a.js", "javascript", tokens.clone());
1671 let file_b = make_file("b.js", "javascript", tokens.clone());
1672 let file_c = make_file("c.js", "javascript", tokens);
1673 let clones = detect(&[file_a, file_b, file_c], 5);
1674 assert!(
1675 clones.len() >= 2,
1676 "three identical files must yield at least 2 clone pairs, got {}",
1677 clones.len()
1678 );
1679 }
1680
1681 #[test]
1682 fn clones_sorted_by_source_and_line() {
1683 let tokens = js_tokens_ab();
1684 let file_a = make_file("a.js", "javascript", tokens.clone());
1685 let file_b = make_file("b.js", "javascript", tokens);
1686 let clones = detect(&[file_a, file_b], 5);
1687 for i in 1..clones.len() {
1688 let prev = &clones[i - 1];
1689 let curr = &clones[i];
1690 assert!(
1691 (
1692 &prev.fragment_a.source_id,
1693 prev.fragment_a.start.line,
1694 &prev.fragment_b.source_id,
1695 prev.fragment_b.start.line,
1696 ) <= (
1697 &curr.fragment_a.source_id,
1698 curr.fragment_a.start.line,
1699 &curr.fragment_b.source_id,
1700 curr.fragment_b.start.line,
1701 ),
1702 "clones must be sorted"
1703 );
1704 }
1705 }
1706
1707 #[test]
1708 fn filter_path_is_the_real_path_when_it_differs_from_the_id() {
1709 let mut source = PreparedSource::from_detection_tokens(
1710 "/repo/corpus/x.js".into(),
1711 "javascript".into(),
1712 &[],
1713 );
1714 assert_eq!(source.filter_path(), "/repo/corpus/x.js");
1715 source.real_path = "/elsewhere/x.js".into();
1716 assert_eq!(source.filter_path(), "/elsewhere/x.js");
1717 }
1718
1719 fn isolated(groups: &[&[&str]]) -> Vec<Vec<PathBuf>> {
1720 groups
1721 .iter()
1722 .map(|g| g.iter().map(PathBuf::from).collect())
1723 .collect()
1724 }
1725
1726 #[test]
1727 fn skip_isolated_drops_pairs_across_group_folders() {
1728 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1729 assert!(should_skip_isolated(
1730 "/repo/packages/a/src/x.js",
1731 "/repo/packages/b/src/y.js",
1732 &groups
1733 ));
1734 }
1735
1736 #[test]
1737 fn skip_isolated_keeps_pairs_inside_one_folder() {
1738 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1739 assert!(!should_skip_isolated(
1740 "/repo/packages/a/src/x.js",
1741 "/repo/packages/a/lib/y.js",
1742 &groups
1743 ));
1744 }
1745
1746 #[test]
1747 fn skip_isolated_keeps_pairs_with_one_file_outside_group() {
1748 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1749 assert!(!should_skip_isolated(
1750 "/repo/packages/a/src/x.js",
1751 "/repo/globals/y.js",
1752 &groups
1753 ));
1754 assert!(!should_skip_isolated(
1755 "/repo/globals/x.js",
1756 "/repo/infra/y.js",
1757 &groups
1758 ));
1759 }
1760
1761 #[test]
1762 fn skip_isolated_folders_in_different_groups_do_not_isolate() {
1763 let groups = isolated(&[
1764 &["/repo/packages/a", "/repo/packages/b"],
1765 &["/repo/libs/a", "/repo/libs/b"],
1766 ]);
1767 assert!(!should_skip_isolated(
1768 "/repo/packages/a/x.js",
1769 "/repo/libs/b/y.js",
1770 &groups
1771 ));
1772 assert!(should_skip_isolated(
1773 "/repo/libs/a/x.js",
1774 "/repo/libs/b/y.js",
1775 &groups
1776 ));
1777 }
1778
1779 #[test]
1780 fn path_filters_combine_skip_local_and_skip_isolated() {
1781 let scan_roots = vec![PathBuf::from("/repo/shared")];
1782 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1783 let filters = PathFilters {
1784 skip_local: true,
1785 scan_roots: &scan_roots,
1786 isolated_groups: &groups,
1787 };
1788 assert!(filters.should_skip("/repo/shared/x.js", "/repo/shared/y.js"));
1789 assert!(filters.should_skip("/repo/packages/a/x.js", "/repo/packages/b/y.js"));
1790 assert!(!filters.should_skip("/repo/shared/x.js", "/repo/packages/a/y.js"));
1791 }
1792}