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::{CpdClone, DetectionToken, Fragment, Location, SourceFile, TokenKind},
10};
11
12type WindowStore = FxHashMap<u64, Occurrence>;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22struct Occurrence {
23 source_id: usize,
25 token_start: usize,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33struct CloneDedupKey {
34 a_id: String,
35 a_start_line: u32,
36 b_id: String,
37 b_start_line: u32,
38}
39
40impl CloneDedupKey {
41 fn from_clone(c: &CpdClone) -> Self {
42 let a_key = (&c.fragment_a.source_id, c.fragment_a.start.line);
44 let b_key = (&c.fragment_b.source_id, c.fragment_b.start.line);
45 if a_key <= b_key {
46 Self {
47 a_id: c.fragment_a.source_id.clone(),
48 a_start_line: c.fragment_a.start.line,
49 b_id: c.fragment_b.source_id.clone(),
50 b_start_line: c.fragment_b.start.line,
51 }
52 } else {
53 Self {
54 a_id: c.fragment_b.source_id.clone(),
55 a_start_line: c.fragment_b.start.line,
56 b_id: c.fragment_a.source_id.clone(),
57 b_start_line: c.fragment_a.start.line,
58 }
59 }
60 }
61}
62
63pub fn detect(files: &[SourceFile], min_tokens: usize) -> Vec<CpdClone> {
72 detect_with_options(files, min_tokens, 0, &PathFilters::default())
73}
74
75pub fn detect_with_options(
82 files: &[SourceFile],
83 min_tokens: usize,
84 min_lines: usize,
85 filters: &PathFilters,
86) -> Vec<CpdClone> {
87 if files.is_empty() || min_tokens == 0 {
88 return vec![];
89 }
90
91 let mut by_format: FxHashMap<&str, Vec<&SourceFile>> = FxHashMap::default();
93 for file in files {
94 by_format
95 .entry(file.format.as_str())
96 .or_default()
97 .push(file);
98 }
99 let mut format_groups: Vec<(&str, Vec<&SourceFile>)> = by_format.into_iter().collect();
100 format_groups.sort_unstable_by_key(|(fmt, _)| *fmt);
101 for (_, group) in &mut format_groups {
102 group.sort_unstable_by_key(|&file| file.id.as_str());
103 }
104
105 let mut clones: Vec<CpdClone> = format_groups
106 .into_par_iter()
107 .flat_map(|(_format, files)| {
108 let prepared: Vec<PreparedSource> = files
112 .into_iter()
113 .map(|file| {
114 let mut hashes = Vec::with_capacity(file.tokens.len());
115 let mut spans: Vec<(Location, Location)> =
116 Vec::with_capacity(file.tokens.len());
117 for t in &file.tokens {
118 if t.kind == TokenKind::Ignore {
119 continue;
120 }
121 hashes.push(token_hash(t.kind.discriminant(), &t.value));
122 spans.push((t.start.clone(), t.end.clone()));
123 }
124 PreparedSource {
125 id: file.id.clone(),
126 format: file.format.clone(),
127 hashes,
128 spans,
129 }
130 })
131 .collect();
132 detect_in_group(&prepared, min_tokens, min_lines, filters)
133 })
134 .collect();
135
136 finalize_clones(&mut clones);
137 clones
138}
139
140fn finalize_clones(clones: &mut Vec<CpdClone>) {
141 dedup_exact_clones(clones);
142 clones.sort_by(|a, b| {
143 (
144 &a.fragment_a.source_id,
145 a.fragment_a.start.line,
146 &a.fragment_b.source_id,
147 a.fragment_b.start.line,
148 )
149 .cmp(&(
150 &b.fragment_a.source_id,
151 b.fragment_a.start.line,
152 &b.fragment_b.source_id,
153 b.fragment_b.start.line,
154 ))
155 });
156}
157
158#[derive(Debug, Clone)]
167pub struct PreparedSource {
168 pub id: String,
169 pub format: String,
170 pub hashes: Vec<u64>,
171 pub spans: Vec<(Location, Location)>,
172}
173
174impl PreparedSource {
175 pub fn from_detection_tokens(id: String, format: String, tokens: &[DetectionToken]) -> Self {
177 let mut hashes = Vec::with_capacity(tokens.len());
178 let mut spans = Vec::with_capacity(tokens.len());
179 for t in tokens {
180 hashes.push(t.hash);
181 spans.push((t.start.clone(), t.end.clone()));
182 }
183 Self {
184 id,
185 format,
186 hashes,
187 spans,
188 }
189 }
190}
191
192pub fn detect_prepared(
197 format_groups: Vec<Vec<PreparedSource>>,
198 min_tokens: usize,
199 min_lines: usize,
200 filters: &PathFilters,
201) -> Vec<CpdClone> {
202 if format_groups.is_empty() || min_tokens == 0 {
203 return vec![];
204 }
205
206 let mut clones: Vec<CpdClone> = format_groups
207 .into_par_iter()
208 .flat_map(|group| detect_in_group(&group, min_tokens, min_lines, filters))
209 .collect();
210
211 finalize_clones(&mut clones);
212 clones
213}
214
215fn detect_in_group(
220 prepared: &[PreparedSource],
221 min_tokens: usize,
222 min_lines: usize,
223 filters: &PathFilters,
224) -> Vec<CpdClone> {
225 let window_power = base_pow(min_tokens.saturating_sub(1));
228
229 let total_windows: usize = prepared
231 .iter()
232 .map(|p| p.hashes.len().saturating_sub(min_tokens))
233 .sum();
234 let mut store: WindowStore =
235 FxHashMap::with_capacity_and_hasher(total_windows, Default::default());
236
237 let mut clones: Vec<CpdClone> = Vec::new();
238 const SECONDARY_OCCURRENCE_CAP: usize = 2;
242 let mut repeated_windows: FxHashMap<u64, Vec<Occurrence>> = FxHashMap::default();
243
244 for (file_idx, source) in prepared.iter().enumerate() {
245 let hashes = &source.hashes;
246 if hashes.len() < min_tokens {
247 continue;
248 }
249 let windows_len = hashes.len() - min_tokens + 1;
250
251 let mut open_clone: Option<OpenClone> = None;
256
257 let mut window_hash = hash_window(&hashes[..min_tokens]);
258
259 for token_start in 0..windows_len {
260 if token_start > 0 {
261 window_hash = roll(
262 window_hash,
263 hashes[token_start - 1],
264 hashes[token_start + min_tokens - 1],
265 window_power,
266 );
267 }
268
269 let current = Occurrence {
270 source_id: file_idx,
271 token_start,
272 };
273
274 match store.get(&window_hash).copied() {
275 Some(stored) if windows_match(stored, current, prepared, min_tokens) => {
276 if open_clone.is_none() {
277 open_clone = Some(OpenClone {
278 stored_occurrence: stored,
279 current_start: token_start,
280 match_len: min_tokens,
281 });
282 } else if let Some(ref mut oc) = open_clone {
283 oc.match_len += 1;
285 }
286 remember_repeated_window(
287 &mut repeated_windows,
288 window_hash,
289 stored,
290 SECONDARY_OCCURRENCE_CAP,
291 );
292 remember_repeated_window(
293 &mut repeated_windows,
294 window_hash,
295 current,
296 SECONDARY_OCCURRENCE_CAP,
297 );
298 }
301 _ => {
302 flush_clone(
304 open_clone.take(),
305 file_idx,
306 prepared,
307 min_lines,
308 filters,
309 &mut clones,
310 );
311 store.insert(window_hash, current);
312 }
313 }
314 }
315
316 flush_clone(
318 open_clone.take(),
319 file_idx,
320 prepared,
321 min_lines,
322 filters,
323 &mut clones,
324 );
325 }
326
327 add_secondary_clones(
328 repeated_windows,
329 prepared,
330 min_tokens,
331 min_lines,
332 filters,
333 &mut clones,
334 );
335
336 clones
337}
338
339#[derive(Debug, Default, Clone, Copy)]
348pub struct PathFilters<'a> {
349 pub skip_local: bool,
352 pub scan_roots: &'a [PathBuf],
354 pub isolated_groups: &'a [Vec<PathBuf>],
358}
359
360impl PathFilters<'_> {
361 fn should_skip(&self, file_a: &str, file_b: &str) -> bool {
363 (self.skip_local && should_skip_local(file_a, file_b, self.scan_roots))
364 || should_skip_isolated(file_a, file_b, self.isolated_groups)
365 }
366}
367
368fn should_skip_local(file_a: &str, file_b: &str, scan_roots: &[PathBuf]) -> bool {
372 scan_roots
373 .iter()
374 .any(|root| is_relative_to(file_a, root) && is_relative_to(file_b, root))
375}
376
377fn should_skip_isolated(file_a: &str, file_b: &str, isolated_groups: &[Vec<PathBuf>]) -> bool {
382 isolated_groups.iter().any(|group| {
383 let Some(dir_a) = group.iter().find(|dir| is_relative_to(file_a, dir)) else {
384 return false;
385 };
386 group
387 .iter()
388 .find(|dir| is_relative_to(file_b, dir))
389 .is_some_and(|dir_b| dir_a != dir_b)
390 })
391}
392
393fn is_relative_to(file_path: &str, dir: &PathBuf) -> bool {
397 let file = Path::new(file_path);
398 if let Ok(rel) = file.strip_prefix(dir) {
400 return !rel.as_os_str().is_empty();
401 }
402 if file.is_absolute() != dir.is_absolute() {
404 return false;
405 }
406 let mut ancestor = file;
408 loop {
409 if ancestor == dir.as_path() {
410 return false;
411 }
412 if ancestor.starts_with(dir) {
413 let rel = ancestor.strip_prefix(dir).unwrap_or(ancestor);
414 return !rel.as_os_str().is_empty();
415 }
416 ancestor = match ancestor.parent() {
417 Some(p) => p,
418 None => return false,
419 };
420 }
421}
422
423struct OpenClone {
424 stored_occurrence: Occurrence,
425 current_start: usize,
426 match_len: usize,
427}
428
429fn windows_match(
432 stored: Occurrence,
433 current: Occurrence,
434 prepared: &[PreparedSource],
435 min_tokens: usize,
436) -> bool {
437 if stored.source_id == current.source_id && stored.token_start == current.token_start {
438 return false;
439 }
440 let stored_hashes = &prepared[stored.source_id].hashes;
441 let current_hashes = &prepared[current.source_id].hashes;
442 if stored.token_start + min_tokens > stored_hashes.len()
443 || current.token_start + min_tokens > current_hashes.len()
444 {
445 return false;
446 }
447 stored_hashes[stored.token_start..stored.token_start + min_tokens]
448 == current_hashes[current.token_start..current.token_start + min_tokens]
449}
450
451fn flush_clone(
457 open: Option<OpenClone>,
458 current_file_idx: usize,
459 prepared: &[PreparedSource],
460 min_lines: usize,
461 filters: &PathFilters,
462 clones: &mut Vec<CpdClone>,
463) {
464 let oc = match open {
465 Some(o) => o,
466 None => return,
467 };
468
469 let existing = &oc.stored_occurrence;
470 let cur_start = oc.current_start;
471 let match_len = oc.match_len;
472
473 let existing_file = &prepared[existing.source_id];
474 let current_file = &prepared[current_file_idx];
475
476 let ex_start = existing.token_start;
477 let ex_end = ex_start + match_len - 1;
478 let cur_end = cur_start + match_len - 1;
479
480 if filters.should_skip(&existing_file.id, ¤t_file.id) {
483 return;
484 }
485
486 let fragment_a = match make_fragment(&existing_file.id, &existing_file.spans, ex_start, ex_end)
487 {
488 Some(f) => f,
489 None => return,
490 };
491 let fragment_b = match make_fragment(¤t_file.id, ¤t_file.spans, cur_start, cur_end)
492 {
493 Some(f) => f,
494 None => return,
495 };
496
497 if min_lines > 0 {
501 let lines = fragment_a.end.line as usize - fragment_a.start.line as usize;
502 if lines < min_lines {
503 return;
504 }
505 }
506
507 clones.push(CpdClone {
508 format: current_file.format.clone(),
509 fragment_a,
510 fragment_b,
511 token_count: match_len as u32,
512 is_new: false,
513 });
514}
515
516fn make_fragment(
517 source_id: &str,
518 spans: &[(Location, Location)],
519 start_idx: usize,
520 end_idx: usize,
521) -> Option<Fragment> {
522 let (first_start, _) = spans.get(start_idx)?;
523 let (_, last_end) = spans.get(end_idx)?;
524 Some(Fragment {
525 source_id: source_id.to_string(),
526 source_root: None,
527 start: first_start.clone(),
528 end: last_end.clone(),
529 range: [start_idx as u32, end_idx as u32],
530 blame: None,
531 })
532}
533
534fn dedup_exact_clones(clones: &mut Vec<CpdClone>) {
539 for clone in clones.iter_mut() {
541 let a_key = (&clone.fragment_a.source_id, clone.fragment_a.start.line);
542 let b_key = (&clone.fragment_b.source_id, clone.fragment_b.start.line);
543 if a_key > b_key {
544 std::mem::swap(&mut clone.fragment_a, &mut clone.fragment_b);
545 }
546 }
547
548 let mut seen: FxHashSet<CloneDedupKey> = FxHashSet::default();
549 clones.retain(|c| seen.insert(CloneDedupKey::from_clone(c)));
550}
551
552fn remember_repeated_window(
557 repeated_windows: &mut FxHashMap<u64, Vec<Occurrence>>,
558 hash: u64,
559 occurrence: Occurrence,
560 cap: usize,
561) {
562 let bucket = repeated_windows.entry(hash).or_default();
563 if bucket
564 .iter()
565 .any(|s| s.source_id == occurrence.source_id && s.token_start == occurrence.token_start)
566 {
567 return;
568 }
569 if bucket.len() < cap {
570 bucket.push(occurrence);
571 }
572}
573
574struct SecondaryOpen {
575 clone: CpdClone,
576 source_a: usize,
577 source_b: usize,
578 last_token_start_a: usize,
579 last_token_start_b: usize,
580}
581
582#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
583struct Candidate {
584 source_a: usize,
585 source_b: usize,
586 token_a: usize,
587 token_b: usize,
588}
589
590impl SecondaryOpen {
591 fn is_continuation(&self, candidate: &Candidate) -> bool {
594 self.source_a == candidate.source_a
595 && self.source_b == candidate.source_b
596 && self.last_token_start_a + 1 == candidate.token_a
597 && self.last_token_start_b + 1 == candidate.token_b
598 }
599
600 fn grow(&mut self, candidate: &Candidate, prepared: &[PreparedSource], min_tokens: usize) {
603 self.clone.token_count += 1;
604 let end_a = candidate.token_a + min_tokens;
605 let end_b = candidate.token_b + min_tokens;
606 if let Some(span) = prepared[self.source_a].spans.get(end_a) {
607 self.clone.fragment_a.end = span.1.clone();
608 self.clone.fragment_a.range[1] = end_a as u32;
609 }
610 if let Some(span) = prepared[self.source_b].spans.get(end_b) {
611 self.clone.fragment_b.end = span.1.clone();
612 self.clone.fragment_b.range[1] = end_b as u32;
613 }
614 self.last_token_start_a = candidate.token_a;
615 self.last_token_start_b = candidate.token_b;
616 }
617}
618
619fn add_secondary_clones(
620 repeated_windows: FxHashMap<u64, Vec<Occurrence>>,
621 prepared: &[PreparedSource],
622 min_tokens: usize,
623 min_lines: usize,
624 filters: &PathFilters,
625 clones: &mut Vec<CpdClone>,
626) {
627 if repeated_windows.is_empty() {
628 return;
629 }
630
631 let mut candidates: Vec<Candidate> = Vec::new();
632 for occurrences in repeated_windows.values() {
633 if occurrences.len() < 2 {
634 continue;
635 }
636 for li in 0..occurrences.len() {
637 for ri in li + 1..occurrences.len() {
638 let left = &occurrences[li];
639 let right = &occurrences[ri];
640 if left.source_id == right.source_id && left.token_start == right.token_start {
641 continue;
642 }
643 let lh = &prepared[left.source_id].hashes;
644 let rh = &prepared[right.source_id].hashes;
645 let la = left.token_start;
646 let ra = right.token_start;
647 if la + min_tokens > lh.len() || ra + min_tokens > rh.len() {
648 continue;
649 }
650 if lh[la..la + min_tokens] != rh[ra..ra + min_tokens] {
651 continue;
652 }
653 let (sa, ta, sb, tb) =
654 if (left.source_id, left.token_start) <= (right.source_id, right.token_start) {
655 (
656 left.source_id,
657 left.token_start,
658 right.source_id,
659 right.token_start,
660 )
661 } else {
662 (
663 right.source_id,
664 right.token_start,
665 left.source_id,
666 left.token_start,
667 )
668 };
669 candidates.push(Candidate {
670 source_a: sa,
671 source_b: sb,
672 token_a: ta,
673 token_b: tb,
674 });
675 }
676 }
677 }
678 if candidates.is_empty() {
679 return;
680 }
681 candidates.sort_unstable();
682 candidates.dedup();
683
684 let mut coverage = LineCoverage::from_clones(prepared, clones);
686 let mut open: Option<SecondaryOpen> = None;
687
688 for candidate in candidates {
689 if let Some(current) = open.as_mut()
690 && current.is_continuation(&candidate)
691 {
692 current.grow(&candidate, prepared, min_tokens);
693 continue;
694 }
695
696 flush_secondary_clone(
697 open.take(),
698 prepared,
699 min_lines,
700 filters,
701 clones,
702 &mut coverage,
703 );
704
705 let start_a = candidate.token_a;
707 let end_a = start_a + min_tokens - 1;
708 let start_b = candidate.token_b;
709 let end_b = start_b + min_tokens - 1;
710
711 let frag_a = match make_fragment(
712 &prepared[candidate.source_a].id,
713 &prepared[candidate.source_a].spans,
714 start_a,
715 end_a,
716 ) {
717 Some(f) => f,
718 None => continue,
719 };
720 let frag_b = match make_fragment(
721 &prepared[candidate.source_b].id,
722 &prepared[candidate.source_b].spans,
723 start_b,
724 end_b,
725 ) {
726 Some(f) => f,
727 None => continue,
728 };
729
730 open = Some(SecondaryOpen {
731 clone: CpdClone {
732 format: prepared[candidate.source_a].format.clone(),
733 fragment_a: frag_a,
734 fragment_b: frag_b,
735 token_count: min_tokens as u32,
736 is_new: false,
737 },
738 source_a: candidate.source_a,
739 source_b: candidate.source_b,
740 last_token_start_a: candidate.token_a,
741 last_token_start_b: candidate.token_b,
742 });
743 }
744
745 flush_secondary_clone(
746 open.take(),
747 prepared,
748 min_lines,
749 filters,
750 clones,
751 &mut coverage,
752 );
753}
754
755fn flush_secondary_clone(
756 open: Option<SecondaryOpen>,
757 prepared: &[PreparedSource],
758 min_lines: usize,
759 filters: &PathFilters,
760 clones: &mut Vec<CpdClone>,
761 coverage: &mut LineCoverage,
762) {
763 let Some(oc) = open else {
764 return;
765 };
766
767 let range_a = fragment_line_range(&oc.clone.fragment_a);
768 let range_b = fragment_line_range(&oc.clone.fragment_b);
769
770 if filters.should_skip(&prepared[oc.source_a].id, &prepared[oc.source_b].id) {
772 return;
773 }
774
775 if min_lines > 0 {
777 let lines = oc.clone.fragment_a.end.line as usize - oc.clone.fragment_a.start.line as usize;
778 if lines < min_lines {
779 return;
780 }
781 }
782
783 if !coverage.extends(oc.source_a, range_a) || !coverage.extends(oc.source_b, range_b) {
787 return;
788 }
789
790 let before = clones.len();
791 clones.push(oc.clone);
792
793 if clones.len() > before {
795 coverage.insert(oc.source_a, range_a);
796 coverage.insert(oc.source_b, range_b);
797 }
798}
799
800fn fragment_line_range(fragment: &Fragment) -> (usize, usize) {
801 let start = fragment.start.line as usize;
802 let end = fragment.end.line as usize;
803 (start.min(end), start.max(end))
804}
805
806struct LineCoverage {
811 ranges_by_source: Vec<Vec<(usize, usize)>>,
812}
813
814impl LineCoverage {
815 fn from_clones(prepared: &[PreparedSource], clones: &[CpdClone]) -> Self {
816 let mut source_lookup: FxHashMap<&str, usize> = FxHashMap::default();
817 for (idx, source) in prepared.iter().enumerate() {
818 source_lookup.insert(source.id.as_str(), idx);
819 }
820 let mut coverage = Self {
821 ranges_by_source: vec![Vec::new(); prepared.len()],
822 };
823 for clone in clones {
824 if let Some(idx) = source_lookup.get(clone.fragment_a.source_id.as_str()) {
825 coverage.insert(*idx, fragment_line_range(&clone.fragment_a));
826 }
827 if let Some(idx) = source_lookup.get(clone.fragment_b.source_id.as_str()) {
828 coverage.insert(*idx, fragment_line_range(&clone.fragment_b));
829 }
830 }
831 coverage
832 }
833
834 fn extends(&self, source_idx: usize, range: (usize, usize)) -> bool {
835 let Some(intervals) = self.ranges_by_source.get(source_idx) else {
839 return true;
840 };
841 let mut cursor = range.0;
842 for &(start, end) in intervals {
843 if end < cursor {
844 continue;
845 }
846 if start > cursor {
847 return true;
848 }
849 cursor = cursor.max(end.saturating_add(1));
850 if cursor > range.1 {
851 return false;
852 }
853 }
854 cursor <= range.1
855 }
856
857 fn insert(&mut self, source_idx: usize, range: (usize, usize)) {
858 let Some(intervals) = self.ranges_by_source.get_mut(source_idx) else {
863 return;
864 };
865 let mut folded = Vec::with_capacity(intervals.len() + 1);
866 let mut pending = Some(range);
867 for &(start, end) in intervals.iter() {
868 let p = match pending.take() {
869 None => {
870 folded.push((start, end));
871 continue;
872 }
873 Some(p) => p,
874 };
875 if p.1.saturating_add(1) < start {
877 folded.push(p);
878 folded.push((start, end));
879 }
880 else if end.saturating_add(1) < p.0 {
882 folded.push((start, end));
883 pending = Some(p);
884 }
885 else {
887 pending = Some((p.0.min(start), p.1.max(end)));
888 }
889 }
890 if let Some(p) = pending {
891 folded.push(p);
892 }
893 *intervals = folded;
894 }
895}
896
897#[cfg(test)]
902mod tests {
903 use super::*;
904 use crate::models::{Location, Token, TokenKind};
905
906 fn loc(line: u32, col: u32, offset: u32) -> Location {
907 Location {
908 line,
909 column: col,
910 offset,
911 }
912 }
913
914 fn make_token(kind: TokenKind, value: &str, line: u32, col: u32, offset: u32) -> Token {
915 let end_col = col + value.len() as u32;
916 let end_off = offset + value.len() as u32;
917 Token {
918 kind,
919 value: value.to_string(),
920 start: loc(line, col, offset),
921 end: loc(line, end_col, end_off),
922 }
923 }
924
925 fn make_file(id: &str, format: &str, tokens: Vec<Token>) -> SourceFile {
926 SourceFile {
927 bytes: 0,
928 id: id.to_string(),
929 format: format.to_string(),
930 tokens,
931 }
932 }
933
934 fn js_tokens_ab() -> Vec<Token> {
935 vec![
936 make_token(TokenKind::Keyword, "function", 1, 0, 0),
937 make_token(TokenKind::Other, "hello", 1, 9, 9),
938 make_token(TokenKind::Operator, "(", 1, 14, 14),
939 make_token(TokenKind::Operator, ")", 1, 15, 15),
940 make_token(TokenKind::Operator, "{", 1, 16, 16),
941 make_token(TokenKind::Keyword, "return", 2, 0, 18),
942 make_token(TokenKind::Literal, "42", 2, 7, 25),
943 make_token(TokenKind::Operator, ";", 2, 9, 27),
944 make_token(TokenKind::Operator, "}", 3, 0, 29),
945 ]
946 }
947
948 #[test]
949 fn empty_input_returns_empty() {
950 let result = detect(&[], 10);
951 assert!(result.is_empty());
952 }
953
954 fn pair_with_js_tokens(min_tokens: usize) -> Vec<CpdClone> {
955 let tokens = js_tokens_ab();
956 let file_a = make_file("a.js", "javascript", tokens.clone());
957 let file_b = make_file("b.js", "javascript", tokens);
958 detect(&[file_a, file_b], min_tokens)
959 }
960
961 #[test]
962 fn identical_files_detected_as_clone() {
963 assert!(
964 !pair_with_js_tokens(5).is_empty(),
965 "identical files must produce at least one clone"
966 );
967 }
968
969 #[test]
970 fn min_tokens_threshold_respected() {
971 assert!(
972 pair_with_js_tokens(100).is_empty(),
973 "no clones when min_tokens exceeds file length"
974 );
975 }
976
977 #[test]
978 fn deduplication_ab_ba_collapse() {
979 assert_eq!(
980 pair_with_js_tokens(5).len(),
981 1,
982 "symmetric pairs must collapse to 1"
983 );
984 }
985
986 #[test]
987 fn different_formats_not_cross_detected() {
988 let tokens = js_tokens_ab();
989 let file_js = make_file("a.js", "javascript", tokens.clone());
990 let file_py = make_file("a.py", "python", tokens);
991 let clones = detect(&[file_js, file_py], 5);
992 assert!(
993 clones.is_empty(),
994 "tokens from different formats must not match"
995 );
996 }
997
998 #[test]
999 fn cross_format_group_detected() {
1000 let to_prepared = |id: &str, format: &str| {
1004 let tokens = js_tokens_ab();
1005 let mut hashes = Vec::new();
1006 let mut spans = Vec::new();
1007 for t in &tokens {
1008 hashes.push(token_hash(t.kind.discriminant(), &t.value));
1009 spans.push((t.start.clone(), t.end.clone()));
1010 }
1011 PreparedSource {
1012 id: id.to_string(),
1013 format: format.to_string(),
1014 hashes,
1015 spans,
1016 }
1017 };
1018 let group = vec![
1019 to_prepared("a.js", "javascript"),
1020 to_prepared("a.ts", "typescript"),
1021 ];
1022 let clones = detect_prepared(vec![group], 5, 0, &PathFilters::default());
1023 assert_eq!(
1024 clones.len(),
1025 1,
1026 "identical token streams in one pool must match across formats"
1027 );
1028 }
1029
1030 #[test]
1031 fn identical_files_maximal_clone() {
1032 let tokens = js_tokens_ab();
1035 let file_a = make_file("a.js", "javascript", tokens.clone());
1036 let file_b = make_file("b.js", "javascript", tokens);
1037 let clones = detect(&[file_a, file_b], 5);
1038 assert_eq!(
1039 clones.len(),
1040 1,
1041 "open_clone SM must produce one maximal clone"
1042 );
1043 assert_eq!(
1044 clones[0].token_count, 9,
1045 "maximal clone must cover all 9 tokens"
1046 );
1047 }
1048
1049 #[test]
1050 fn three_identical_files_secondary_pass_adds_missing_pair() {
1051 let tokens = js_tokens_ab();
1052 let file_a = make_file("a.js", "javascript", tokens.clone());
1053 let file_b = make_file("b.js", "javascript", tokens.clone());
1054 let file_c = make_file("c.js", "javascript", tokens);
1055 let clones = detect(&[file_a, file_b, file_c], 5);
1056 assert!(
1057 clones.len() >= 2,
1058 "three identical files must yield at least 2 clone pairs, got {}",
1059 clones.len()
1060 );
1061 }
1062
1063 #[test]
1064 fn clones_sorted_by_source_and_line() {
1065 let tokens = js_tokens_ab();
1066 let file_a = make_file("a.js", "javascript", tokens.clone());
1067 let file_b = make_file("b.js", "javascript", tokens);
1068 let clones = detect(&[file_a, file_b], 5);
1069 for i in 1..clones.len() {
1070 let prev = &clones[i - 1];
1071 let curr = &clones[i];
1072 assert!(
1073 (
1074 &prev.fragment_a.source_id,
1075 prev.fragment_a.start.line,
1076 &prev.fragment_b.source_id,
1077 prev.fragment_b.start.line,
1078 ) <= (
1079 &curr.fragment_a.source_id,
1080 curr.fragment_a.start.line,
1081 &curr.fragment_b.source_id,
1082 curr.fragment_b.start.line,
1083 ),
1084 "clones must be sorted"
1085 );
1086 }
1087 }
1088
1089 fn isolated(groups: &[&[&str]]) -> Vec<Vec<PathBuf>> {
1090 groups
1091 .iter()
1092 .map(|g| g.iter().map(PathBuf::from).collect())
1093 .collect()
1094 }
1095
1096 #[test]
1097 fn skip_isolated_drops_pairs_across_group_folders() {
1098 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1099 assert!(should_skip_isolated(
1100 "/repo/packages/a/src/x.js",
1101 "/repo/packages/b/src/y.js",
1102 &groups
1103 ));
1104 }
1105
1106 #[test]
1107 fn skip_isolated_keeps_pairs_inside_one_folder() {
1108 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1109 assert!(!should_skip_isolated(
1110 "/repo/packages/a/src/x.js",
1111 "/repo/packages/a/lib/y.js",
1112 &groups
1113 ));
1114 }
1115
1116 #[test]
1117 fn skip_isolated_keeps_pairs_with_one_file_outside_group() {
1118 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1119 assert!(!should_skip_isolated(
1120 "/repo/packages/a/src/x.js",
1121 "/repo/globals/y.js",
1122 &groups
1123 ));
1124 assert!(!should_skip_isolated(
1125 "/repo/globals/x.js",
1126 "/repo/infra/y.js",
1127 &groups
1128 ));
1129 }
1130
1131 #[test]
1132 fn skip_isolated_folders_in_different_groups_do_not_isolate() {
1133 let groups = isolated(&[
1134 &["/repo/packages/a", "/repo/packages/b"],
1135 &["/repo/libs/a", "/repo/libs/b"],
1136 ]);
1137 assert!(!should_skip_isolated(
1138 "/repo/packages/a/x.js",
1139 "/repo/libs/b/y.js",
1140 &groups
1141 ));
1142 assert!(should_skip_isolated(
1143 "/repo/libs/a/x.js",
1144 "/repo/libs/b/y.js",
1145 &groups
1146 ));
1147 }
1148
1149 #[test]
1150 fn path_filters_combine_skip_local_and_skip_isolated() {
1151 let scan_roots = vec![PathBuf::from("/repo/shared")];
1152 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1153 let filters = PathFilters {
1154 skip_local: true,
1155 scan_roots: &scan_roots,
1156 isolated_groups: &groups,
1157 };
1158 assert!(filters.should_skip("/repo/shared/x.js", "/repo/shared/y.js"));
1159 assert!(filters.should_skip("/repo/packages/a/x.js", "/repo/packages/b/y.js"));
1160 assert!(!filters.should_skip("/repo/shared/x.js", "/repo/packages/a/y.js"));
1161 }
1162}