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| a.position_key().cmp(&b.position_key()));
150}
151
152#[derive(Debug, Clone)]
161pub struct PreparedSource {
162 pub id: String,
163 pub format: String,
164 pub hashes: Vec<u64>,
165 pub spans: Vec<(Location, Location)>,
166 pub raw_hashes: Vec<u64>,
170 pub functions: Vec<crate::similarity::FunctionSig>,
173 pub real_path: String,
179 pub embedded: bool,
185}
186
187impl PreparedSource {
188 pub fn filter_path(&self) -> &str {
190 if self.real_path.is_empty() {
191 &self.id
192 } else {
193 &self.real_path
194 }
195 }
196
197 pub fn from_detection_tokens(id: String, format: String, tokens: &[DetectionToken]) -> Self {
199 let mut hashes = Vec::with_capacity(tokens.len());
200 let mut spans = Vec::with_capacity(tokens.len());
201 let mut raw_hashes: Vec<u64> = Vec::new();
204 for (i, t) in tokens.iter().enumerate() {
205 hashes.push(t.hash);
206 spans.push((t.start.clone(), t.end.clone()));
207 if raw_hashes.is_empty() && t.raw_hash != t.hash {
208 raw_hashes.reserve(tokens.len());
209 raw_hashes.extend(hashes[..i].iter().copied());
210 }
211 if !raw_hashes.is_empty() {
212 raw_hashes.push(t.raw_hash);
213 }
214 }
215 Self {
216 id,
217 format,
218 hashes,
219 spans,
220 raw_hashes,
221 functions: Vec::new(),
222 real_path: String::new(),
223 embedded: false,
224 }
225 }
226}
227
228pub fn detect_prepared(
233 format_groups: Vec<Vec<PreparedSource>>,
234 min_tokens: usize,
235 min_lines: usize,
236 filters: &PathFilters,
237) -> Vec<CpdClone> {
238 if format_groups.is_empty() || min_tokens == 0 {
239 return vec![];
240 }
241
242 let mut clones: Vec<CpdClone> = format_groups
243 .into_par_iter()
244 .flat_map(|group| detect_in_group(&group, min_tokens, min_lines, filters))
245 .collect();
246
247 finalize_clones(&mut clones);
248 clones
249}
250
251fn detect_in_group(
256 prepared: &[PreparedSource],
257 min_tokens: usize,
258 min_lines: usize,
259 filters: &PathFilters,
260) -> Vec<CpdClone> {
261 let window_power = base_pow(min_tokens.saturating_sub(1));
264
265 let total_windows: usize = prepared
267 .iter()
268 .map(|p| p.hashes.len().saturating_sub(min_tokens))
269 .sum();
270 let mut store: WindowStore =
271 FxHashMap::with_capacity_and_hasher(total_windows, Default::default());
272
273 let mut clones: Vec<CpdClone> = Vec::new();
274 const SECONDARY_OCCURRENCE_CAP: usize = 2;
278 let mut repeated_windows: FxHashMap<u64, Vec<Occurrence>> = FxHashMap::default();
279
280 for (file_idx, source) in prepared.iter().enumerate() {
281 let hashes = &source.hashes;
282 if hashes.len() < min_tokens {
283 continue;
284 }
285 let windows_len = hashes.len() - min_tokens + 1;
286
287 let mut open_clone: Option<OpenClone> = None;
292
293 let mut window_hash = hash_window(&hashes[..min_tokens]);
294
295 for token_start in 0..windows_len {
296 if token_start > 0 {
297 window_hash = roll(
298 window_hash,
299 hashes[token_start - 1],
300 hashes[token_start + min_tokens - 1],
301 window_power,
302 );
303 }
304
305 let current = Occurrence {
306 source_id: file_idx,
307 token_start,
308 };
309
310 let stored = store
311 .get(&window_hash)
312 .copied()
313 .filter(|stored| windows_match(*stored, current, prepared, min_tokens));
314
315 let anchor_continues = open_clone.as_ref().is_some_and(|oc| {
323 let anchor = Occurrence {
324 source_id: oc.stored_occurrence.source_id,
325 token_start: oc.stored_occurrence.token_start
326 + (token_start - oc.current_start),
327 };
328 windows_match(anchor, current, prepared, min_tokens)
329 });
330
331 if anchor_continues {
332 if let Some(oc) = open_clone.as_mut() {
333 oc.match_len += 1;
334 }
335 } else {
336 flush_clone(
339 open_clone.take(),
340 file_idx,
341 prepared,
342 min_lines,
343 filters,
344 &mut clones,
345 );
346 match stored {
347 Some(stored) => {
348 open_clone = Some(OpenClone {
349 stored_occurrence: stored,
350 current_start: token_start,
351 match_len: min_tokens,
352 });
353 }
354 None => {
355 store.insert(window_hash, current);
356 }
357 }
358 }
359 if let Some(stored) = stored {
360 remember_repeated_window(
361 &mut repeated_windows,
362 window_hash,
363 stored,
364 SECONDARY_OCCURRENCE_CAP,
365 );
366 remember_repeated_window(
367 &mut repeated_windows,
368 window_hash,
369 current,
370 SECONDARY_OCCURRENCE_CAP,
371 );
372 }
375 }
376
377 flush_clone(
379 open_clone.take(),
380 file_idx,
381 prepared,
382 min_lines,
383 filters,
384 &mut clones,
385 );
386 }
387
388 add_secondary_clones(
389 repeated_windows,
390 prepared,
391 min_tokens,
392 min_lines,
393 filters,
394 &mut clones,
395 );
396
397 clones
398}
399
400#[derive(Debug, Default, Clone, Copy)]
409pub struct PathFilters<'a> {
410 pub skip_local: bool,
413 pub scan_roots: &'a [PathBuf],
415 pub isolated_groups: &'a [Vec<PathBuf>],
419}
420
421impl PathFilters<'_> {
422 pub fn should_skip(&self, file_a: &str, file_b: &str) -> bool {
424 (self.skip_local && should_skip_local(file_a, file_b, self.scan_roots))
425 || should_skip_isolated(file_a, file_b, self.isolated_groups)
426 }
427
428 fn should_skip_pair(&self, a: &PreparedSource, b: &PreparedSource) -> bool {
435 self.should_skip(&a.id, &b.id)
436 || ((!a.real_path.is_empty() || !b.real_path.is_empty())
437 && should_skip_isolated(a.filter_path(), b.filter_path(), self.isolated_groups))
438 }
439}
440
441#[derive(Debug, Clone, Default, PartialEq, Eq)]
445pub struct PathLabel {
446 roots: Vec<u16>,
448 folders: Vec<(u16, u16)>,
451}
452
453impl PathLabel {
454 pub fn skips(&self, other: &PathLabel) -> bool {
457 self.roots.iter().any(|r| other.roots.contains(r))
458 || self
459 .folders
460 .iter()
461 .any(|(g, f)| other.folders.iter().any(|(g2, f2)| g == g2 && f != f2))
462 }
463}
464
465impl PathFilters<'_> {
466 pub fn is_active(&self) -> bool {
468 self.skip_local || !self.isolated_groups.is_empty()
469 }
470
471 pub fn label(&self, file: &str) -> PathLabel {
473 let roots = match self.skip_local {
474 true => self
475 .scan_roots
476 .iter()
477 .enumerate()
478 .filter(|(_, root)| is_relative_to(file, root))
479 .map(|(k, _)| k as u16)
480 .collect(),
481 false => Vec::new(),
482 };
483 let folders = self
484 .isolated_groups
485 .iter()
486 .enumerate()
487 .filter_map(|(g, group)| {
488 let f = group.iter().position(|dir| is_relative_to(file, dir))?;
489 Some((g as u16, f as u16))
490 })
491 .collect();
492 PathLabel { roots, folders }
493 }
494}
495
496fn should_skip_local(file_a: &str, file_b: &str, scan_roots: &[PathBuf]) -> bool {
500 scan_roots
501 .iter()
502 .any(|root| is_relative_to(file_a, root) && is_relative_to(file_b, root))
503}
504
505fn should_skip_isolated(file_a: &str, file_b: &str, isolated_groups: &[Vec<PathBuf>]) -> bool {
510 isolated_groups.iter().any(|group| {
511 let Some(dir_a) = group.iter().find(|dir| is_relative_to(file_a, dir)) else {
512 return false;
513 };
514 group
515 .iter()
516 .find(|dir| is_relative_to(file_b, dir))
517 .is_some_and(|dir_b| dir_a != dir_b)
518 })
519}
520
521fn is_relative_to(file_path: &str, dir: &PathBuf) -> bool {
525 let file = Path::new(file_path);
526 if let Ok(rel) = file.strip_prefix(dir) {
528 return !rel.as_os_str().is_empty();
529 }
530 if file.is_absolute() != dir.is_absolute() {
532 return false;
533 }
534 let mut ancestor = file;
536 loop {
537 if ancestor == dir.as_path() {
538 return false;
539 }
540 if ancestor.starts_with(dir) {
541 let rel = ancestor.strip_prefix(dir).unwrap_or(ancestor);
542 return !rel.as_os_str().is_empty();
543 }
544 ancestor = match ancestor.parent() {
545 Some(p) => p,
546 None => return false,
547 };
548 }
549}
550
551struct OpenClone {
552 stored_occurrence: Occurrence,
553 current_start: usize,
554 match_len: usize,
555}
556
557fn windows_match(
560 stored: Occurrence,
561 current: Occurrence,
562 prepared: &[PreparedSource],
563 min_tokens: usize,
564) -> bool {
565 if stored.source_id == current.source_id && stored.token_start == current.token_start {
566 return false;
567 }
568 let stored_hashes = &prepared[stored.source_id].hashes;
569 let current_hashes = &prepared[current.source_id].hashes;
570 if stored.token_start + min_tokens > stored_hashes.len()
571 || current.token_start + min_tokens > current_hashes.len()
572 {
573 return false;
574 }
575 stored_hashes[stored.token_start..stored.token_start + min_tokens]
576 == current_hashes[current.token_start..current.token_start + min_tokens]
577}
578
579fn flush_clone(
585 open: Option<OpenClone>,
586 current_file_idx: usize,
587 prepared: &[PreparedSource],
588 min_lines: usize,
589 filters: &PathFilters,
590 clones: &mut Vec<CpdClone>,
591) {
592 let oc = match open {
593 Some(o) => o,
594 None => return,
595 };
596
597 let existing = &oc.stored_occurrence;
598 let cur_start = oc.current_start;
599 let match_len = oc.match_len;
600
601 let existing_file = &prepared[existing.source_id];
602 let current_file = &prepared[current_file_idx];
603
604 let ex_start = existing.token_start;
605 let ex_end = ex_start + match_len - 1;
606 let cur_end = cur_start + match_len - 1;
607
608 if filters.should_skip_pair(existing_file, current_file) {
611 return;
612 }
613
614 let fragment_a = match make_fragment(&existing_file.id, &existing_file.spans, ex_start, ex_end)
615 {
616 Some(f) => f,
617 None => return,
618 };
619 let fragment_b = match make_fragment(¤t_file.id, ¤t_file.spans, cur_start, cur_end)
620 {
621 Some(f) => f,
622 None => return,
623 };
624 let kind = clone_kind(
625 existing_file,
626 fragment_a.range,
627 current_file,
628 fragment_b.range,
629 );
630
631 if min_lines > 0 {
635 let lines = fragment_a.end.line as usize - fragment_a.start.line as usize;
636 if lines < min_lines {
637 return;
638 }
639 }
640
641 let unmatched_lines = [
642 host_lines_in(existing_file, &fragment_a),
643 host_lines_in(current_file, &fragment_b),
644 ];
645
646 clones.push(CpdClone {
647 format: current_file.format.clone(),
648 fragment_a,
649 fragment_b,
650 token_count: match_len as u32,
651 is_new: false,
652 kind,
653 similarity: None,
654 similarity_method: None,
655 unmatched_lines,
656 });
657}
658
659fn host_lines_in(source: &PreparedSource, fragment: &Fragment) -> u32 {
667 if !source.embedded {
668 return 0;
669 }
670 let (first, last) = (fragment.range[0] as usize, fragment.range[1] as usize);
671 let Some(spans) = source.spans.get(first..=last) else {
672 return 0;
673 };
674 let span = fragment.end.line.saturating_sub(fragment.start.line) + 1;
675 let covered = covered_lines(spans.iter().map(|(s, e)| (s.line, e.line)));
676 span.saturating_sub(covered)
677}
678
679fn clone_kind(
688 a: &PreparedSource,
689 a_range: [u32; 2],
690 b: &PreparedSource,
691 b_range: [u32; 2],
692) -> CloneKind {
693 if a.raw_hashes.is_empty() && b.raw_hashes.is_empty() {
694 return CloneKind::Exact;
695 }
696 let (na, ra) = range_slices(a, a_range);
697 let (nb, rb) = range_slices(b, b_range);
698 let matched = na.iter().zip(nb).take_while(|(x, y)| x == y).count();
699 if ra[..matched.min(ra.len())] == rb[..matched.min(rb.len())] {
700 CloneKind::Exact
701 } else {
702 CloneKind::Renamed
703 }
704}
705
706fn range_slices(p: &PreparedSource, range: [u32; 2]) -> (&[u64], &[u64]) {
708 let start = (range[0] as usize).min(p.hashes.len());
709 let end = (range[1] as usize + 1).min(p.hashes.len());
710 let raw = if p.raw_hashes.is_empty() {
711 &p.hashes
712 } else {
713 &p.raw_hashes
714 };
715 (&p.hashes[start..end], &raw[start..end])
716}
717
718fn make_fragment(
719 source_id: &str,
720 spans: &[(Location, Location)],
721 start_idx: usize,
722 end_idx: usize,
723) -> Option<Fragment> {
724 let (first_start, _) = spans.get(start_idx)?;
725 let (_, last_end) = spans.get(end_idx)?;
726 Some(Fragment {
727 source_id: source_id.to_string(),
728 source_root: None,
729 start: first_start.clone(),
730 end: last_end.clone(),
731 range: [start_idx as u32, end_idx as u32],
732 blame: None,
733 })
734}
735
736pub const MIN_GAP_SIMILARITY: f32 = 0.5;
744
745pub fn merge_gapped_clones(mut clones: Vec<CpdClone>, max_gap_lines: usize) -> Vec<CpdClone> {
758 if max_gap_lines == 0 || clones.len() < 2 {
759 return clones;
760 }
761 clones.sort_by(|x, y| {
762 pair_key(x)
763 .cmp(&pair_key(y))
764 .then(x.fragment_a.range[0].cmp(&y.fragment_a.range[0]))
765 .then(x.fragment_b.range[0].cmp(&y.fragment_b.range[0]))
766 });
767 let mut merged: Vec<CpdClone> = Vec::with_capacity(clones.len());
768 for clone in clones {
769 let extended = match merged.last_mut() {
770 Some(last) if pair_key(last) == pair_key(&clone) => {
771 merge_into(last, &clone, max_gap_lines)
772 }
773 _ => false,
774 };
775 if !extended {
776 merged.push(clone);
777 }
778 }
779 merged
780}
781
782fn merge_into(last: &mut CpdClone, next: &CpdClone, max_gap_lines: usize) -> bool {
787 let Some(step_a) = continuation(&last.fragment_a, &next.fragment_a, max_gap_lines) else {
788 return false;
789 };
790 let Some(step_b) = continuation(&last.fragment_b, &next.fragment_b, max_gap_lines) else {
791 return false;
792 };
793 let matched = last.token_count
796 + next
797 .token_count
798 .saturating_sub(step_a.overlap.max(step_b.overlap));
799 let span_a = next.fragment_a.range[1] - last.fragment_a.range[0] + 1;
800 let span_b = next.fragment_b.range[1] - last.fragment_b.range[0] + 1;
801 let similarity = matched as f32 / span_a.max(span_b) as f32;
802 if similarity < MIN_GAP_SIMILARITY {
803 return false;
804 }
805 last.fragment_a.end = next.fragment_a.end.clone();
806 last.fragment_a.range[1] = next.fragment_a.range[1];
807 last.fragment_b.end = next.fragment_b.end.clone();
808 last.fragment_b.range[1] = next.fragment_b.range[1];
809 last.token_count = matched;
810 last.similarity = Some(similarity);
811 last.similarity_method = Some(SimilarityMethod::Gap);
812 last.kind = CloneKind::Similar;
813 last.unmatched_lines[0] += next.unmatched_lines[0] + step_a.gap_lines;
816 last.unmatched_lines[1] += next.unmatched_lines[1] + step_b.gap_lines;
817 true
818}
819
820fn pair_key(c: &CpdClone) -> (&str, &str, &str) {
821 (
822 c.format.as_str(),
823 c.fragment_a.source_id.as_str(),
824 c.fragment_b.source_id.as_str(),
825 )
826}
827
828#[derive(Debug, Clone, Copy, PartialEq, Eq)]
831struct Continuation {
832 overlap: u32,
833 gap_lines: u32,
834}
835
836fn continuation(prev: &Fragment, next: &Fragment, max_gap_lines: usize) -> Option<Continuation> {
839 if next.range[0] <= prev.range[0] || next.range[1] <= prev.range[1] {
840 return None;
841 }
842 let gap_lines = next.start.line.saturating_sub(prev.end.line + 1);
843 if gap_lines as usize > max_gap_lines {
844 return None;
845 }
846 Some(Continuation {
847 overlap: (prev.range[1] + 1).saturating_sub(next.range[0]),
848 gap_lines,
849 })
850}
851
852fn dedup_exact_clones(clones: &mut Vec<CpdClone>) {
853 for clone in clones.iter_mut() {
855 let a_key = (&clone.fragment_a.source_id, clone.fragment_a.start.line);
856 let b_key = (&clone.fragment_b.source_id, clone.fragment_b.start.line);
857 if a_key > b_key {
858 std::mem::swap(&mut clone.fragment_a, &mut clone.fragment_b);
859 }
860 }
861
862 let mut seen: FxHashSet<CloneDedupKey> = FxHashSet::default();
863 clones.retain(|c| seen.insert(CloneDedupKey::from_clone(c)));
864}
865
866fn remember_repeated_window(
871 repeated_windows: &mut FxHashMap<u64, Vec<Occurrence>>,
872 hash: u64,
873 occurrence: Occurrence,
874 cap: usize,
875) {
876 let bucket = repeated_windows.entry(hash).or_default();
877 if bucket
878 .iter()
879 .any(|s| s.source_id == occurrence.source_id && s.token_start == occurrence.token_start)
880 {
881 return;
882 }
883 if bucket.len() < cap {
884 bucket.push(occurrence);
885 }
886}
887
888struct SecondaryOpen {
889 clone: CpdClone,
890 source_a: usize,
891 source_b: usize,
892 last_token_start_a: usize,
893 last_token_start_b: usize,
894}
895
896#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
897struct Candidate {
898 source_a: usize,
899 source_b: usize,
900 token_a: usize,
901 token_b: usize,
902}
903
904impl SecondaryOpen {
905 fn is_continuation(&self, candidate: &Candidate) -> bool {
908 self.source_a == candidate.source_a
909 && self.source_b == candidate.source_b
910 && self.last_token_start_a + 1 == candidate.token_a
911 && self.last_token_start_b + 1 == candidate.token_b
912 }
913
914 fn grow(&mut self, candidate: &Candidate, prepared: &[PreparedSource], min_tokens: usize) {
917 self.clone.token_count += 1;
918 let end_a = candidate.token_a + min_tokens;
919 let end_b = candidate.token_b + min_tokens;
920 if let Some(span) = prepared[self.source_a].spans.get(end_a) {
921 self.clone.fragment_a.end = span.1.clone();
922 self.clone.fragment_a.range[1] = end_a as u32;
923 }
924 if let Some(span) = prepared[self.source_b].spans.get(end_b) {
925 self.clone.fragment_b.end = span.1.clone();
926 self.clone.fragment_b.range[1] = end_b as u32;
927 }
928 self.last_token_start_a = candidate.token_a;
929 self.last_token_start_b = candidate.token_b;
930 }
931}
932
933fn add_secondary_clones(
934 repeated_windows: FxHashMap<u64, Vec<Occurrence>>,
935 prepared: &[PreparedSource],
936 min_tokens: usize,
937 min_lines: usize,
938 filters: &PathFilters,
939 clones: &mut Vec<CpdClone>,
940) {
941 if repeated_windows.is_empty() {
942 return;
943 }
944
945 let mut candidates: Vec<Candidate> = Vec::new();
946 for occurrences in repeated_windows.values() {
947 if occurrences.len() < 2 {
948 continue;
949 }
950 for li in 0..occurrences.len() {
951 for ri in li + 1..occurrences.len() {
952 let left = &occurrences[li];
953 let right = &occurrences[ri];
954 if left.source_id == right.source_id && left.token_start == right.token_start {
955 continue;
956 }
957 let lh = &prepared[left.source_id].hashes;
958 let rh = &prepared[right.source_id].hashes;
959 let la = left.token_start;
960 let ra = right.token_start;
961 if la + min_tokens > lh.len() || ra + min_tokens > rh.len() {
962 continue;
963 }
964 if lh[la..la + min_tokens] != rh[ra..ra + min_tokens] {
965 continue;
966 }
967 let (sa, ta, sb, tb) =
968 if (left.source_id, left.token_start) <= (right.source_id, right.token_start) {
969 (
970 left.source_id,
971 left.token_start,
972 right.source_id,
973 right.token_start,
974 )
975 } else {
976 (
977 right.source_id,
978 right.token_start,
979 left.source_id,
980 left.token_start,
981 )
982 };
983 candidates.push(Candidate {
984 source_a: sa,
985 source_b: sb,
986 token_a: ta,
987 token_b: tb,
988 });
989 }
990 }
991 }
992 if candidates.is_empty() {
993 return;
994 }
995 candidates.sort_unstable();
996 candidates.dedup();
997
998 let mut coverage = LineCoverage::from_clones(prepared, clones);
1000 let mut open: Option<SecondaryOpen> = None;
1001
1002 for candidate in candidates {
1003 if let Some(current) = open.as_mut()
1004 && current.is_continuation(&candidate)
1005 {
1006 current.grow(&candidate, prepared, min_tokens);
1007 continue;
1008 }
1009
1010 flush_secondary_clone(
1011 open.take(),
1012 prepared,
1013 min_lines,
1014 filters,
1015 clones,
1016 &mut coverage,
1017 );
1018
1019 let start_a = candidate.token_a;
1021 let end_a = start_a + min_tokens - 1;
1022 let start_b = candidate.token_b;
1023 let end_b = start_b + min_tokens - 1;
1024
1025 let frag_a = match make_fragment(
1026 &prepared[candidate.source_a].id,
1027 &prepared[candidate.source_a].spans,
1028 start_a,
1029 end_a,
1030 ) {
1031 Some(f) => f,
1032 None => continue,
1033 };
1034 let frag_b = match make_fragment(
1035 &prepared[candidate.source_b].id,
1036 &prepared[candidate.source_b].spans,
1037 start_b,
1038 end_b,
1039 ) {
1040 Some(f) => f,
1041 None => continue,
1042 };
1043
1044 open = Some(SecondaryOpen {
1045 clone: CpdClone {
1046 format: prepared[candidate.source_a].format.clone(),
1047 fragment_a: frag_a,
1048 fragment_b: frag_b,
1049 token_count: min_tokens as u32,
1050 is_new: false,
1051 kind: Default::default(),
1052 similarity: None,
1053 similarity_method: None,
1054 unmatched_lines: [0, 0],
1055 },
1056 source_a: candidate.source_a,
1057 source_b: candidate.source_b,
1058 last_token_start_a: candidate.token_a,
1059 last_token_start_b: candidate.token_b,
1060 });
1061 }
1062
1063 flush_secondary_clone(
1064 open.take(),
1065 prepared,
1066 min_lines,
1067 filters,
1068 clones,
1069 &mut coverage,
1070 );
1071}
1072
1073fn flush_secondary_clone(
1074 open: Option<SecondaryOpen>,
1075 prepared: &[PreparedSource],
1076 min_lines: usize,
1077 filters: &PathFilters,
1078 clones: &mut Vec<CpdClone>,
1079 coverage: &mut LineCoverage,
1080) {
1081 let Some(oc) = open else {
1082 return;
1083 };
1084
1085 let range_a = fragment_line_range(&oc.clone.fragment_a);
1086 let range_b = fragment_line_range(&oc.clone.fragment_b);
1087
1088 if filters.should_skip_pair(&prepared[oc.source_a], &prepared[oc.source_b]) {
1090 return;
1091 }
1092
1093 if min_lines > 0 {
1095 let lines = oc.clone.fragment_a.end.line as usize - oc.clone.fragment_a.start.line as usize;
1096 if lines < min_lines {
1097 return;
1098 }
1099 }
1100
1101 if !coverage.extends(oc.source_a, range_a) || !coverage.extends(oc.source_b, range_b) {
1105 return;
1106 }
1107
1108 let before = clones.len();
1109 let mut clone = oc.clone;
1110 clone.kind = clone_kind(
1111 &prepared[oc.source_a],
1112 clone.fragment_a.range,
1113 &prepared[oc.source_b],
1114 clone.fragment_b.range,
1115 );
1116 clone.unmatched_lines = [
1119 host_lines_in(&prepared[oc.source_a], &clone.fragment_a),
1120 host_lines_in(&prepared[oc.source_b], &clone.fragment_b),
1121 ];
1122 clones.push(clone);
1123
1124 if clones.len() > before {
1126 coverage.insert(oc.source_a, range_a);
1127 coverage.insert(oc.source_b, range_b);
1128 }
1129}
1130
1131fn fragment_line_range(fragment: &Fragment) -> (usize, usize) {
1132 let start = fragment.start.line as usize;
1133 let end = fragment.end.line as usize;
1134 (start.min(end), start.max(end))
1135}
1136
1137struct LineCoverage {
1142 ranges_by_source: Vec<Vec<(usize, usize)>>,
1143}
1144
1145impl LineCoverage {
1146 fn from_clones(prepared: &[PreparedSource], clones: &[CpdClone]) -> Self {
1147 let mut source_lookup: FxHashMap<&str, usize> = FxHashMap::default();
1148 for (idx, source) in prepared.iter().enumerate() {
1149 source_lookup.insert(source.id.as_str(), idx);
1150 }
1151 let mut coverage = Self {
1152 ranges_by_source: vec![Vec::new(); prepared.len()],
1153 };
1154 for clone in clones {
1155 if let Some(idx) = source_lookup.get(clone.fragment_a.source_id.as_str()) {
1156 coverage.insert(*idx, fragment_line_range(&clone.fragment_a));
1157 }
1158 if let Some(idx) = source_lookup.get(clone.fragment_b.source_id.as_str()) {
1159 coverage.insert(*idx, fragment_line_range(&clone.fragment_b));
1160 }
1161 }
1162 coverage
1163 }
1164
1165 fn extends(&self, source_idx: usize, range: (usize, usize)) -> bool {
1166 let Some(intervals) = self.ranges_by_source.get(source_idx) else {
1170 return true;
1171 };
1172 let mut cursor = range.0;
1173 for &(start, end) in intervals {
1174 if end < cursor {
1175 continue;
1176 }
1177 if start > cursor {
1178 return true;
1179 }
1180 cursor = cursor.max(end.saturating_add(1));
1181 if cursor > range.1 {
1182 return false;
1183 }
1184 }
1185 cursor <= range.1
1186 }
1187
1188 fn insert(&mut self, source_idx: usize, range: (usize, usize)) {
1189 let Some(intervals) = self.ranges_by_source.get_mut(source_idx) else {
1194 return;
1195 };
1196 let mut folded = Vec::with_capacity(intervals.len() + 1);
1197 let mut pending = Some(range);
1198 for &(start, end) in intervals.iter() {
1199 let p = match pending.take() {
1200 None => {
1201 folded.push((start, end));
1202 continue;
1203 }
1204 Some(p) => p,
1205 };
1206 if p.1.saturating_add(1) < start {
1208 folded.push(p);
1209 folded.push((start, end));
1210 }
1211 else if end.saturating_add(1) < p.0 {
1213 folded.push((start, end));
1214 pending = Some(p);
1215 }
1216 else {
1218 pending = Some((p.0.min(start), p.1.max(end)));
1219 }
1220 }
1221 if let Some(p) = pending {
1222 folded.push(p);
1223 }
1224 *intervals = folded;
1225 }
1226}
1227
1228#[cfg(test)]
1233mod tests {
1234 use super::*;
1235
1236 fn tok(hash: u64, raw_hash: u64, line: u32) -> DetectionToken {
1237 let loc = Location {
1238 line,
1239 column: 0,
1240 offset: line,
1241 };
1242 DetectionToken {
1243 hash,
1244 raw_hash,
1245 start: loc.clone(),
1246 end: loc,
1247 range: [line as usize, line as usize + 1],
1248 }
1249 }
1250
1251 fn gap_clone(
1254 a: &str,
1255 a_tok: [u32; 2],
1256 a_lines: [u32; 2],
1257 b: &str,
1258 b_tok: [u32; 2],
1259 b_lines: [u32; 2],
1260 ) -> CpdClone {
1261 let frag = |id: &str, tok: [u32; 2], lines: [u32; 2]| Fragment {
1262 source_id: id.to_string(),
1263 source_root: None,
1264 start: Location {
1265 line: lines[0],
1266 column: 1,
1267 offset: tok[0],
1268 },
1269 end: Location {
1270 line: lines[1],
1271 column: 1,
1272 offset: tok[1],
1273 },
1274 range: tok,
1275 blame: None,
1276 };
1277 CpdClone {
1278 format: "javascript".to_string(),
1279 fragment_a: frag(a, a_tok, a_lines),
1280 fragment_b: frag(b, b_tok, b_lines),
1281 token_count: a_tok[1] - a_tok[0] + 1,
1282 is_new: false,
1283 kind: CloneKind::Exact,
1284 similarity: None,
1285 similarity_method: None,
1286 unmatched_lines: [0, 0],
1287 }
1288 }
1289
1290 #[test]
1291 fn merge_gapped_is_a_no_op_at_zero() {
1292 let clones = vec![
1293 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1294 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1295 ];
1296 let out = merge_gapped_clones(clones.clone(), 0);
1297 assert_eq!(out, clones);
1298 }
1299
1300 #[test]
1301 fn merge_gapped_joins_adjacent_fragments_within_gap() {
1302 let clones = vec![
1304 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1305 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1306 ];
1307 let out = merge_gapped_clones(clones, 1);
1308 assert_eq!(out.len(), 1);
1309 let c = &out[0];
1310 assert_eq!(c.kind, CloneKind::Similar);
1311 assert_eq!(c.token_count, 20, "matched tokens");
1312 assert_eq!(c.fragment_a.range, [0, 19]);
1313 assert_eq!(c.fragment_b.range, [0, 21]);
1314 assert_eq!(c.fragment_a.end.line, 8);
1315 assert_eq!(c.fragment_b.end.line, 9);
1316 assert!((c.similarity.unwrap() - 20.0 / 22.0).abs() < 1e-6);
1318 }
1319
1320 #[test]
1321 fn merge_gapped_respects_the_line_limit_and_file_pair() {
1322 let far = vec![
1323 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1324 gap_clone("a", [10, 19], [5, 8], "b", [20, 29], [8, 11]), ];
1326 assert_eq!(merge_gapped_clones(far.clone(), 2).len(), 2);
1327 assert_eq!(merge_gapped_clones(far, 3).len(), 1);
1328
1329 let other_pair = vec![
1330 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1331 gap_clone("a", [10, 19], [5, 8], "c", [10, 19], [5, 8]),
1332 ];
1333 assert_eq!(merge_gapped_clones(other_pair, 5).len(), 2);
1334 }
1335
1336 #[test]
1337 fn merge_gapped_counts_a_shared_boundary_token_once_and_chains() {
1338 let clones = vec![
1340 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1341 gap_clone("a", [9, 18], [4, 8], "b", [11, 20], [6, 9]),
1342 gap_clone("a", [19, 28], [9, 12], "b", [22, 31], [10, 13]),
1343 ];
1344 let out = merge_gapped_clones(clones, 1);
1345 assert_eq!(out.len(), 1);
1346 assert_eq!(out[0].token_count, 29, "10 + (10 - 1 overlap) + 10");
1347 assert_eq!(out[0].fragment_a.range, [0, 28]);
1348 assert_eq!(out[0].fragment_b.range, [0, 31]);
1349 }
1350
1351 #[test]
1352 fn merge_gapped_never_merges_overlapping_or_reordered_fragments() {
1353 let nested = vec![
1354 gap_clone("a", [0, 19], [1, 8], "b", [0, 19], [1, 8]),
1355 gap_clone("a", [5, 9], [3, 4], "b", [5, 9], [3, 4]),
1356 ];
1357 assert_eq!(merge_gapped_clones(nested, 5).len(), 2);
1358 let crossed = vec![
1359 gap_clone("a", [0, 9], [1, 4], "b", [20, 29], [10, 13]),
1360 gap_clone("a", [10, 19], [5, 8], "b", [0, 9], [1, 4]),
1361 ];
1362 assert_eq!(merge_gapped_clones(crossed, 5).len(), 2);
1363 }
1364
1365 #[test]
1366 fn merge_gapped_refuses_a_gap_wider_than_the_match() {
1367 let wide = vec![
1370 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1371 gap_clone("a", [10, 19], [5, 8], "b", [40, 49], [6, 9]),
1372 ];
1373 let out = merge_gapped_clones(wide, 1);
1374 assert_eq!(out.len(), 2);
1375 assert!(out.iter().all(|c| c.kind == CloneKind::Exact));
1376 assert!(out.iter().all(|c| c.similarity.is_none()));
1377 let at_floor = vec![
1379 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1380 gap_clone("a", [10, 19], [5, 8], "b", [30, 39], [6, 9]),
1381 ];
1382 let out = merge_gapped_clones(at_floor, 1);
1383 assert_eq!(out.len(), 1);
1384 assert!((out[0].similarity.unwrap() - MIN_GAP_SIMILARITY).abs() < 1e-6);
1385 }
1386
1387 #[test]
1388 fn merge_gapped_records_unmatched_lines_per_fragment() {
1389 let clones = vec![
1391 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1392 gap_clone("a", [10, 19], [5, 8], "b", [14, 23], [7, 10]),
1393 ];
1394 let out = merge_gapped_clones(clones, 2);
1395 assert_eq!(out.len(), 1);
1396 assert_eq!(out[0].unmatched_lines, [0, 2]);
1397 assert_eq!(out[0].fragment_b.end.line, 10);
1398 }
1399
1400 #[test]
1401 fn merge_gapped_reports_renamed_halves_as_similar() {
1402 let mut clones = vec![
1403 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1404 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1405 ];
1406 for c in &mut clones {
1407 c.kind = CloneKind::Renamed;
1408 }
1409 let out = merge_gapped_clones(clones, 1);
1410 assert_eq!(out.len(), 1);
1411 assert_eq!(
1412 out[0].kind,
1413 CloneKind::Similar,
1414 "similar takes precedence over renamed"
1415 );
1416 }
1417
1418 #[test]
1419 fn prepared_source_skips_raw_hashes_when_nothing_was_normalized() {
1420 let tokens = vec![tok(1, 1, 1), tok(2, 2, 2)];
1421 let p = PreparedSource::from_detection_tokens("a".into(), "js".into(), &tokens);
1422 assert!(p.raw_hashes.is_empty());
1423 }
1424
1425 #[test]
1426 fn prepared_source_backfills_raw_hashes_from_first_normalized_token() {
1427 let tokens = vec![tok(1, 1, 1), tok(2, 2, 2), tok(3, 30, 3), tok(4, 4, 4)];
1428 let p = PreparedSource::from_detection_tokens("a".into(), "js".into(), &tokens);
1429 assert_eq!(p.raw_hashes, vec![1, 2, 30, 4]);
1430 }
1431
1432 #[test]
1433 fn clone_kind_is_exact_without_raw_hashes_and_renamed_when_raw_differs() {
1434 let a = PreparedSource::from_detection_tokens(
1435 "a".into(),
1436 "js".into(),
1437 &[tok(1, 1, 1), tok(2, 2, 2), tok(3, 3, 3)],
1438 );
1439 let b = PreparedSource::from_detection_tokens(
1440 "b".into(),
1441 "js".into(),
1442 &[tok(1, 1, 1), tok(2, 20, 2), tok(3, 3, 3)],
1443 );
1444 assert_eq!(clone_kind(&a, [0, 2], &a, [0, 2]), CloneKind::Exact);
1445 assert_eq!(clone_kind(&a, [0, 2], &b, [0, 2]), CloneKind::Renamed);
1446 assert_eq!(clone_kind(&a, [2, 2], &b, [2, 2]), CloneKind::Exact);
1448 let c = PreparedSource::from_detection_tokens(
1450 "c".into(),
1451 "js".into(),
1452 &[tok(1, 1, 1), tok(2, 2, 2), tok(9, 90, 3)],
1453 );
1454 assert_eq!(clone_kind(&a, [0, 2], &c, [0, 2]), CloneKind::Exact);
1455 }
1456 use crate::models::{Location, Token, TokenKind};
1457
1458 fn loc(line: u32, col: u32, offset: u32) -> Location {
1459 Location {
1460 line,
1461 column: col,
1462 offset,
1463 }
1464 }
1465
1466 fn make_token(kind: TokenKind, value: &str, line: u32, col: u32, offset: u32) -> Token {
1467 let end_col = col + value.len() as u32;
1468 let end_off = offset + value.len() as u32;
1469 Token {
1470 kind,
1471 value: value.to_string(),
1472 start: loc(line, col, offset),
1473 end: loc(line, end_col, end_off),
1474 }
1475 }
1476
1477 fn make_file(id: &str, format: &str, tokens: Vec<Token>) -> SourceFile {
1478 SourceFile {
1479 bytes: 0,
1480 id: id.to_string(),
1481 format: format.to_string(),
1482 tokens,
1483 }
1484 }
1485
1486 fn make_prepared(
1489 id: &str,
1490 format: &str,
1491 hashes: Vec<u64>,
1492 spans: Vec<(Location, Location)>,
1493 ) -> PreparedSource {
1494 PreparedSource {
1495 id: id.to_string(),
1496 format: format.to_string(),
1497 hashes,
1498 spans,
1499 raw_hashes: Vec::new(),
1500 functions: Vec::new(),
1501 real_path: String::new(),
1502 embedded: false,
1503 }
1504 }
1505
1506 fn js_tokens_ab() -> Vec<Token> {
1507 vec![
1508 make_token(TokenKind::Keyword, "function", 1, 0, 0),
1509 make_token(TokenKind::Other, "hello", 1, 9, 9),
1510 make_token(TokenKind::Operator, "(", 1, 14, 14),
1511 make_token(TokenKind::Operator, ")", 1, 15, 15),
1512 make_token(TokenKind::Operator, "{", 1, 16, 16),
1513 make_token(TokenKind::Keyword, "return", 2, 0, 18),
1514 make_token(TokenKind::Literal, "42", 2, 7, 25),
1515 make_token(TokenKind::Operator, ";", 2, 9, 27),
1516 make_token(TokenKind::Operator, "}", 3, 0, 29),
1517 ]
1518 }
1519
1520 #[test]
1521 fn empty_input_returns_empty() {
1522 let result = detect(&[], 10);
1523 assert!(result.is_empty());
1524 }
1525
1526 fn pair_with_js_tokens(min_tokens: usize) -> Vec<CpdClone> {
1527 let tokens = js_tokens_ab();
1528 let file_a = make_file("a.js", "javascript", tokens.clone());
1529 let file_b = make_file("b.js", "javascript", tokens);
1530 detect(&[file_a, file_b], min_tokens)
1531 }
1532
1533 #[test]
1534 fn identical_files_detected_as_clone() {
1535 assert!(
1536 !pair_with_js_tokens(5).is_empty(),
1537 "identical files must produce at least one clone"
1538 );
1539 }
1540
1541 #[test]
1542 fn min_tokens_threshold_respected() {
1543 assert!(
1544 pair_with_js_tokens(100).is_empty(),
1545 "no clones when min_tokens exceeds file length"
1546 );
1547 }
1548
1549 #[test]
1550 fn deduplication_ab_ba_collapse() {
1551 assert_eq!(
1552 pair_with_js_tokens(5).len(),
1553 1,
1554 "symmetric pairs must collapse to 1"
1555 );
1556 }
1557
1558 #[test]
1559 fn different_formats_not_cross_detected() {
1560 let tokens = js_tokens_ab();
1561 let file_js = make_file("a.js", "javascript", tokens.clone());
1562 let file_py = make_file("a.py", "python", tokens);
1563 let clones = detect(&[file_js, file_py], 5);
1564 assert!(
1565 clones.is_empty(),
1566 "tokens from different formats must not match"
1567 );
1568 }
1569
1570 #[test]
1571 fn cross_format_group_detected() {
1572 let to_prepared = |id: &str, format: &str| {
1576 let tokens = js_tokens_ab();
1577 let mut hashes = Vec::new();
1578 let mut spans = Vec::new();
1579 for t in &tokens {
1580 hashes.push(token_hash(t.kind.discriminant(), &t.value));
1581 spans.push((t.start.clone(), t.end.clone()));
1582 }
1583 make_prepared(id, format, hashes, spans)
1584 };
1585 let group = vec![
1586 to_prepared("a.js", "javascript"),
1587 to_prepared("a.ts", "typescript"),
1588 ];
1589 let clones = detect_prepared(vec![group], 5, 0, &PathFilters::default());
1590 assert_eq!(
1591 clones.len(),
1592 1,
1593 "identical token streams in one pool must match across formats"
1594 );
1595 }
1596
1597 #[test]
1607 fn open_clone_extends_only_while_its_anchor_continues() {
1608 let min_tokens = 5;
1609 let x: Vec<u64> = (100..112).collect(); let t: Vec<u64> = vec![900, 901, 902, 903, 904, 905];
1611 let z: Vec<u64> = vec![700, 701, 702, 703, 704, 705, 706];
1612 let renamed = |first: u64| {
1613 let mut v = x.clone();
1614 v[0] = first;
1615 v
1616 };
1617 let stream = |parts: &[&[u64]]| -> Vec<u64> { parts.concat() };
1618 let a = stream(&[&x]);
1619 let b = stream(&[&renamed(1), &t, &renamed(2)]);
1620 let c = stream(&[&x, &t, &z]);
1621 let streams: Vec<(&str, Vec<u64>)> =
1622 vec![("a", a.clone()), ("b", b.clone()), ("c", c.clone())];
1623 let to_prepared = |id: &str, hashes: Vec<u64>| {
1624 let spans = (0..hashes.len())
1625 .map(|i| {
1626 let loc = Location {
1627 line: i as u32 + 1,
1628 column: 1,
1629 offset: i as u32,
1630 };
1631 (loc.clone(), loc)
1632 })
1633 .collect();
1634 make_prepared(id, "javascript", hashes, spans)
1635 };
1636 let group = vec![
1637 to_prepared("a", a),
1638 to_prepared("b", b),
1639 to_prepared("c", c),
1640 ];
1641 let clones = detect_prepared(vec![group], min_tokens, 0, &PathFilters::default());
1642 let a_c: Vec<&CpdClone> = clones
1643 .iter()
1644 .filter(|cl| cl.fragment_a.source_id == "a" && cl.fragment_b.source_id == "c")
1645 .collect();
1646 assert_eq!(
1647 a_c.len(),
1648 1,
1649 "a↔c must be reported once, got {:?}",
1650 clones
1651 .iter()
1652 .map(|cl| (
1653 cl.fragment_a.source_id.as_str(),
1654 cl.fragment_a.range,
1655 cl.fragment_b.source_id.as_str(),
1656 cl.fragment_b.range,
1657 cl.token_count
1658 ))
1659 .collect::<Vec<_>>()
1660 );
1661 assert_eq!(
1662 a_c[0].token_count,
1663 x.len() as u32,
1664 "exactly X, not X plus T"
1665 );
1666 assert_eq!(a_c[0].fragment_a.range, [0, x.len() as u32 - 1]);
1667 assert_eq!(a_c[0].fragment_b.range, [0, x.len() as u32 - 1]);
1668 let run = |frag: &Fragment| -> &[u64] {
1670 let hashes = &streams
1671 .iter()
1672 .find(|(id, _)| *id == frag.source_id)
1673 .unwrap()
1674 .1;
1675 &hashes[frag.range[0] as usize..=frag.range[1] as usize]
1676 };
1677 for cl in &clones {
1678 assert_eq!(
1679 run(&cl.fragment_a),
1680 run(&cl.fragment_b),
1681 "{}{:?} and {}{:?} must hold the same tokens",
1682 cl.fragment_a.source_id,
1683 cl.fragment_a.range,
1684 cl.fragment_b.source_id,
1685 cl.fragment_b.range
1686 );
1687 }
1688 }
1689
1690 #[test]
1691 fn identical_files_maximal_clone() {
1692 let tokens = js_tokens_ab();
1695 let file_a = make_file("a.js", "javascript", tokens.clone());
1696 let file_b = make_file("b.js", "javascript", tokens);
1697 let clones = detect(&[file_a, file_b], 5);
1698 assert_eq!(
1699 clones.len(),
1700 1,
1701 "open_clone SM must produce one maximal clone"
1702 );
1703 assert_eq!(
1704 clones[0].token_count, 9,
1705 "maximal clone must cover all 9 tokens"
1706 );
1707 }
1708
1709 #[test]
1710 fn three_identical_files_secondary_pass_adds_missing_pair() {
1711 let tokens = js_tokens_ab();
1712 let file_a = make_file("a.js", "javascript", tokens.clone());
1713 let file_b = make_file("b.js", "javascript", tokens.clone());
1714 let file_c = make_file("c.js", "javascript", tokens);
1715 let clones = detect(&[file_a, file_b, file_c], 5);
1716 assert!(
1717 clones.len() >= 2,
1718 "three identical files must yield at least 2 clone pairs, got {}",
1719 clones.len()
1720 );
1721 }
1722
1723 #[test]
1724 fn clones_sorted_by_source_and_line() {
1725 let tokens = js_tokens_ab();
1726 let file_a = make_file("a.js", "javascript", tokens.clone());
1727 let file_b = make_file("b.js", "javascript", tokens);
1728 let clones = detect(&[file_a, file_b], 5);
1729 for i in 1..clones.len() {
1730 let prev = &clones[i - 1];
1731 let curr = &clones[i];
1732 assert!(
1733 (
1734 &prev.fragment_a.source_id,
1735 prev.fragment_a.start.line,
1736 &prev.fragment_b.source_id,
1737 prev.fragment_b.start.line,
1738 ) <= (
1739 &curr.fragment_a.source_id,
1740 curr.fragment_a.start.line,
1741 &curr.fragment_b.source_id,
1742 curr.fragment_b.start.line,
1743 ),
1744 "clones must be sorted"
1745 );
1746 }
1747 }
1748
1749 #[test]
1750 fn filter_path_is_the_real_path_when_it_differs_from_the_id() {
1751 let mut source = PreparedSource::from_detection_tokens(
1752 "/repo/corpus/x.js".into(),
1753 "javascript".into(),
1754 &[],
1755 );
1756 assert_eq!(source.filter_path(), "/repo/corpus/x.js");
1757 source.real_path = "/elsewhere/x.js".into();
1758 assert_eq!(source.filter_path(), "/elsewhere/x.js");
1759 }
1760
1761 fn isolated(groups: &[&[&str]]) -> Vec<Vec<PathBuf>> {
1762 groups
1763 .iter()
1764 .map(|g| g.iter().map(PathBuf::from).collect())
1765 .collect()
1766 }
1767
1768 #[test]
1769 fn skip_isolated_drops_pairs_across_group_folders() {
1770 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1771 assert!(should_skip_isolated(
1772 "/repo/packages/a/src/x.js",
1773 "/repo/packages/b/src/y.js",
1774 &groups
1775 ));
1776 }
1777
1778 #[test]
1779 fn skip_isolated_keeps_pairs_inside_one_folder() {
1780 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1781 assert!(!should_skip_isolated(
1782 "/repo/packages/a/src/x.js",
1783 "/repo/packages/a/lib/y.js",
1784 &groups
1785 ));
1786 }
1787
1788 #[test]
1789 fn skip_isolated_keeps_pairs_with_one_file_outside_group() {
1790 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1791 assert!(!should_skip_isolated(
1792 "/repo/packages/a/src/x.js",
1793 "/repo/globals/y.js",
1794 &groups
1795 ));
1796 assert!(!should_skip_isolated(
1797 "/repo/globals/x.js",
1798 "/repo/infra/y.js",
1799 &groups
1800 ));
1801 }
1802
1803 #[test]
1804 fn skip_isolated_folders_in_different_groups_do_not_isolate() {
1805 let groups = isolated(&[
1806 &["/repo/packages/a", "/repo/packages/b"],
1807 &["/repo/libs/a", "/repo/libs/b"],
1808 ]);
1809 assert!(!should_skip_isolated(
1810 "/repo/packages/a/x.js",
1811 "/repo/libs/b/y.js",
1812 &groups
1813 ));
1814 assert!(should_skip_isolated(
1815 "/repo/libs/a/x.js",
1816 "/repo/libs/b/y.js",
1817 &groups
1818 ));
1819 }
1820
1821 #[test]
1822 fn path_labels_agree_with_should_skip() {
1823 let roots = [PathBuf::from("/repo/app"), PathBuf::from("/repo/lib")];
1824 let groups = [vec![
1825 PathBuf::from("/repo/app/a"),
1826 PathBuf::from("/repo/app/b"),
1827 ]];
1828 for skip_local in [false, true] {
1829 let filters = PathFilters {
1830 skip_local,
1831 scan_roots: &roots,
1832 isolated_groups: &groups,
1833 };
1834 let files = [
1835 "/repo/app/a/x.ts",
1836 "/repo/app/b/y.ts",
1837 "/repo/app/z.ts",
1838 "/repo/lib/w.rs",
1839 "/repo/other.js",
1840 ];
1841 for a in files {
1842 for b in files {
1843 assert_eq!(
1844 filters.label(a).skips(&filters.label(b)),
1845 filters.should_skip(a, b),
1846 "{a} ~ {b}, skip_local {skip_local}"
1847 );
1848 }
1849 }
1850 }
1851 }
1852
1853 #[test]
1854 fn path_filters_combine_skip_local_and_skip_isolated() {
1855 let scan_roots = vec![PathBuf::from("/repo/shared")];
1856 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1857 let filters = PathFilters {
1858 skip_local: true,
1859 scan_roots: &scan_roots,
1860 isolated_groups: &groups,
1861 };
1862 assert!(filters.should_skip("/repo/shared/x.js", "/repo/shared/y.js"));
1863 assert!(filters.should_skip("/repo/packages/a/x.js", "/repo/packages/b/y.js"));
1864 assert!(!filters.should_skip("/repo/shared/x.js", "/repo/packages/a/y.js"));
1865 }
1866}