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, false, 0, &[])
73}
74
75pub fn detect_with_options(
85 files: &[SourceFile],
86 min_tokens: usize,
87 skip_local: bool,
88 min_lines: usize,
89 scan_roots: &[PathBuf],
90) -> Vec<CpdClone> {
91 if files.is_empty() || min_tokens == 0 {
92 return vec![];
93 }
94
95 let mut by_format: FxHashMap<&str, Vec<&SourceFile>> = FxHashMap::default();
97 for file in files {
98 by_format
99 .entry(file.format.as_str())
100 .or_default()
101 .push(file);
102 }
103 let mut format_groups: Vec<(&str, Vec<&SourceFile>)> = by_format.into_iter().collect();
104 format_groups.sort_unstable_by_key(|(fmt, _)| *fmt);
105 for (_, group) in &mut format_groups {
106 group.sort_unstable_by_key(|&file| file.id.as_str());
107 }
108
109 let mut clones: Vec<CpdClone> = format_groups
110 .into_par_iter()
111 .flat_map(|(_format, files)| {
112 let prepared: Vec<PreparedSource> = files
116 .into_iter()
117 .map(|file| {
118 let mut hashes = Vec::with_capacity(file.tokens.len());
119 let mut spans: Vec<(Location, Location)> =
120 Vec::with_capacity(file.tokens.len());
121 for t in &file.tokens {
122 if t.kind == TokenKind::Ignore {
123 continue;
124 }
125 hashes.push(token_hash(t.kind.discriminant(), &t.value));
126 spans.push((t.start.clone(), t.end.clone()));
127 }
128 PreparedSource {
129 id: file.id.clone(),
130 format: file.format.clone(),
131 hashes,
132 spans,
133 }
134 })
135 .collect();
136 detect_in_group(&prepared, min_tokens, skip_local, min_lines, scan_roots)
137 })
138 .collect();
139
140 finalize_clones(&mut clones);
141 clones
142}
143
144fn finalize_clones(clones: &mut Vec<CpdClone>) {
145 dedup_exact_clones(clones);
146 clones.sort_by(|a, b| {
147 (
148 &a.fragment_a.source_id,
149 a.fragment_a.start.line,
150 &a.fragment_b.source_id,
151 a.fragment_b.start.line,
152 )
153 .cmp(&(
154 &b.fragment_a.source_id,
155 b.fragment_a.start.line,
156 &b.fragment_b.source_id,
157 b.fragment_b.start.line,
158 ))
159 });
160}
161
162pub struct PreparedSource {
171 pub id: String,
172 pub format: String,
173 pub hashes: Vec<u64>,
174 pub spans: Vec<(Location, Location)>,
175}
176
177impl PreparedSource {
178 pub fn from_detection_tokens(id: String, format: String, tokens: &[DetectionToken]) -> Self {
180 let mut hashes = Vec::with_capacity(tokens.len());
181 let mut spans = Vec::with_capacity(tokens.len());
182 for t in tokens {
183 hashes.push(t.hash);
184 spans.push((t.start.clone(), t.end.clone()));
185 }
186 Self {
187 id,
188 format,
189 hashes,
190 spans,
191 }
192 }
193}
194
195pub fn detect_prepared(
201 format_groups: Vec<Vec<PreparedSource>>,
202 min_tokens: usize,
203 skip_local: bool,
204 min_lines: usize,
205 scan_roots: &[PathBuf],
206) -> Vec<CpdClone> {
207 if format_groups.is_empty() || min_tokens == 0 {
208 return vec![];
209 }
210
211 let mut clones: Vec<CpdClone> = format_groups
212 .into_par_iter()
213 .flat_map(|group| detect_in_group(&group, min_tokens, skip_local, min_lines, scan_roots))
214 .collect();
215
216 finalize_clones(&mut clones);
217 clones
218}
219
220fn detect_in_group(
225 prepared: &[PreparedSource],
226 min_tokens: usize,
227 skip_local: bool,
228 min_lines: usize,
229 scan_roots: &[PathBuf],
230) -> Vec<CpdClone> {
231 let window_power = base_pow(min_tokens.saturating_sub(1));
234
235 let total_windows: usize = prepared
237 .iter()
238 .map(|p| p.hashes.len().saturating_sub(min_tokens))
239 .sum();
240 let mut store: WindowStore =
241 FxHashMap::with_capacity_and_hasher(total_windows, Default::default());
242
243 let mut clones: Vec<CpdClone> = Vec::new();
244 const SECONDARY_OCCURRENCE_CAP: usize = 2;
248 let mut repeated_windows: FxHashMap<u64, Vec<Occurrence>> = FxHashMap::default();
249
250 for (file_idx, source) in prepared.iter().enumerate() {
251 let hashes = &source.hashes;
252 if hashes.len() < min_tokens {
253 continue;
254 }
255 let windows_len = hashes.len() - min_tokens + 1;
256
257 let mut open_clone: Option<OpenClone> = None;
262
263 let mut window_hash = hash_window(&hashes[..min_tokens]);
264
265 for token_start in 0..windows_len {
266 if token_start > 0 {
267 window_hash = roll(
268 window_hash,
269 hashes[token_start - 1],
270 hashes[token_start + min_tokens - 1],
271 window_power,
272 );
273 }
274
275 let current = Occurrence {
276 source_id: file_idx,
277 token_start,
278 };
279
280 match store.get(&window_hash).copied() {
281 Some(stored) if windows_match(stored, current, prepared, min_tokens) => {
282 if open_clone.is_none() {
283 open_clone = Some(OpenClone {
284 stored_occurrence: stored,
285 current_start: token_start,
286 match_len: min_tokens,
287 });
288 } else if let Some(ref mut oc) = open_clone {
289 oc.match_len += 1;
291 }
292 remember_repeated_window(
293 &mut repeated_windows,
294 window_hash,
295 stored,
296 SECONDARY_OCCURRENCE_CAP,
297 );
298 remember_repeated_window(
299 &mut repeated_windows,
300 window_hash,
301 current,
302 SECONDARY_OCCURRENCE_CAP,
303 );
304 }
307 _ => {
308 flush_clone(
310 open_clone.take(),
311 file_idx,
312 prepared,
313 skip_local,
314 min_lines,
315 scan_roots,
316 &mut clones,
317 );
318 store.insert(window_hash, current);
319 }
320 }
321 }
322
323 flush_clone(
325 open_clone.take(),
326 file_idx,
327 prepared,
328 skip_local,
329 min_lines,
330 scan_roots,
331 &mut clones,
332 );
333 }
334
335 add_secondary_clones(
336 repeated_windows,
337 prepared,
338 min_tokens,
339 skip_local,
340 min_lines,
341 scan_roots,
342 &mut clones,
343 );
344
345 clones
346}
347
348fn should_skip_local(file_a: &str, file_b: &str, scan_roots: &[PathBuf]) -> bool {
356 scan_roots
357 .iter()
358 .any(|root| is_relative_to(file_a, root) && is_relative_to(file_b, root))
359}
360
361fn is_relative_to(file_path: &str, dir: &PathBuf) -> bool {
365 let file = Path::new(file_path);
366 if let Ok(rel) = file.strip_prefix(dir) {
368 return !rel.as_os_str().is_empty();
369 }
370 if file.is_absolute() != dir.is_absolute() {
372 return false;
373 }
374 let mut ancestor = file;
376 loop {
377 if ancestor == dir.as_path() {
378 return false;
379 }
380 if ancestor.starts_with(dir) {
381 let rel = ancestor.strip_prefix(dir).unwrap_or(ancestor);
382 return !rel.as_os_str().is_empty();
383 }
384 ancestor = match ancestor.parent() {
385 Some(p) => p,
386 None => return false,
387 };
388 }
389}
390
391struct OpenClone {
392 stored_occurrence: Occurrence,
393 current_start: usize,
394 match_len: usize,
395}
396
397fn windows_match(
400 stored: Occurrence,
401 current: Occurrence,
402 prepared: &[PreparedSource],
403 min_tokens: usize,
404) -> bool {
405 if stored.source_id == current.source_id && stored.token_start == current.token_start {
406 return false;
407 }
408 let stored_hashes = &prepared[stored.source_id].hashes;
409 let current_hashes = &prepared[current.source_id].hashes;
410 if stored.token_start + min_tokens > stored_hashes.len()
411 || current.token_start + min_tokens > current_hashes.len()
412 {
413 return false;
414 }
415 stored_hashes[stored.token_start..stored.token_start + min_tokens]
416 == current_hashes[current.token_start..current.token_start + min_tokens]
417}
418
419fn flush_clone(
425 open: Option<OpenClone>,
426 current_file_idx: usize,
427 prepared: &[PreparedSource],
428 skip_local: bool,
429 min_lines: usize,
430 scan_roots: &[PathBuf],
431 clones: &mut Vec<CpdClone>,
432) {
433 let oc = match open {
434 Some(o) => o,
435 None => return,
436 };
437
438 let existing = &oc.stored_occurrence;
439 let cur_start = oc.current_start;
440 let match_len = oc.match_len;
441
442 let existing_file = &prepared[existing.source_id];
443 let current_file = &prepared[current_file_idx];
444
445 let ex_start = existing.token_start;
446 let ex_end = ex_start + match_len - 1;
447 let cur_end = cur_start + match_len - 1;
448
449 if skip_local && should_skip_local(&existing_file.id, ¤t_file.id, scan_roots) {
453 return;
454 }
455
456 let fragment_a = match make_fragment(&existing_file.id, &existing_file.spans, ex_start, ex_end)
457 {
458 Some(f) => f,
459 None => return,
460 };
461 let fragment_b = match make_fragment(¤t_file.id, ¤t_file.spans, cur_start, cur_end)
462 {
463 Some(f) => f,
464 None => return,
465 };
466
467 if min_lines > 0 {
471 let lines = fragment_a.end.line as usize - fragment_a.start.line as usize;
472 if lines < min_lines {
473 return;
474 }
475 }
476
477 clones.push(CpdClone {
478 format: current_file.format.clone(),
479 fragment_a,
480 fragment_b,
481 token_count: match_len as u32,
482 });
483}
484
485fn make_fragment(
486 source_id: &str,
487 spans: &[(Location, Location)],
488 start_idx: usize,
489 end_idx: usize,
490) -> Option<Fragment> {
491 let (first_start, _) = spans.get(start_idx)?;
492 let (_, last_end) = spans.get(end_idx)?;
493 Some(Fragment {
494 source_id: source_id.to_string(),
495 source_root: None,
496 start: first_start.clone(),
497 end: last_end.clone(),
498 range: [start_idx as u32, end_idx as u32],
499 blame: None,
500 })
501}
502
503fn dedup_exact_clones(clones: &mut Vec<CpdClone>) {
508 for clone in clones.iter_mut() {
510 let a_key = (&clone.fragment_a.source_id, clone.fragment_a.start.line);
511 let b_key = (&clone.fragment_b.source_id, clone.fragment_b.start.line);
512 if a_key > b_key {
513 std::mem::swap(&mut clone.fragment_a, &mut clone.fragment_b);
514 }
515 }
516
517 let mut seen: FxHashSet<CloneDedupKey> = FxHashSet::default();
518 clones.retain(|c| seen.insert(CloneDedupKey::from_clone(c)));
519}
520
521fn remember_repeated_window(
526 repeated_windows: &mut FxHashMap<u64, Vec<Occurrence>>,
527 hash: u64,
528 occurrence: Occurrence,
529 cap: usize,
530) {
531 let bucket = repeated_windows.entry(hash).or_default();
532 if bucket
533 .iter()
534 .any(|s| s.source_id == occurrence.source_id && s.token_start == occurrence.token_start)
535 {
536 return;
537 }
538 if bucket.len() < cap {
539 bucket.push(occurrence);
540 }
541}
542
543struct SecondaryOpen {
544 clone: CpdClone,
545 source_a: usize,
546 source_b: usize,
547 last_token_start_a: usize,
548 last_token_start_b: usize,
549}
550
551#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
552struct Candidate {
553 source_a: usize,
554 source_b: usize,
555 token_a: usize,
556 token_b: usize,
557}
558
559impl SecondaryOpen {
560 fn is_continuation(&self, candidate: &Candidate) -> bool {
563 self.source_a == candidate.source_a
564 && self.source_b == candidate.source_b
565 && self.last_token_start_a + 1 == candidate.token_a
566 && self.last_token_start_b + 1 == candidate.token_b
567 }
568
569 fn grow(&mut self, candidate: &Candidate, prepared: &[PreparedSource], min_tokens: usize) {
572 self.clone.token_count += 1;
573 let end_a = candidate.token_a + min_tokens;
574 let end_b = candidate.token_b + min_tokens;
575 if let Some(span) = prepared[self.source_a].spans.get(end_a) {
576 self.clone.fragment_a.end = span.1.clone();
577 self.clone.fragment_a.range[1] = end_a as u32;
578 }
579 if let Some(span) = prepared[self.source_b].spans.get(end_b) {
580 self.clone.fragment_b.end = span.1.clone();
581 self.clone.fragment_b.range[1] = end_b as u32;
582 }
583 self.last_token_start_a = candidate.token_a;
584 self.last_token_start_b = candidate.token_b;
585 }
586}
587
588fn add_secondary_clones(
589 repeated_windows: FxHashMap<u64, Vec<Occurrence>>,
590 prepared: &[PreparedSource],
591 min_tokens: usize,
592 skip_local: bool,
593 min_lines: usize,
594 scan_roots: &[PathBuf],
595 clones: &mut Vec<CpdClone>,
596) {
597 if repeated_windows.is_empty() {
598 return;
599 }
600
601 let mut candidates: Vec<Candidate> = Vec::new();
602 for occurrences in repeated_windows.values() {
603 if occurrences.len() < 2 {
604 continue;
605 }
606 for li in 0..occurrences.len() {
607 for ri in li + 1..occurrences.len() {
608 let left = &occurrences[li];
609 let right = &occurrences[ri];
610 if left.source_id == right.source_id && left.token_start == right.token_start {
611 continue;
612 }
613 let lh = &prepared[left.source_id].hashes;
614 let rh = &prepared[right.source_id].hashes;
615 let la = left.token_start;
616 let ra = right.token_start;
617 if la + min_tokens > lh.len() || ra + min_tokens > rh.len() {
618 continue;
619 }
620 if lh[la..la + min_tokens] != rh[ra..ra + min_tokens] {
621 continue;
622 }
623 let (sa, ta, sb, tb) =
624 if (left.source_id, left.token_start) <= (right.source_id, right.token_start) {
625 (
626 left.source_id,
627 left.token_start,
628 right.source_id,
629 right.token_start,
630 )
631 } else {
632 (
633 right.source_id,
634 right.token_start,
635 left.source_id,
636 left.token_start,
637 )
638 };
639 candidates.push(Candidate {
640 source_a: sa,
641 source_b: sb,
642 token_a: ta,
643 token_b: tb,
644 });
645 }
646 }
647 }
648 if candidates.is_empty() {
649 return;
650 }
651 candidates.sort_unstable();
652 candidates.dedup();
653
654 let mut coverage = LineCoverage::from_clones(prepared, clones);
656 let mut open: Option<SecondaryOpen> = None;
657
658 for candidate in candidates {
659 if let Some(current) = open.as_mut()
660 && current.is_continuation(&candidate)
661 {
662 current.grow(&candidate, prepared, min_tokens);
663 continue;
664 }
665
666 flush_secondary_clone(
667 open.take(),
668 prepared,
669 skip_local,
670 min_lines,
671 scan_roots,
672 clones,
673 &mut coverage,
674 );
675
676 let start_a = candidate.token_a;
678 let end_a = start_a + min_tokens - 1;
679 let start_b = candidate.token_b;
680 let end_b = start_b + min_tokens - 1;
681
682 let frag_a = match make_fragment(
683 &prepared[candidate.source_a].id,
684 &prepared[candidate.source_a].spans,
685 start_a,
686 end_a,
687 ) {
688 Some(f) => f,
689 None => continue,
690 };
691 let frag_b = match make_fragment(
692 &prepared[candidate.source_b].id,
693 &prepared[candidate.source_b].spans,
694 start_b,
695 end_b,
696 ) {
697 Some(f) => f,
698 None => continue,
699 };
700
701 open = Some(SecondaryOpen {
702 clone: CpdClone {
703 format: prepared[candidate.source_a].format.clone(),
704 fragment_a: frag_a,
705 fragment_b: frag_b,
706 token_count: min_tokens as u32,
707 },
708 source_a: candidate.source_a,
709 source_b: candidate.source_b,
710 last_token_start_a: candidate.token_a,
711 last_token_start_b: candidate.token_b,
712 });
713 }
714
715 flush_secondary_clone(
716 open.take(),
717 prepared,
718 skip_local,
719 min_lines,
720 scan_roots,
721 clones,
722 &mut coverage,
723 );
724}
725
726fn flush_secondary_clone(
727 open: Option<SecondaryOpen>,
728 prepared: &[PreparedSource],
729 skip_local: bool,
730 min_lines: usize,
731 scan_roots: &[PathBuf],
732 clones: &mut Vec<CpdClone>,
733 coverage: &mut LineCoverage,
734) {
735 let Some(oc) = open else {
736 return;
737 };
738
739 let range_a = fragment_line_range(&oc.clone.fragment_a);
740 let range_b = fragment_line_range(&oc.clone.fragment_b);
741
742 if skip_local
744 && should_skip_local(
745 &prepared[oc.source_a].id,
746 &prepared[oc.source_b].id,
747 scan_roots,
748 )
749 {
750 return;
751 }
752
753 if min_lines > 0 {
755 let lines = oc.clone.fragment_a.end.line as usize - oc.clone.fragment_a.start.line as usize;
756 if lines < min_lines {
757 return;
758 }
759 }
760
761 if !coverage.extends(oc.source_a, range_a) || !coverage.extends(oc.source_b, range_b) {
765 return;
766 }
767
768 let before = clones.len();
769 clones.push(oc.clone);
770
771 if clones.len() > before {
773 coverage.insert(oc.source_a, range_a);
774 coverage.insert(oc.source_b, range_b);
775 }
776}
777
778fn fragment_line_range(fragment: &Fragment) -> (usize, usize) {
779 let start = fragment.start.line as usize;
780 let end = fragment.end.line as usize;
781 (start.min(end), start.max(end))
782}
783
784struct LineCoverage {
789 ranges_by_source: Vec<Vec<(usize, usize)>>,
790}
791
792impl LineCoverage {
793 fn from_clones(prepared: &[PreparedSource], clones: &[CpdClone]) -> Self {
794 let mut source_lookup: FxHashMap<&str, usize> = FxHashMap::default();
795 for (idx, source) in prepared.iter().enumerate() {
796 source_lookup.insert(source.id.as_str(), idx);
797 }
798 let mut coverage = Self {
799 ranges_by_source: vec![Vec::new(); prepared.len()],
800 };
801 for clone in clones {
802 if let Some(idx) = source_lookup.get(clone.fragment_a.source_id.as_str()) {
803 coverage.insert(*idx, fragment_line_range(&clone.fragment_a));
804 }
805 if let Some(idx) = source_lookup.get(clone.fragment_b.source_id.as_str()) {
806 coverage.insert(*idx, fragment_line_range(&clone.fragment_b));
807 }
808 }
809 coverage
810 }
811
812 fn extends(&self, source_idx: usize, range: (usize, usize)) -> bool {
813 let Some(intervals) = self.ranges_by_source.get(source_idx) else {
817 return true;
818 };
819 let mut cursor = range.0;
820 for &(start, end) in intervals {
821 if end < cursor {
822 continue;
823 }
824 if start > cursor {
825 return true;
826 }
827 cursor = cursor.max(end.saturating_add(1));
828 if cursor > range.1 {
829 return false;
830 }
831 }
832 cursor <= range.1
833 }
834
835 fn insert(&mut self, source_idx: usize, range: (usize, usize)) {
836 let Some(intervals) = self.ranges_by_source.get_mut(source_idx) else {
841 return;
842 };
843 let mut folded = Vec::with_capacity(intervals.len() + 1);
844 let mut pending = Some(range);
845 for &(start, end) in intervals.iter() {
846 let p = match pending.take() {
847 None => {
848 folded.push((start, end));
849 continue;
850 }
851 Some(p) => p,
852 };
853 if p.1.saturating_add(1) < start {
855 folded.push(p);
856 folded.push((start, end));
857 }
858 else if end.saturating_add(1) < p.0 {
860 folded.push((start, end));
861 pending = Some(p);
862 }
863 else {
865 pending = Some((p.0.min(start), p.1.max(end)));
866 }
867 }
868 if let Some(p) = pending {
869 folded.push(p);
870 }
871 *intervals = folded;
872 }
873}
874
875#[cfg(test)]
880mod tests {
881 use super::*;
882 use crate::models::{Location, Token, TokenKind};
883
884 fn loc(line: u32, col: u32, offset: u32) -> Location {
885 Location {
886 line,
887 column: col,
888 offset,
889 }
890 }
891
892 fn make_token(kind: TokenKind, value: &str, line: u32, col: u32, offset: u32) -> Token {
893 let end_col = col + value.len() as u32;
894 let end_off = offset + value.len() as u32;
895 Token {
896 kind,
897 value: value.to_string(),
898 start: loc(line, col, offset),
899 end: loc(line, end_col, end_off),
900 }
901 }
902
903 fn make_file(id: &str, format: &str, tokens: Vec<Token>) -> SourceFile {
904 SourceFile {
905 id: id.to_string(),
906 format: format.to_string(),
907 tokens,
908 }
909 }
910
911 fn js_tokens_ab() -> Vec<Token> {
912 vec![
913 make_token(TokenKind::Keyword, "function", 1, 0, 0),
914 make_token(TokenKind::Other, "hello", 1, 9, 9),
915 make_token(TokenKind::Operator, "(", 1, 14, 14),
916 make_token(TokenKind::Operator, ")", 1, 15, 15),
917 make_token(TokenKind::Operator, "{", 1, 16, 16),
918 make_token(TokenKind::Keyword, "return", 2, 0, 18),
919 make_token(TokenKind::Literal, "42", 2, 7, 25),
920 make_token(TokenKind::Operator, ";", 2, 9, 27),
921 make_token(TokenKind::Operator, "}", 3, 0, 29),
922 ]
923 }
924
925 #[test]
926 fn empty_input_returns_empty() {
927 let result = detect(&[], 10);
928 assert!(result.is_empty());
929 }
930
931 fn pair_with_js_tokens(min_tokens: usize) -> Vec<CpdClone> {
932 let tokens = js_tokens_ab();
933 let file_a = make_file("a.js", "javascript", tokens.clone());
934 let file_b = make_file("b.js", "javascript", tokens);
935 detect(&[file_a, file_b], min_tokens)
936 }
937
938 #[test]
939 fn identical_files_detected_as_clone() {
940 assert!(
941 !pair_with_js_tokens(5).is_empty(),
942 "identical files must produce at least one clone"
943 );
944 }
945
946 #[test]
947 fn min_tokens_threshold_respected() {
948 assert!(
949 pair_with_js_tokens(100).is_empty(),
950 "no clones when min_tokens exceeds file length"
951 );
952 }
953
954 #[test]
955 fn deduplication_ab_ba_collapse() {
956 assert_eq!(
957 pair_with_js_tokens(5).len(),
958 1,
959 "symmetric pairs must collapse to 1"
960 );
961 }
962
963 #[test]
964 fn different_formats_not_cross_detected() {
965 let tokens = js_tokens_ab();
966 let file_js = make_file("a.js", "javascript", tokens.clone());
967 let file_py = make_file("a.py", "python", tokens);
968 let clones = detect(&[file_js, file_py], 5);
969 assert!(
970 clones.is_empty(),
971 "tokens from different formats must not match"
972 );
973 }
974
975 #[test]
976 fn cross_format_group_detected() {
977 let to_prepared = |id: &str, format: &str| {
981 let tokens = js_tokens_ab();
982 let mut hashes = Vec::new();
983 let mut spans = Vec::new();
984 for t in &tokens {
985 hashes.push(token_hash(t.kind.discriminant(), &t.value));
986 spans.push((t.start.clone(), t.end.clone()));
987 }
988 PreparedSource {
989 id: id.to_string(),
990 format: format.to_string(),
991 hashes,
992 spans,
993 }
994 };
995 let group = vec![
996 to_prepared("a.js", "javascript"),
997 to_prepared("a.ts", "typescript"),
998 ];
999 let clones = detect_prepared(vec![group], 5, false, 0, &[]);
1000 assert_eq!(
1001 clones.len(),
1002 1,
1003 "identical token streams in one pool must match across formats"
1004 );
1005 }
1006
1007 #[test]
1008 fn identical_files_maximal_clone() {
1009 let tokens = js_tokens_ab();
1012 let file_a = make_file("a.js", "javascript", tokens.clone());
1013 let file_b = make_file("b.js", "javascript", tokens);
1014 let clones = detect(&[file_a, file_b], 5);
1015 assert_eq!(
1016 clones.len(),
1017 1,
1018 "open_clone SM must produce one maximal clone"
1019 );
1020 assert_eq!(
1021 clones[0].token_count, 9,
1022 "maximal clone must cover all 9 tokens"
1023 );
1024 }
1025
1026 #[test]
1027 fn three_identical_files_secondary_pass_adds_missing_pair() {
1028 let tokens = js_tokens_ab();
1029 let file_a = make_file("a.js", "javascript", tokens.clone());
1030 let file_b = make_file("b.js", "javascript", tokens.clone());
1031 let file_c = make_file("c.js", "javascript", tokens);
1032 let clones = detect(&[file_a, file_b, file_c], 5);
1033 assert!(
1034 clones.len() >= 2,
1035 "three identical files must yield at least 2 clone pairs, got {}",
1036 clones.len()
1037 );
1038 }
1039
1040 #[test]
1041 fn clones_sorted_by_source_and_line() {
1042 let tokens = js_tokens_ab();
1043 let file_a = make_file("a.js", "javascript", tokens.clone());
1044 let file_b = make_file("b.js", "javascript", tokens);
1045 let clones = detect(&[file_a, file_b], 5);
1046 for i in 1..clones.len() {
1047 let prev = &clones[i - 1];
1048 let curr = &clones[i];
1049 assert!(
1050 (
1051 &prev.fragment_a.source_id,
1052 prev.fragment_a.start.line,
1053 &prev.fragment_b.source_id,
1054 prev.fragment_b.start.line,
1055 ) <= (
1056 &curr.fragment_a.source_id,
1057 curr.fragment_a.start.line,
1058 &curr.fragment_b.source_id,
1059 curr.fragment_b.start.line,
1060 ),
1061 "clones must be sorted"
1062 );
1063 }
1064 }
1065}