1use rayon::prelude::*;
4use rustc_hash::{FxHashMap, FxHashSet};
5use std::path::{Path, PathBuf};
6
7use crate::{
8 hash::{base_pow, hash_window, roll, token_hash},
9 models::{
10 CloneKind, CpdClone, DetectionToken, Fragment, Location, SimilarityMethod, SourceFile,
11 TokenKind,
12 },
13};
14
15type WindowStore = FxHashMap<u64, Occurrence>;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25struct Occurrence {
26 source_id: usize,
28 token_start: usize,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36struct CloneDedupKey {
37 a_id: String,
38 a_start_line: u32,
39 b_id: String,
40 b_start_line: u32,
41}
42
43impl CloneDedupKey {
44 fn from_clone(c: &CpdClone) -> Self {
45 let a_key = (&c.fragment_a.source_id, c.fragment_a.start.line);
47 let b_key = (&c.fragment_b.source_id, c.fragment_b.start.line);
48 if a_key <= b_key {
49 Self {
50 a_id: c.fragment_a.source_id.clone(),
51 a_start_line: c.fragment_a.start.line,
52 b_id: c.fragment_b.source_id.clone(),
53 b_start_line: c.fragment_b.start.line,
54 }
55 } else {
56 Self {
57 a_id: c.fragment_b.source_id.clone(),
58 a_start_line: c.fragment_b.start.line,
59 b_id: c.fragment_a.source_id.clone(),
60 b_start_line: c.fragment_a.start.line,
61 }
62 }
63 }
64}
65
66pub fn detect(files: &[SourceFile], min_tokens: usize) -> Vec<CpdClone> {
75 detect_with_options(files, min_tokens, 0, &PathFilters::default())
76}
77
78pub fn detect_with_options(
85 files: &[SourceFile],
86 min_tokens: usize,
87 min_lines: usize,
88 filters: &PathFilters,
89) -> Vec<CpdClone> {
90 if files.is_empty() || min_tokens == 0 {
91 return vec![];
92 }
93
94 let mut by_format: FxHashMap<&str, Vec<&SourceFile>> = FxHashMap::default();
96 for file in files {
97 by_format
98 .entry(file.format.as_str())
99 .or_default()
100 .push(file);
101 }
102 let mut format_groups: Vec<(&str, Vec<&SourceFile>)> = by_format.into_iter().collect();
103 format_groups.sort_unstable_by_key(|(fmt, _)| *fmt);
104 for (_, group) in &mut format_groups {
105 group.sort_unstable_by_key(|&file| file.id.as_str());
106 }
107
108 let mut clones: Vec<CpdClone> = format_groups
109 .into_par_iter()
110 .flat_map(|(_format, files)| {
111 let prepared: Vec<PreparedSource> = files
115 .into_iter()
116 .map(|file| {
117 let mut hashes = Vec::with_capacity(file.tokens.len());
118 let mut spans: Vec<(Location, Location)> =
119 Vec::with_capacity(file.tokens.len());
120 for t in &file.tokens {
121 if t.kind == TokenKind::Ignore {
122 continue;
123 }
124 hashes.push(token_hash(t.kind.discriminant(), &t.value));
125 spans.push((t.start.clone(), t.end.clone()));
126 }
127 PreparedSource {
128 id: file.id.clone(),
129 format: file.format.clone(),
130 hashes,
131 spans,
132 raw_hashes: Vec::new(),
133 functions: Vec::new(),
134 real_path: String::new(),
135 }
136 })
137 .collect();
138 detect_in_group(&prepared, min_tokens, min_lines, filters)
139 })
140 .collect();
141
142 finalize_clones(&mut clones);
143 clones
144}
145
146fn finalize_clones(clones: &mut Vec<CpdClone>) {
147 dedup_exact_clones(clones);
148 clones.sort_by(|a, b| {
149 (
150 &a.fragment_a.source_id,
151 a.fragment_a.start.line,
152 &a.fragment_b.source_id,
153 a.fragment_b.start.line,
154 )
155 .cmp(&(
156 &b.fragment_a.source_id,
157 b.fragment_a.start.line,
158 &b.fragment_b.source_id,
159 b.fragment_b.start.line,
160 ))
161 });
162}
163
164#[derive(Debug, Clone)]
173pub struct PreparedSource {
174 pub id: String,
175 pub format: String,
176 pub hashes: Vec<u64>,
177 pub spans: Vec<(Location, Location)>,
178 pub raw_hashes: Vec<u64>,
182 pub functions: Vec<crate::similarity::FunctionSig>,
185 pub real_path: String,
191}
192
193impl PreparedSource {
194 pub fn filter_path(&self) -> &str {
196 if self.real_path.is_empty() {
197 &self.id
198 } else {
199 &self.real_path
200 }
201 }
202
203 pub fn from_detection_tokens(id: String, format: String, tokens: &[DetectionToken]) -> Self {
205 let mut hashes = Vec::with_capacity(tokens.len());
206 let mut spans = Vec::with_capacity(tokens.len());
207 let mut raw_hashes: Vec<u64> = Vec::new();
210 for (i, t) in tokens.iter().enumerate() {
211 hashes.push(t.hash);
212 spans.push((t.start.clone(), t.end.clone()));
213 if raw_hashes.is_empty() && t.raw_hash != t.hash {
214 raw_hashes.reserve(tokens.len());
215 raw_hashes.extend(hashes[..i].iter().copied());
216 }
217 if !raw_hashes.is_empty() {
218 raw_hashes.push(t.raw_hash);
219 }
220 }
221 Self {
222 id,
223 format,
224 hashes,
225 spans,
226 raw_hashes,
227 functions: Vec::new(),
228 real_path: String::new(),
229 }
230 }
231}
232
233pub fn detect_prepared(
238 format_groups: Vec<Vec<PreparedSource>>,
239 min_tokens: usize,
240 min_lines: usize,
241 filters: &PathFilters,
242) -> Vec<CpdClone> {
243 if format_groups.is_empty() || min_tokens == 0 {
244 return vec![];
245 }
246
247 let mut clones: Vec<CpdClone> = format_groups
248 .into_par_iter()
249 .flat_map(|group| detect_in_group(&group, min_tokens, min_lines, filters))
250 .collect();
251
252 finalize_clones(&mut clones);
253 clones
254}
255
256fn detect_in_group(
261 prepared: &[PreparedSource],
262 min_tokens: usize,
263 min_lines: usize,
264 filters: &PathFilters,
265) -> Vec<CpdClone> {
266 let window_power = base_pow(min_tokens.saturating_sub(1));
269
270 let total_windows: usize = prepared
272 .iter()
273 .map(|p| p.hashes.len().saturating_sub(min_tokens))
274 .sum();
275 let mut store: WindowStore =
276 FxHashMap::with_capacity_and_hasher(total_windows, Default::default());
277
278 let mut clones: Vec<CpdClone> = Vec::new();
279 const SECONDARY_OCCURRENCE_CAP: usize = 2;
283 let mut repeated_windows: FxHashMap<u64, Vec<Occurrence>> = FxHashMap::default();
284
285 for (file_idx, source) in prepared.iter().enumerate() {
286 let hashes = &source.hashes;
287 if hashes.len() < min_tokens {
288 continue;
289 }
290 let windows_len = hashes.len() - min_tokens + 1;
291
292 let mut open_clone: Option<OpenClone> = None;
297
298 let mut window_hash = hash_window(&hashes[..min_tokens]);
299
300 for token_start in 0..windows_len {
301 if token_start > 0 {
302 window_hash = roll(
303 window_hash,
304 hashes[token_start - 1],
305 hashes[token_start + min_tokens - 1],
306 window_power,
307 );
308 }
309
310 let current = Occurrence {
311 source_id: file_idx,
312 token_start,
313 };
314
315 let stored = store
316 .get(&window_hash)
317 .copied()
318 .filter(|stored| windows_match(*stored, current, prepared, min_tokens));
319
320 let anchor_continues = open_clone.as_ref().is_some_and(|oc| {
328 let anchor = Occurrence {
329 source_id: oc.stored_occurrence.source_id,
330 token_start: oc.stored_occurrence.token_start
331 + (token_start - oc.current_start),
332 };
333 windows_match(anchor, current, prepared, min_tokens)
334 });
335
336 if anchor_continues {
337 if let Some(oc) = open_clone.as_mut() {
338 oc.match_len += 1;
339 }
340 } else {
341 flush_clone(
344 open_clone.take(),
345 file_idx,
346 prepared,
347 min_lines,
348 filters,
349 &mut clones,
350 );
351 match stored {
352 Some(stored) => {
353 open_clone = Some(OpenClone {
354 stored_occurrence: stored,
355 current_start: token_start,
356 match_len: min_tokens,
357 });
358 }
359 None => {
360 store.insert(window_hash, current);
361 }
362 }
363 }
364 if let Some(stored) = stored {
365 remember_repeated_window(
366 &mut repeated_windows,
367 window_hash,
368 stored,
369 SECONDARY_OCCURRENCE_CAP,
370 );
371 remember_repeated_window(
372 &mut repeated_windows,
373 window_hash,
374 current,
375 SECONDARY_OCCURRENCE_CAP,
376 );
377 }
380 }
381
382 flush_clone(
384 open_clone.take(),
385 file_idx,
386 prepared,
387 min_lines,
388 filters,
389 &mut clones,
390 );
391 }
392
393 add_secondary_clones(
394 repeated_windows,
395 prepared,
396 min_tokens,
397 min_lines,
398 filters,
399 &mut clones,
400 );
401
402 clones
403}
404
405#[derive(Debug, Default, Clone, Copy)]
414pub struct PathFilters<'a> {
415 pub skip_local: bool,
418 pub scan_roots: &'a [PathBuf],
420 pub isolated_groups: &'a [Vec<PathBuf>],
424}
425
426impl PathFilters<'_> {
427 fn should_skip(&self, file_a: &str, file_b: &str) -> bool {
429 (self.skip_local && should_skip_local(file_a, file_b, self.scan_roots))
430 || should_skip_isolated(file_a, file_b, self.isolated_groups)
431 }
432
433 fn should_skip_pair(&self, a: &PreparedSource, b: &PreparedSource) -> bool {
440 self.should_skip(&a.id, &b.id)
441 || ((!a.real_path.is_empty() || !b.real_path.is_empty())
442 && should_skip_isolated(a.filter_path(), b.filter_path(), self.isolated_groups))
443 }
444}
445
446fn should_skip_local(file_a: &str, file_b: &str, scan_roots: &[PathBuf]) -> bool {
450 scan_roots
451 .iter()
452 .any(|root| is_relative_to(file_a, root) && is_relative_to(file_b, root))
453}
454
455fn should_skip_isolated(file_a: &str, file_b: &str, isolated_groups: &[Vec<PathBuf>]) -> bool {
460 isolated_groups.iter().any(|group| {
461 let Some(dir_a) = group.iter().find(|dir| is_relative_to(file_a, dir)) else {
462 return false;
463 };
464 group
465 .iter()
466 .find(|dir| is_relative_to(file_b, dir))
467 .is_some_and(|dir_b| dir_a != dir_b)
468 })
469}
470
471fn is_relative_to(file_path: &str, dir: &PathBuf) -> bool {
475 let file = Path::new(file_path);
476 if let Ok(rel) = file.strip_prefix(dir) {
478 return !rel.as_os_str().is_empty();
479 }
480 if file.is_absolute() != dir.is_absolute() {
482 return false;
483 }
484 let mut ancestor = file;
486 loop {
487 if ancestor == dir.as_path() {
488 return false;
489 }
490 if ancestor.starts_with(dir) {
491 let rel = ancestor.strip_prefix(dir).unwrap_or(ancestor);
492 return !rel.as_os_str().is_empty();
493 }
494 ancestor = match ancestor.parent() {
495 Some(p) => p,
496 None => return false,
497 };
498 }
499}
500
501struct OpenClone {
502 stored_occurrence: Occurrence,
503 current_start: usize,
504 match_len: usize,
505}
506
507fn windows_match(
510 stored: Occurrence,
511 current: Occurrence,
512 prepared: &[PreparedSource],
513 min_tokens: usize,
514) -> bool {
515 if stored.source_id == current.source_id && stored.token_start == current.token_start {
516 return false;
517 }
518 let stored_hashes = &prepared[stored.source_id].hashes;
519 let current_hashes = &prepared[current.source_id].hashes;
520 if stored.token_start + min_tokens > stored_hashes.len()
521 || current.token_start + min_tokens > current_hashes.len()
522 {
523 return false;
524 }
525 stored_hashes[stored.token_start..stored.token_start + min_tokens]
526 == current_hashes[current.token_start..current.token_start + min_tokens]
527}
528
529fn flush_clone(
535 open: Option<OpenClone>,
536 current_file_idx: usize,
537 prepared: &[PreparedSource],
538 min_lines: usize,
539 filters: &PathFilters,
540 clones: &mut Vec<CpdClone>,
541) {
542 let oc = match open {
543 Some(o) => o,
544 None => return,
545 };
546
547 let existing = &oc.stored_occurrence;
548 let cur_start = oc.current_start;
549 let match_len = oc.match_len;
550
551 let existing_file = &prepared[existing.source_id];
552 let current_file = &prepared[current_file_idx];
553
554 let ex_start = existing.token_start;
555 let ex_end = ex_start + match_len - 1;
556 let cur_end = cur_start + match_len - 1;
557
558 if filters.should_skip_pair(existing_file, current_file) {
561 return;
562 }
563
564 let fragment_a = match make_fragment(&existing_file.id, &existing_file.spans, ex_start, ex_end)
565 {
566 Some(f) => f,
567 None => return,
568 };
569 let fragment_b = match make_fragment(¤t_file.id, ¤t_file.spans, cur_start, cur_end)
570 {
571 Some(f) => f,
572 None => return,
573 };
574 let kind = clone_kind(
575 existing_file,
576 fragment_a.range,
577 current_file,
578 fragment_b.range,
579 );
580
581 if min_lines > 0 {
585 let lines = fragment_a.end.line as usize - fragment_a.start.line as usize;
586 if lines < min_lines {
587 return;
588 }
589 }
590
591 clones.push(CpdClone {
592 format: current_file.format.clone(),
593 fragment_a,
594 fragment_b,
595 token_count: match_len as u32,
596 is_new: false,
597 kind,
598 similarity: None,
599 similarity_method: None,
600 unmatched_lines: [0, 0],
601 });
602}
603
604fn clone_kind(
613 a: &PreparedSource,
614 a_range: [u32; 2],
615 b: &PreparedSource,
616 b_range: [u32; 2],
617) -> CloneKind {
618 if a.raw_hashes.is_empty() && b.raw_hashes.is_empty() {
619 return CloneKind::Exact;
620 }
621 let (na, ra) = range_slices(a, a_range);
622 let (nb, rb) = range_slices(b, b_range);
623 let matched = na.iter().zip(nb).take_while(|(x, y)| x == y).count();
624 if ra[..matched.min(ra.len())] == rb[..matched.min(rb.len())] {
625 CloneKind::Exact
626 } else {
627 CloneKind::Renamed
628 }
629}
630
631fn range_slices(p: &PreparedSource, range: [u32; 2]) -> (&[u64], &[u64]) {
633 let start = (range[0] as usize).min(p.hashes.len());
634 let end = (range[1] as usize + 1).min(p.hashes.len());
635 let raw = if p.raw_hashes.is_empty() {
636 &p.hashes
637 } else {
638 &p.raw_hashes
639 };
640 (&p.hashes[start..end], &raw[start..end])
641}
642
643fn make_fragment(
644 source_id: &str,
645 spans: &[(Location, Location)],
646 start_idx: usize,
647 end_idx: usize,
648) -> Option<Fragment> {
649 let (first_start, _) = spans.get(start_idx)?;
650 let (_, last_end) = spans.get(end_idx)?;
651 Some(Fragment {
652 source_id: source_id.to_string(),
653 source_root: None,
654 start: first_start.clone(),
655 end: last_end.clone(),
656 range: [start_idx as u32, end_idx as u32],
657 blame: None,
658 })
659}
660
661pub const MIN_GAP_SIMILARITY: f32 = 0.5;
669
670pub fn merge_gapped_clones(mut clones: Vec<CpdClone>, max_gap_lines: usize) -> Vec<CpdClone> {
683 if max_gap_lines == 0 || clones.len() < 2 {
684 return clones;
685 }
686 clones.sort_by(|x, y| {
687 pair_key(x)
688 .cmp(&pair_key(y))
689 .then(x.fragment_a.range[0].cmp(&y.fragment_a.range[0]))
690 .then(x.fragment_b.range[0].cmp(&y.fragment_b.range[0]))
691 });
692 let mut merged: Vec<CpdClone> = Vec::with_capacity(clones.len());
693 for clone in clones {
694 let extended = match merged.last_mut() {
695 Some(last) if pair_key(last) == pair_key(&clone) => {
696 merge_into(last, &clone, max_gap_lines)
697 }
698 _ => false,
699 };
700 if !extended {
701 merged.push(clone);
702 }
703 }
704 merged
705}
706
707fn merge_into(last: &mut CpdClone, next: &CpdClone, max_gap_lines: usize) -> bool {
712 let Some(step_a) = continuation(&last.fragment_a, &next.fragment_a, max_gap_lines) else {
713 return false;
714 };
715 let Some(step_b) = continuation(&last.fragment_b, &next.fragment_b, max_gap_lines) else {
716 return false;
717 };
718 let matched = last.token_count
721 + next
722 .token_count
723 .saturating_sub(step_a.overlap.max(step_b.overlap));
724 let span_a = next.fragment_a.range[1] - last.fragment_a.range[0] + 1;
725 let span_b = next.fragment_b.range[1] - last.fragment_b.range[0] + 1;
726 let similarity = matched as f32 / span_a.max(span_b) as f32;
727 if similarity < MIN_GAP_SIMILARITY {
728 return false;
729 }
730 last.fragment_a.end = next.fragment_a.end.clone();
731 last.fragment_a.range[1] = next.fragment_a.range[1];
732 last.fragment_b.end = next.fragment_b.end.clone();
733 last.fragment_b.range[1] = next.fragment_b.range[1];
734 last.token_count = matched;
735 last.similarity = Some(similarity);
736 last.similarity_method = Some(SimilarityMethod::Gap);
737 last.kind = CloneKind::Similar;
738 last.unmatched_lines[0] += step_a.gap_lines;
739 last.unmatched_lines[1] += step_b.gap_lines;
740 true
741}
742
743fn pair_key(c: &CpdClone) -> (&str, &str, &str) {
744 (
745 c.format.as_str(),
746 c.fragment_a.source_id.as_str(),
747 c.fragment_b.source_id.as_str(),
748 )
749}
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
754struct Continuation {
755 overlap: u32,
756 gap_lines: u32,
757}
758
759fn continuation(prev: &Fragment, next: &Fragment, max_gap_lines: usize) -> Option<Continuation> {
762 if next.range[0] <= prev.range[0] || next.range[1] <= prev.range[1] {
763 return None;
764 }
765 let gap_lines = next.start.line.saturating_sub(prev.end.line + 1);
766 if gap_lines as usize > max_gap_lines {
767 return None;
768 }
769 Some(Continuation {
770 overlap: (prev.range[1] + 1).saturating_sub(next.range[0]),
771 gap_lines,
772 })
773}
774
775fn dedup_exact_clones(clones: &mut Vec<CpdClone>) {
776 for clone in clones.iter_mut() {
778 let a_key = (&clone.fragment_a.source_id, clone.fragment_a.start.line);
779 let b_key = (&clone.fragment_b.source_id, clone.fragment_b.start.line);
780 if a_key > b_key {
781 std::mem::swap(&mut clone.fragment_a, &mut clone.fragment_b);
782 }
783 }
784
785 let mut seen: FxHashSet<CloneDedupKey> = FxHashSet::default();
786 clones.retain(|c| seen.insert(CloneDedupKey::from_clone(c)));
787}
788
789fn remember_repeated_window(
794 repeated_windows: &mut FxHashMap<u64, Vec<Occurrence>>,
795 hash: u64,
796 occurrence: Occurrence,
797 cap: usize,
798) {
799 let bucket = repeated_windows.entry(hash).or_default();
800 if bucket
801 .iter()
802 .any(|s| s.source_id == occurrence.source_id && s.token_start == occurrence.token_start)
803 {
804 return;
805 }
806 if bucket.len() < cap {
807 bucket.push(occurrence);
808 }
809}
810
811struct SecondaryOpen {
812 clone: CpdClone,
813 source_a: usize,
814 source_b: usize,
815 last_token_start_a: usize,
816 last_token_start_b: usize,
817}
818
819#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
820struct Candidate {
821 source_a: usize,
822 source_b: usize,
823 token_a: usize,
824 token_b: usize,
825}
826
827impl SecondaryOpen {
828 fn is_continuation(&self, candidate: &Candidate) -> bool {
831 self.source_a == candidate.source_a
832 && self.source_b == candidate.source_b
833 && self.last_token_start_a + 1 == candidate.token_a
834 && self.last_token_start_b + 1 == candidate.token_b
835 }
836
837 fn grow(&mut self, candidate: &Candidate, prepared: &[PreparedSource], min_tokens: usize) {
840 self.clone.token_count += 1;
841 let end_a = candidate.token_a + min_tokens;
842 let end_b = candidate.token_b + min_tokens;
843 if let Some(span) = prepared[self.source_a].spans.get(end_a) {
844 self.clone.fragment_a.end = span.1.clone();
845 self.clone.fragment_a.range[1] = end_a as u32;
846 }
847 if let Some(span) = prepared[self.source_b].spans.get(end_b) {
848 self.clone.fragment_b.end = span.1.clone();
849 self.clone.fragment_b.range[1] = end_b as u32;
850 }
851 self.last_token_start_a = candidate.token_a;
852 self.last_token_start_b = candidate.token_b;
853 }
854}
855
856fn add_secondary_clones(
857 repeated_windows: FxHashMap<u64, Vec<Occurrence>>,
858 prepared: &[PreparedSource],
859 min_tokens: usize,
860 min_lines: usize,
861 filters: &PathFilters,
862 clones: &mut Vec<CpdClone>,
863) {
864 if repeated_windows.is_empty() {
865 return;
866 }
867
868 let mut candidates: Vec<Candidate> = Vec::new();
869 for occurrences in repeated_windows.values() {
870 if occurrences.len() < 2 {
871 continue;
872 }
873 for li in 0..occurrences.len() {
874 for ri in li + 1..occurrences.len() {
875 let left = &occurrences[li];
876 let right = &occurrences[ri];
877 if left.source_id == right.source_id && left.token_start == right.token_start {
878 continue;
879 }
880 let lh = &prepared[left.source_id].hashes;
881 let rh = &prepared[right.source_id].hashes;
882 let la = left.token_start;
883 let ra = right.token_start;
884 if la + min_tokens > lh.len() || ra + min_tokens > rh.len() {
885 continue;
886 }
887 if lh[la..la + min_tokens] != rh[ra..ra + min_tokens] {
888 continue;
889 }
890 let (sa, ta, sb, tb) =
891 if (left.source_id, left.token_start) <= (right.source_id, right.token_start) {
892 (
893 left.source_id,
894 left.token_start,
895 right.source_id,
896 right.token_start,
897 )
898 } else {
899 (
900 right.source_id,
901 right.token_start,
902 left.source_id,
903 left.token_start,
904 )
905 };
906 candidates.push(Candidate {
907 source_a: sa,
908 source_b: sb,
909 token_a: ta,
910 token_b: tb,
911 });
912 }
913 }
914 }
915 if candidates.is_empty() {
916 return;
917 }
918 candidates.sort_unstable();
919 candidates.dedup();
920
921 let mut coverage = LineCoverage::from_clones(prepared, clones);
923 let mut open: Option<SecondaryOpen> = None;
924
925 for candidate in candidates {
926 if let Some(current) = open.as_mut()
927 && current.is_continuation(&candidate)
928 {
929 current.grow(&candidate, prepared, min_tokens);
930 continue;
931 }
932
933 flush_secondary_clone(
934 open.take(),
935 prepared,
936 min_lines,
937 filters,
938 clones,
939 &mut coverage,
940 );
941
942 let start_a = candidate.token_a;
944 let end_a = start_a + min_tokens - 1;
945 let start_b = candidate.token_b;
946 let end_b = start_b + min_tokens - 1;
947
948 let frag_a = match make_fragment(
949 &prepared[candidate.source_a].id,
950 &prepared[candidate.source_a].spans,
951 start_a,
952 end_a,
953 ) {
954 Some(f) => f,
955 None => continue,
956 };
957 let frag_b = match make_fragment(
958 &prepared[candidate.source_b].id,
959 &prepared[candidate.source_b].spans,
960 start_b,
961 end_b,
962 ) {
963 Some(f) => f,
964 None => continue,
965 };
966
967 open = Some(SecondaryOpen {
968 clone: CpdClone {
969 format: prepared[candidate.source_a].format.clone(),
970 fragment_a: frag_a,
971 fragment_b: frag_b,
972 token_count: min_tokens as u32,
973 is_new: false,
974 kind: Default::default(),
975 similarity: None,
976 similarity_method: None,
977 unmatched_lines: [0, 0],
978 },
979 source_a: candidate.source_a,
980 source_b: candidate.source_b,
981 last_token_start_a: candidate.token_a,
982 last_token_start_b: candidate.token_b,
983 });
984 }
985
986 flush_secondary_clone(
987 open.take(),
988 prepared,
989 min_lines,
990 filters,
991 clones,
992 &mut coverage,
993 );
994}
995
996fn flush_secondary_clone(
997 open: Option<SecondaryOpen>,
998 prepared: &[PreparedSource],
999 min_lines: usize,
1000 filters: &PathFilters,
1001 clones: &mut Vec<CpdClone>,
1002 coverage: &mut LineCoverage,
1003) {
1004 let Some(oc) = open else {
1005 return;
1006 };
1007
1008 let range_a = fragment_line_range(&oc.clone.fragment_a);
1009 let range_b = fragment_line_range(&oc.clone.fragment_b);
1010
1011 if filters.should_skip_pair(&prepared[oc.source_a], &prepared[oc.source_b]) {
1013 return;
1014 }
1015
1016 if min_lines > 0 {
1018 let lines = oc.clone.fragment_a.end.line as usize - oc.clone.fragment_a.start.line as usize;
1019 if lines < min_lines {
1020 return;
1021 }
1022 }
1023
1024 if !coverage.extends(oc.source_a, range_a) || !coverage.extends(oc.source_b, range_b) {
1028 return;
1029 }
1030
1031 let before = clones.len();
1032 let mut clone = oc.clone;
1033 clone.kind = clone_kind(
1034 &prepared[oc.source_a],
1035 clone.fragment_a.range,
1036 &prepared[oc.source_b],
1037 clone.fragment_b.range,
1038 );
1039 clones.push(clone);
1040
1041 if clones.len() > before {
1043 coverage.insert(oc.source_a, range_a);
1044 coverage.insert(oc.source_b, range_b);
1045 }
1046}
1047
1048fn fragment_line_range(fragment: &Fragment) -> (usize, usize) {
1049 let start = fragment.start.line as usize;
1050 let end = fragment.end.line as usize;
1051 (start.min(end), start.max(end))
1052}
1053
1054struct LineCoverage {
1059 ranges_by_source: Vec<Vec<(usize, usize)>>,
1060}
1061
1062impl LineCoverage {
1063 fn from_clones(prepared: &[PreparedSource], clones: &[CpdClone]) -> Self {
1064 let mut source_lookup: FxHashMap<&str, usize> = FxHashMap::default();
1065 for (idx, source) in prepared.iter().enumerate() {
1066 source_lookup.insert(source.id.as_str(), idx);
1067 }
1068 let mut coverage = Self {
1069 ranges_by_source: vec![Vec::new(); prepared.len()],
1070 };
1071 for clone in clones {
1072 if let Some(idx) = source_lookup.get(clone.fragment_a.source_id.as_str()) {
1073 coverage.insert(*idx, fragment_line_range(&clone.fragment_a));
1074 }
1075 if let Some(idx) = source_lookup.get(clone.fragment_b.source_id.as_str()) {
1076 coverage.insert(*idx, fragment_line_range(&clone.fragment_b));
1077 }
1078 }
1079 coverage
1080 }
1081
1082 fn extends(&self, source_idx: usize, range: (usize, usize)) -> bool {
1083 let Some(intervals) = self.ranges_by_source.get(source_idx) else {
1087 return true;
1088 };
1089 let mut cursor = range.0;
1090 for &(start, end) in intervals {
1091 if end < cursor {
1092 continue;
1093 }
1094 if start > cursor {
1095 return true;
1096 }
1097 cursor = cursor.max(end.saturating_add(1));
1098 if cursor > range.1 {
1099 return false;
1100 }
1101 }
1102 cursor <= range.1
1103 }
1104
1105 fn insert(&mut self, source_idx: usize, range: (usize, usize)) {
1106 let Some(intervals) = self.ranges_by_source.get_mut(source_idx) else {
1111 return;
1112 };
1113 let mut folded = Vec::with_capacity(intervals.len() + 1);
1114 let mut pending = Some(range);
1115 for &(start, end) in intervals.iter() {
1116 let p = match pending.take() {
1117 None => {
1118 folded.push((start, end));
1119 continue;
1120 }
1121 Some(p) => p,
1122 };
1123 if p.1.saturating_add(1) < start {
1125 folded.push(p);
1126 folded.push((start, end));
1127 }
1128 else if end.saturating_add(1) < p.0 {
1130 folded.push((start, end));
1131 pending = Some(p);
1132 }
1133 else {
1135 pending = Some((p.0.min(start), p.1.max(end)));
1136 }
1137 }
1138 if let Some(p) = pending {
1139 folded.push(p);
1140 }
1141 *intervals = folded;
1142 }
1143}
1144
1145#[cfg(test)]
1150mod tests {
1151 use super::*;
1152
1153 fn tok(hash: u64, raw_hash: u64, line: u32) -> DetectionToken {
1154 let loc = Location {
1155 line,
1156 column: 0,
1157 offset: line,
1158 };
1159 DetectionToken {
1160 hash,
1161 raw_hash,
1162 start: loc.clone(),
1163 end: loc,
1164 range: [line as usize, line as usize + 1],
1165 }
1166 }
1167
1168 fn gap_clone(
1171 a: &str,
1172 a_tok: [u32; 2],
1173 a_lines: [u32; 2],
1174 b: &str,
1175 b_tok: [u32; 2],
1176 b_lines: [u32; 2],
1177 ) -> CpdClone {
1178 let frag = |id: &str, tok: [u32; 2], lines: [u32; 2]| Fragment {
1179 source_id: id.to_string(),
1180 source_root: None,
1181 start: Location {
1182 line: lines[0],
1183 column: 1,
1184 offset: tok[0],
1185 },
1186 end: Location {
1187 line: lines[1],
1188 column: 1,
1189 offset: tok[1],
1190 },
1191 range: tok,
1192 blame: None,
1193 };
1194 CpdClone {
1195 format: "javascript".to_string(),
1196 fragment_a: frag(a, a_tok, a_lines),
1197 fragment_b: frag(b, b_tok, b_lines),
1198 token_count: a_tok[1] - a_tok[0] + 1,
1199 is_new: false,
1200 kind: CloneKind::Exact,
1201 similarity: None,
1202 similarity_method: None,
1203 unmatched_lines: [0, 0],
1204 }
1205 }
1206
1207 #[test]
1208 fn merge_gapped_is_a_no_op_at_zero() {
1209 let clones = vec![
1210 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1211 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1212 ];
1213 let out = merge_gapped_clones(clones.clone(), 0);
1214 assert_eq!(out, clones);
1215 }
1216
1217 #[test]
1218 fn merge_gapped_joins_adjacent_fragments_within_gap() {
1219 let clones = vec![
1221 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1222 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1223 ];
1224 let out = merge_gapped_clones(clones, 1);
1225 assert_eq!(out.len(), 1);
1226 let c = &out[0];
1227 assert_eq!(c.kind, CloneKind::Similar);
1228 assert_eq!(c.token_count, 20, "matched tokens");
1229 assert_eq!(c.fragment_a.range, [0, 19]);
1230 assert_eq!(c.fragment_b.range, [0, 21]);
1231 assert_eq!(c.fragment_a.end.line, 8);
1232 assert_eq!(c.fragment_b.end.line, 9);
1233 assert!((c.similarity.unwrap() - 20.0 / 22.0).abs() < 1e-6);
1235 }
1236
1237 #[test]
1238 fn merge_gapped_respects_the_line_limit_and_file_pair() {
1239 let far = vec![
1240 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1241 gap_clone("a", [10, 19], [5, 8], "b", [20, 29], [8, 11]), ];
1243 assert_eq!(merge_gapped_clones(far.clone(), 2).len(), 2);
1244 assert_eq!(merge_gapped_clones(far, 3).len(), 1);
1245
1246 let other_pair = vec![
1247 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1248 gap_clone("a", [10, 19], [5, 8], "c", [10, 19], [5, 8]),
1249 ];
1250 assert_eq!(merge_gapped_clones(other_pair, 5).len(), 2);
1251 }
1252
1253 #[test]
1254 fn merge_gapped_counts_a_shared_boundary_token_once_and_chains() {
1255 let clones = vec![
1257 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1258 gap_clone("a", [9, 18], [4, 8], "b", [11, 20], [6, 9]),
1259 gap_clone("a", [19, 28], [9, 12], "b", [22, 31], [10, 13]),
1260 ];
1261 let out = merge_gapped_clones(clones, 1);
1262 assert_eq!(out.len(), 1);
1263 assert_eq!(out[0].token_count, 29, "10 + (10 - 1 overlap) + 10");
1264 assert_eq!(out[0].fragment_a.range, [0, 28]);
1265 assert_eq!(out[0].fragment_b.range, [0, 31]);
1266 }
1267
1268 #[test]
1269 fn merge_gapped_never_merges_overlapping_or_reordered_fragments() {
1270 let nested = vec![
1271 gap_clone("a", [0, 19], [1, 8], "b", [0, 19], [1, 8]),
1272 gap_clone("a", [5, 9], [3, 4], "b", [5, 9], [3, 4]),
1273 ];
1274 assert_eq!(merge_gapped_clones(nested, 5).len(), 2);
1275 let crossed = vec![
1276 gap_clone("a", [0, 9], [1, 4], "b", [20, 29], [10, 13]),
1277 gap_clone("a", [10, 19], [5, 8], "b", [0, 9], [1, 4]),
1278 ];
1279 assert_eq!(merge_gapped_clones(crossed, 5).len(), 2);
1280 }
1281
1282 #[test]
1283 fn merge_gapped_refuses_a_gap_wider_than_the_match() {
1284 let wide = vec![
1287 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1288 gap_clone("a", [10, 19], [5, 8], "b", [40, 49], [6, 9]),
1289 ];
1290 let out = merge_gapped_clones(wide, 1);
1291 assert_eq!(out.len(), 2);
1292 assert!(out.iter().all(|c| c.kind == CloneKind::Exact));
1293 assert!(out.iter().all(|c| c.similarity.is_none()));
1294 let at_floor = vec![
1296 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1297 gap_clone("a", [10, 19], [5, 8], "b", [30, 39], [6, 9]),
1298 ];
1299 let out = merge_gapped_clones(at_floor, 1);
1300 assert_eq!(out.len(), 1);
1301 assert!((out[0].similarity.unwrap() - MIN_GAP_SIMILARITY).abs() < 1e-6);
1302 }
1303
1304 #[test]
1305 fn merge_gapped_records_unmatched_lines_per_fragment() {
1306 let clones = vec![
1308 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1309 gap_clone("a", [10, 19], [5, 8], "b", [14, 23], [7, 10]),
1310 ];
1311 let out = merge_gapped_clones(clones, 2);
1312 assert_eq!(out.len(), 1);
1313 assert_eq!(out[0].unmatched_lines, [0, 2]);
1314 assert_eq!(out[0].fragment_b.end.line, 10);
1315 }
1316
1317 #[test]
1318 fn merge_gapped_reports_renamed_halves_as_similar() {
1319 let mut clones = vec![
1320 gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1321 gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1322 ];
1323 for c in &mut clones {
1324 c.kind = CloneKind::Renamed;
1325 }
1326 let out = merge_gapped_clones(clones, 1);
1327 assert_eq!(out.len(), 1);
1328 assert_eq!(
1329 out[0].kind,
1330 CloneKind::Similar,
1331 "similar takes precedence over renamed"
1332 );
1333 }
1334
1335 #[test]
1336 fn prepared_source_skips_raw_hashes_when_nothing_was_normalized() {
1337 let tokens = vec![tok(1, 1, 1), tok(2, 2, 2)];
1338 let p = PreparedSource::from_detection_tokens("a".into(), "js".into(), &tokens);
1339 assert!(p.raw_hashes.is_empty());
1340 }
1341
1342 #[test]
1343 fn prepared_source_backfills_raw_hashes_from_first_normalized_token() {
1344 let tokens = vec![tok(1, 1, 1), tok(2, 2, 2), tok(3, 30, 3), tok(4, 4, 4)];
1345 let p = PreparedSource::from_detection_tokens("a".into(), "js".into(), &tokens);
1346 assert_eq!(p.raw_hashes, vec![1, 2, 30, 4]);
1347 }
1348
1349 #[test]
1350 fn clone_kind_is_exact_without_raw_hashes_and_renamed_when_raw_differs() {
1351 let a = PreparedSource::from_detection_tokens(
1352 "a".into(),
1353 "js".into(),
1354 &[tok(1, 1, 1), tok(2, 2, 2), tok(3, 3, 3)],
1355 );
1356 let b = PreparedSource::from_detection_tokens(
1357 "b".into(),
1358 "js".into(),
1359 &[tok(1, 1, 1), tok(2, 20, 2), tok(3, 3, 3)],
1360 );
1361 assert_eq!(clone_kind(&a, [0, 2], &a, [0, 2]), CloneKind::Exact);
1362 assert_eq!(clone_kind(&a, [0, 2], &b, [0, 2]), CloneKind::Renamed);
1363 assert_eq!(clone_kind(&a, [2, 2], &b, [2, 2]), CloneKind::Exact);
1365 let c = PreparedSource::from_detection_tokens(
1367 "c".into(),
1368 "js".into(),
1369 &[tok(1, 1, 1), tok(2, 2, 2), tok(9, 90, 3)],
1370 );
1371 assert_eq!(clone_kind(&a, [0, 2], &c, [0, 2]), CloneKind::Exact);
1372 }
1373 use crate::models::{Location, Token, TokenKind};
1374
1375 fn loc(line: u32, col: u32, offset: u32) -> Location {
1376 Location {
1377 line,
1378 column: col,
1379 offset,
1380 }
1381 }
1382
1383 fn make_token(kind: TokenKind, value: &str, line: u32, col: u32, offset: u32) -> Token {
1384 let end_col = col + value.len() as u32;
1385 let end_off = offset + value.len() as u32;
1386 Token {
1387 kind,
1388 value: value.to_string(),
1389 start: loc(line, col, offset),
1390 end: loc(line, end_col, end_off),
1391 }
1392 }
1393
1394 fn make_file(id: &str, format: &str, tokens: Vec<Token>) -> SourceFile {
1395 SourceFile {
1396 bytes: 0,
1397 id: id.to_string(),
1398 format: format.to_string(),
1399 tokens,
1400 }
1401 }
1402
1403 fn js_tokens_ab() -> Vec<Token> {
1404 vec![
1405 make_token(TokenKind::Keyword, "function", 1, 0, 0),
1406 make_token(TokenKind::Other, "hello", 1, 9, 9),
1407 make_token(TokenKind::Operator, "(", 1, 14, 14),
1408 make_token(TokenKind::Operator, ")", 1, 15, 15),
1409 make_token(TokenKind::Operator, "{", 1, 16, 16),
1410 make_token(TokenKind::Keyword, "return", 2, 0, 18),
1411 make_token(TokenKind::Literal, "42", 2, 7, 25),
1412 make_token(TokenKind::Operator, ";", 2, 9, 27),
1413 make_token(TokenKind::Operator, "}", 3, 0, 29),
1414 ]
1415 }
1416
1417 #[test]
1418 fn empty_input_returns_empty() {
1419 let result = detect(&[], 10);
1420 assert!(result.is_empty());
1421 }
1422
1423 fn pair_with_js_tokens(min_tokens: usize) -> Vec<CpdClone> {
1424 let tokens = js_tokens_ab();
1425 let file_a = make_file("a.js", "javascript", tokens.clone());
1426 let file_b = make_file("b.js", "javascript", tokens);
1427 detect(&[file_a, file_b], min_tokens)
1428 }
1429
1430 #[test]
1431 fn identical_files_detected_as_clone() {
1432 assert!(
1433 !pair_with_js_tokens(5).is_empty(),
1434 "identical files must produce at least one clone"
1435 );
1436 }
1437
1438 #[test]
1439 fn min_tokens_threshold_respected() {
1440 assert!(
1441 pair_with_js_tokens(100).is_empty(),
1442 "no clones when min_tokens exceeds file length"
1443 );
1444 }
1445
1446 #[test]
1447 fn deduplication_ab_ba_collapse() {
1448 assert_eq!(
1449 pair_with_js_tokens(5).len(),
1450 1,
1451 "symmetric pairs must collapse to 1"
1452 );
1453 }
1454
1455 #[test]
1456 fn different_formats_not_cross_detected() {
1457 let tokens = js_tokens_ab();
1458 let file_js = make_file("a.js", "javascript", tokens.clone());
1459 let file_py = make_file("a.py", "python", tokens);
1460 let clones = detect(&[file_js, file_py], 5);
1461 assert!(
1462 clones.is_empty(),
1463 "tokens from different formats must not match"
1464 );
1465 }
1466
1467 #[test]
1468 fn cross_format_group_detected() {
1469 let to_prepared = |id: &str, format: &str| {
1473 let tokens = js_tokens_ab();
1474 let mut hashes = Vec::new();
1475 let mut spans = Vec::new();
1476 for t in &tokens {
1477 hashes.push(token_hash(t.kind.discriminant(), &t.value));
1478 spans.push((t.start.clone(), t.end.clone()));
1479 }
1480 PreparedSource {
1481 id: id.to_string(),
1482 format: format.to_string(),
1483 hashes,
1484 spans,
1485 raw_hashes: Vec::new(),
1486 functions: Vec::new(),
1487 real_path: String::new(),
1488 }
1489 };
1490 let group = vec![
1491 to_prepared("a.js", "javascript"),
1492 to_prepared("a.ts", "typescript"),
1493 ];
1494 let clones = detect_prepared(vec![group], 5, 0, &PathFilters::default());
1495 assert_eq!(
1496 clones.len(),
1497 1,
1498 "identical token streams in one pool must match across formats"
1499 );
1500 }
1501
1502 #[test]
1512 fn open_clone_extends_only_while_its_anchor_continues() {
1513 let min_tokens = 5;
1514 let x: Vec<u64> = (100..112).collect(); let t: Vec<u64> = vec![900, 901, 902, 903, 904, 905];
1516 let z: Vec<u64> = vec![700, 701, 702, 703, 704, 705, 706];
1517 let renamed = |first: u64| {
1518 let mut v = x.clone();
1519 v[0] = first;
1520 v
1521 };
1522 let stream = |parts: &[&[u64]]| -> Vec<u64> { parts.concat() };
1523 let a = stream(&[&x]);
1524 let b = stream(&[&renamed(1), &t, &renamed(2)]);
1525 let c = stream(&[&x, &t, &z]);
1526 let streams: Vec<(&str, Vec<u64>)> =
1527 vec![("a", a.clone()), ("b", b.clone()), ("c", c.clone())];
1528 let to_prepared = |id: &str, hashes: Vec<u64>| {
1529 let spans = (0..hashes.len())
1530 .map(|i| {
1531 let loc = Location {
1532 line: i as u32 + 1,
1533 column: 1,
1534 offset: i as u32,
1535 };
1536 (loc.clone(), loc)
1537 })
1538 .collect();
1539 PreparedSource {
1540 id: id.to_string(),
1541 format: "javascript".to_string(),
1542 hashes,
1543 spans,
1544 raw_hashes: Vec::new(),
1545 functions: Vec::new(),
1546 real_path: String::new(),
1547 }
1548 };
1549 let group = vec![
1550 to_prepared("a", a),
1551 to_prepared("b", b),
1552 to_prepared("c", c),
1553 ];
1554 let clones = detect_prepared(vec![group], min_tokens, 0, &PathFilters::default());
1555 let a_c: Vec<&CpdClone> = clones
1556 .iter()
1557 .filter(|cl| cl.fragment_a.source_id == "a" && cl.fragment_b.source_id == "c")
1558 .collect();
1559 assert_eq!(
1560 a_c.len(),
1561 1,
1562 "a↔c must be reported once, got {:?}",
1563 clones
1564 .iter()
1565 .map(|cl| (
1566 cl.fragment_a.source_id.as_str(),
1567 cl.fragment_a.range,
1568 cl.fragment_b.source_id.as_str(),
1569 cl.fragment_b.range,
1570 cl.token_count
1571 ))
1572 .collect::<Vec<_>>()
1573 );
1574 assert_eq!(
1575 a_c[0].token_count,
1576 x.len() as u32,
1577 "exactly X, not X plus T"
1578 );
1579 assert_eq!(a_c[0].fragment_a.range, [0, x.len() as u32 - 1]);
1580 assert_eq!(a_c[0].fragment_b.range, [0, x.len() as u32 - 1]);
1581 let run = |frag: &Fragment| -> &[u64] {
1583 let hashes = &streams
1584 .iter()
1585 .find(|(id, _)| *id == frag.source_id)
1586 .unwrap()
1587 .1;
1588 &hashes[frag.range[0] as usize..=frag.range[1] as usize]
1589 };
1590 for cl in &clones {
1591 assert_eq!(
1592 run(&cl.fragment_a),
1593 run(&cl.fragment_b),
1594 "{}{:?} and {}{:?} must hold the same tokens",
1595 cl.fragment_a.source_id,
1596 cl.fragment_a.range,
1597 cl.fragment_b.source_id,
1598 cl.fragment_b.range
1599 );
1600 }
1601 }
1602
1603 #[test]
1604 fn identical_files_maximal_clone() {
1605 let tokens = js_tokens_ab();
1608 let file_a = make_file("a.js", "javascript", tokens.clone());
1609 let file_b = make_file("b.js", "javascript", tokens);
1610 let clones = detect(&[file_a, file_b], 5);
1611 assert_eq!(
1612 clones.len(),
1613 1,
1614 "open_clone SM must produce one maximal clone"
1615 );
1616 assert_eq!(
1617 clones[0].token_count, 9,
1618 "maximal clone must cover all 9 tokens"
1619 );
1620 }
1621
1622 #[test]
1623 fn three_identical_files_secondary_pass_adds_missing_pair() {
1624 let tokens = js_tokens_ab();
1625 let file_a = make_file("a.js", "javascript", tokens.clone());
1626 let file_b = make_file("b.js", "javascript", tokens.clone());
1627 let file_c = make_file("c.js", "javascript", tokens);
1628 let clones = detect(&[file_a, file_b, file_c], 5);
1629 assert!(
1630 clones.len() >= 2,
1631 "three identical files must yield at least 2 clone pairs, got {}",
1632 clones.len()
1633 );
1634 }
1635
1636 #[test]
1637 fn clones_sorted_by_source_and_line() {
1638 let tokens = js_tokens_ab();
1639 let file_a = make_file("a.js", "javascript", tokens.clone());
1640 let file_b = make_file("b.js", "javascript", tokens);
1641 let clones = detect(&[file_a, file_b], 5);
1642 for i in 1..clones.len() {
1643 let prev = &clones[i - 1];
1644 let curr = &clones[i];
1645 assert!(
1646 (
1647 &prev.fragment_a.source_id,
1648 prev.fragment_a.start.line,
1649 &prev.fragment_b.source_id,
1650 prev.fragment_b.start.line,
1651 ) <= (
1652 &curr.fragment_a.source_id,
1653 curr.fragment_a.start.line,
1654 &curr.fragment_b.source_id,
1655 curr.fragment_b.start.line,
1656 ),
1657 "clones must be sorted"
1658 );
1659 }
1660 }
1661
1662 #[test]
1663 fn filter_path_is_the_real_path_when_it_differs_from_the_id() {
1664 let mut source = PreparedSource::from_detection_tokens(
1665 "/repo/corpus/x.js".into(),
1666 "javascript".into(),
1667 &[],
1668 );
1669 assert_eq!(source.filter_path(), "/repo/corpus/x.js");
1670 source.real_path = "/elsewhere/x.js".into();
1671 assert_eq!(source.filter_path(), "/elsewhere/x.js");
1672 }
1673
1674 fn isolated(groups: &[&[&str]]) -> Vec<Vec<PathBuf>> {
1675 groups
1676 .iter()
1677 .map(|g| g.iter().map(PathBuf::from).collect())
1678 .collect()
1679 }
1680
1681 #[test]
1682 fn skip_isolated_drops_pairs_across_group_folders() {
1683 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1684 assert!(should_skip_isolated(
1685 "/repo/packages/a/src/x.js",
1686 "/repo/packages/b/src/y.js",
1687 &groups
1688 ));
1689 }
1690
1691 #[test]
1692 fn skip_isolated_keeps_pairs_inside_one_folder() {
1693 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1694 assert!(!should_skip_isolated(
1695 "/repo/packages/a/src/x.js",
1696 "/repo/packages/a/lib/y.js",
1697 &groups
1698 ));
1699 }
1700
1701 #[test]
1702 fn skip_isolated_keeps_pairs_with_one_file_outside_group() {
1703 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1704 assert!(!should_skip_isolated(
1705 "/repo/packages/a/src/x.js",
1706 "/repo/globals/y.js",
1707 &groups
1708 ));
1709 assert!(!should_skip_isolated(
1710 "/repo/globals/x.js",
1711 "/repo/infra/y.js",
1712 &groups
1713 ));
1714 }
1715
1716 #[test]
1717 fn skip_isolated_folders_in_different_groups_do_not_isolate() {
1718 let groups = isolated(&[
1719 &["/repo/packages/a", "/repo/packages/b"],
1720 &["/repo/libs/a", "/repo/libs/b"],
1721 ]);
1722 assert!(!should_skip_isolated(
1723 "/repo/packages/a/x.js",
1724 "/repo/libs/b/y.js",
1725 &groups
1726 ));
1727 assert!(should_skip_isolated(
1728 "/repo/libs/a/x.js",
1729 "/repo/libs/b/y.js",
1730 &groups
1731 ));
1732 }
1733
1734 #[test]
1735 fn path_filters_combine_skip_local_and_skip_isolated() {
1736 let scan_roots = vec![PathBuf::from("/repo/shared")];
1737 let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1738 let filters = PathFilters {
1739 skip_local: true,
1740 scan_roots: &scan_roots,
1741 isolated_groups: &groups,
1742 };
1743 assert!(filters.should_skip("/repo/shared/x.js", "/repo/shared/y.js"));
1744 assert!(filters.should_skip("/repo/packages/a/x.js", "/repo/packages/b/y.js"));
1745 assert!(!filters.should_skip("/repo/shared/x.js", "/repo/packages/a/y.js"));
1746 }
1747}