1pub mod fingerprint;
22pub mod normalize;
23
24mod detect;
25mod group;
26mod segment;
27
28pub use group::{content_entropy_bits, entropy_ratio, group_pairs};
29pub use normalize::LiteralNorm;
30
31use crate::clone_class::CloneClass;
32use crate::conditional::ArmPath;
33use crate::frontend::{Token, Unit};
34
35pub const ENGINE_VERSION: &str = "fast-engine-v1";
37
38#[derive(Debug, Clone, Copy)]
43pub struct InputFile<'a> {
44 pub tokens: &'a [Token],
46 pub units: &'a [Unit],
48}
49
50#[derive(Debug, Clone)]
52pub struct EngineConfig {
53 pub min_clone_tokens: usize,
55 pub winnow_window: usize,
58 pub literals: LiteralNorm,
60 pub posting_cap: usize,
63 pub pair_budget: usize,
69 pub max_statement_window: usize,
71 pub entropy_ratio_floor: f64,
74 pub degree_cap: usize,
77}
78
79impl Default for EngineConfig {
80 fn default() -> Self {
81 Self {
82 min_clone_tokens: 20,
83 winnow_window: 4,
84 literals: LiteralNorm::Full,
85 posting_cap: 64,
86 pair_budget: 1_000_000,
87 max_statement_window: 8,
88 entropy_ratio_floor: 0.60,
89 degree_cap: 16,
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Instance {
97 pub file: usize,
99 pub token_start: usize,
101 pub token_end: usize,
103 pub start_line: u32,
105 pub end_line: u32,
107 pub unit: Option<usize>,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum SuppressReason {
115 LowEntropy,
117 HighFrequency,
119}
120
121impl SuppressReason {
122 #[must_use]
124 pub const fn name(self) -> &'static str {
125 match self {
126 Self::LowEntropy => "low-entropy",
127 Self::HighFrequency => "high-frequency",
128 }
129 }
130}
131
132#[derive(Debug, Clone)]
134pub struct ClonePair {
135 pub content_key: u64,
138 pub(crate) content_digest: fingerprint::ContentDigest,
140 pub clone_type: CloneClass,
142 pub score: f64,
144 pub a: Instance,
146 pub b: Instance,
148}
149
150#[derive(Debug, Clone)]
152pub struct CloneGroup {
153 pub content_key: u64,
155 pub clone_type: CloneClass,
157 pub score: f64,
159 pub members: Vec<Instance>,
162 pub entropy_bits: f64,
164 pub suppressed: Option<SuppressReason>,
167}
168
169#[derive(Debug, Clone, Default, PartialEq, Eq)]
171pub struct EngineStats {
172 pub files: usize,
174 pub tokens: usize,
176 pub raw_fingerprints: usize,
178 pub raw_distinct: usize,
180 pub stop_fingerprints: usize,
182 pub stop_postings: usize,
184 pub fragments: usize,
186 pub control_headers_over_limit: usize,
189 pub fragment_classes: usize,
191 pub class_cap_dropped: usize,
193 pub seed_candidates: usize,
195 pub raw_pairs_available: usize,
202 pub fragment_candidates: usize,
204 pub fragment_pairs_available: usize,
206 pub pairs: usize,
208 pub hash_collisions: usize,
210 pub pair_budget_exhausted: bool,
212 pub conditional_pairs: usize,
215 pub subsumed_groups: usize,
217}
218
219#[derive(Debug, Clone)]
221pub struct EngineReport {
222 pub groups: Vec<CloneGroup>,
224 pub stats: EngineStats,
226}
227
228#[must_use]
234pub fn detect(files: &[InputFile<'_>], config: &EngineConfig) -> EngineReport {
235 detect_inner(files, None, config)
236}
237
238#[must_use]
245pub fn detect_with_arm_paths(
246 files: &[InputFile<'_>],
247 arm_paths: &[Option<&[ArmPath]>],
248 config: &EngineConfig,
249) -> EngineReport {
250 let arm_paths = (arm_paths.len() == files.len()
251 && arm_paths
252 .iter()
253 .zip(files)
254 .all(|(paths, file)| paths.is_none_or(|paths| paths.len() == file.tokens.len())))
255 .then_some(arm_paths);
256 detect_inner(files, arm_paths, config)
257}
258
259fn detect_inner(
261 files: &[InputFile<'_>],
262 arm_paths: Option<&[Option<&[ArmPath]>]>,
263 config: &EngineConfig,
264) -> EngineReport {
265 let mut stats = EngineStats {
266 files: files.len(),
267 tokens: files.iter().map(|f| f.tokens.len()).sum(),
268 ..EngineStats::default()
269 };
270
271 let segments: Vec<Vec<segment::SegmentId>> = files
272 .iter()
273 .map(|f| segment::segment_ids(f.tokens, f.units))
274 .collect();
275 let anchors: Vec<Vec<Option<usize>>> = files
276 .iter()
277 .map(|f| segment::anchor_ids(f.tokens, f.units))
278 .collect();
279
280 let mut raw_budget = detect::PairBudget::new(config.pair_budget);
287 let mut fragment_budget = detect::PairBudget::new(config.pair_budget);
288 let mut pairs = detect::raw_pass(
289 files,
290 &segments,
291 &anchors,
292 config,
293 &mut stats,
294 &mut raw_budget,
295 );
296 pairs.extend(detect::fragment_pass(
297 files,
298 &anchors,
299 config,
300 &mut stats,
301 &mut fragment_budget,
302 ));
303 if let Some(arm_paths) = arm_paths {
304 let before = pairs.len();
305 pairs.retain(|pair| pair_can_coexist(arm_paths, pair));
306 stats.conditional_pairs = before.saturating_sub(pairs.len());
307 }
308 stats.pairs = pairs.len();
309 stats.pair_budget_exhausted = raw_budget.exhausted() || fragment_budget.exhausted();
310
311 let mut groups = group_pairs(&pairs, files, config);
312 stats.subsumed_groups = drop_subsumed_type1_groups(&mut groups);
313 EngineReport { groups, stats }
314}
315
316fn drop_subsumed_type1_groups(groups: &mut Vec<CloneGroup>) -> usize {
324 let mut dropped = vec![false; groups.len()];
325 for (index, group) in groups.iter().enumerate() {
326 if group.clone_type != CloneClass::Type1 {
327 continue;
328 }
329 dropped[index] = groups.iter().any(|outer| {
330 outer.clone_type == CloneClass::Type2
331 && group.members.iter().all(|member| {
332 outer.members.iter().any(|candidate| {
333 candidate.file == member.file
334 && ((candidate.token_start <= member.token_start
335 && member.token_end <= candidate.token_end)
336 || (member.token_start <= candidate.token_start
337 && candidate.token_end <= member.token_end))
338 })
339 })
340 });
341 }
342 let count = dropped.iter().filter(|&&drop| drop).count();
343 let mut position = 0;
344 groups.retain(|_| {
345 let keep = !dropped[position];
346 position += 1;
347 keep
348 });
349 count
350}
351
352fn pair_can_coexist(paths: &[Option<&[ArmPath]>], pair: &ClonePair) -> bool {
354 let left = paths.get(pair.a.file).and_then(|paths| *paths);
355 let right = paths.get(pair.b.file).and_then(|paths| *paths);
356 match (left, right) {
357 (Some(left), Some(right)) => instance_arm_path(left, &pair.a)
358 .zip(instance_arm_path(right, &pair.b))
359 .is_none_or(|(left, right)| {
360 !left.is_unreachable()
361 && !right.is_unreachable()
362 && (pair.a.file != pair.b.file || !left.excludes(right))
363 }),
364 _ => true,
365 }
366}
367
368fn instance_arm_path<'a>(paths: &'a [ArmPath], instance: &Instance) -> Option<&'a ArmPath> {
370 let range = paths.get(instance.token_start..instance.token_end)?;
371 let first = range.first()?;
372 range.iter().all(|path| path == first).then_some(first)
373}
374
375#[cfg(test)]
376#[allow(clippy::expect_used, clippy::unwrap_used)]
377mod tests {
378 use super::*;
379 use crate::frontend::{SourceSpan, TokenKind, UnitKind};
380
381 fn quick(src: &str) -> Vec<Token> {
386 src.split_whitespace()
387 .enumerate()
388 .map(|(i, w)| {
389 let kind = match w {
390 "fn" | "let" | "for" | "while" | "loop" | "if" | "else" | "match" | "in"
391 | "return" => TokenKind::Keyword,
392 _ if w.chars().all(|c| c.is_ascii_digit()) => {
393 TokenKind::Literal(crate::frontend::LiteralKind::Integer)
394 }
395 _ if w
396 .chars()
397 .next()
398 .is_some_and(|c| c.is_alphabetic() || c == '_') =>
399 {
400 TokenKind::Identifier
401 }
402 _ => TokenKind::Punctuation,
403 };
404 Token {
405 kind,
406 text: w.into(),
407 span: SourceSpan {
408 start_byte: i,
409 end_byte: i + 1,
410 start_line: u32::try_from(i).unwrap_or(u32::MAX) + 1,
411 start_column: 1,
412 },
413 }
414 })
415 .collect()
416 }
417
418 fn function_unit(token_start: usize, token_end: usize) -> Unit {
419 Unit {
420 kind: UnitKind::Function,
421 name: None,
422 token_start,
423 token_end,
424 span: SourceSpan {
425 start_byte: 0,
426 end_byte: 0,
427 start_line: 1,
428 start_column: 1,
429 },
430 }
431 }
432
433 const FN_A: &str =
435 "fn alpha ( ) { let acc = base + rate * step ; emit ( acc , base , rate , step ) ; }";
436 const FN_A_RENAMED: &str =
438 "fn beta ( ) { let sum = seed + gain * width ; emit ( sum , seed , gain , width ) ; }";
439 const FN_OTHER: &str = "fn omega ( ) { while cursor < bound { cursor = cursor + probe ; } yield_all ( cursor , bound ) ; }";
441
442 #[test]
443 fn empty_input_yields_an_empty_report() {
444 let report = detect(&[], &EngineConfig::default());
445 assert!(report.groups.is_empty());
446 assert_eq!(report.stats.files, 0);
447 }
448
449 #[test]
450 fn verbatim_functions_across_files_form_a_type1_group() {
451 let a = quick(&format!("{FN_A} {FN_OTHER}"));
452 let b = quick(FN_A);
453 let units_a = vec![function_unit(0, 24), function_unit(24, a.len())];
454 let units_b = vec![function_unit(0, 24)];
455 let files = [
456 InputFile {
457 tokens: &a,
458 units: &units_a,
459 },
460 InputFile {
461 tokens: &b,
462 units: &units_b,
463 },
464 ];
465 let report = detect(&files, &EngineConfig::default());
466 let type1: Vec<_> = report
467 .groups
468 .iter()
469 .filter(|g| g.clone_type == CloneClass::Type1)
470 .collect();
471 assert_eq!(type1.len(), 1, "groups: {:?}", report.groups);
472 let group = type1[0];
473 assert_eq!(group.members.len(), 2);
474 assert_eq!(group.members[0].file, 0);
475 assert_eq!(group.members[1].file, 1);
476 assert_eq!(group.members[0].unit, Some(0));
478 assert!((group.score - 1.0).abs() < f64::EPSILON);
479 }
480
481 #[test]
482 fn alternative_preprocessor_arms_do_not_form_a_fast_clone_group() {
483 use crate::conditional::{ArmTracker, StaticCondition};
484
485 let first = quick(FN_A);
486 let mut tokens = first.clone();
487 tokens.extend(quick(FN_A));
488 let split = first.len();
489 let units = [function_unit(0, split), function_unit(split, tokens.len())];
490 let files = [InputFile {
491 tokens: &tokens,
492 units: &units,
493 }];
494 let mut tracker = ArmTracker::default();
495 tracker.begin(StaticCondition::Unknown);
496 let mut paths = vec![tracker.current(); split];
497 tracker.next_arm(StaticCondition::Unknown);
498 paths.extend(vec![tracker.current(); tokens.len() - split]);
499
500 let report = detect_with_arm_paths(&files, &[Some(&paths)], &EngineConfig::default());
501 assert!(report.groups.is_empty(), "groups: {:?}", report.groups);
502 assert!(report.stats.conditional_pairs > 0);
503 }
504
505 #[test]
506 fn literal_false_preprocessor_arm_does_not_form_a_fast_clone_group() {
507 use crate::conditional::{ArmTracker, StaticCondition};
508
509 let first = quick(FN_A);
510 let mut tokens = first.clone();
511 tokens.extend(quick(FN_A));
512 let split = first.len();
513 let units = [function_unit(0, split), function_unit(split, tokens.len())];
514 let files = [InputFile {
515 tokens: &tokens,
516 units: &units,
517 }];
518 let mut tracker = ArmTracker::default();
519 tracker.begin(StaticCondition::False);
520 let mut paths = vec![tracker.current(); split];
521 tracker.end();
522 paths.extend(vec![tracker.current(); tokens.len() - split]);
523
524 let report = detect_with_arm_paths(&files, &[Some(&paths)], &EngineConfig::default());
525 assert!(report.groups.is_empty(), "groups: {:?}", report.groups);
526 assert!(report.stats.conditional_pairs > 0);
527 }
528
529 #[test]
530 fn renamed_function_bodies_form_a_type2_group() {
531 let a = quick(FN_A);
532 let b = quick(FN_A_RENAMED);
533 let units_a = vec![function_unit(0, a.len())];
534 let units_b = vec![function_unit(0, b.len())];
535 let files = [
536 InputFile {
537 tokens: &a,
538 units: &units_a,
539 },
540 InputFile {
541 tokens: &b,
542 units: &units_b,
543 },
544 ];
545 let report = detect(&files, &EngineConfig::default());
546 let type2: Vec<_> = report
547 .groups
548 .iter()
549 .filter(|g| g.clone_type == CloneClass::Type2)
550 .collect();
551 assert_eq!(type2.len(), 1, "groups: {:?}", report.groups);
552 let group = type2[0];
553 assert_eq!(group.members.len(), 2);
554 assert!(group.score < 1.0, "renames must lower raw similarity");
555 assert!(
557 report
558 .groups
559 .iter()
560 .all(|g| g.clone_type != CloneClass::Type1)
561 );
562 }
563
564 #[test]
565 fn a_type2_group_absorbs_its_exact_type1_subset() {
566 let first = quick(FN_A);
567 let second = quick(FN_A);
568 let renamed = quick(FN_A_RENAMED);
569 let first_units = [function_unit(0, first.len())];
570 let second_units = [function_unit(0, second.len())];
571 let renamed_units = [function_unit(0, renamed.len())];
572 let files = [
573 InputFile {
574 tokens: &first,
575 units: &first_units,
576 },
577 InputFile {
578 tokens: &second,
579 units: &second_units,
580 },
581 InputFile {
582 tokens: &renamed,
583 units: &renamed_units,
584 },
585 ];
586
587 let report = detect(&files, &EngineConfig::default());
588
589 assert_eq!(report.groups.len(), 1, "groups: {:#?}", report.groups);
590 assert_eq!(report.groups[0].clone_type, CloneClass::Type2);
591 assert_eq!(report.groups[0].members.len(), 3);
592 assert_eq!(report.stats.subsumed_groups, 1);
593 }
594
595 #[test]
596 fn unrelated_functions_do_not_match() {
597 let a = quick(FN_A);
598 let b = quick(FN_OTHER);
599 let units_a = vec![function_unit(0, a.len())];
600 let units_b = vec![function_unit(0, b.len())];
601 let files = [
602 InputFile {
603 tokens: &a,
604 units: &units_a,
605 },
606 InputFile {
607 tokens: &b,
608 units: &units_b,
609 },
610 ];
611 let report = detect(&files, &EngineConfig::default());
612 assert!(report.groups.is_empty(), "groups: {:?}", report.groups);
613 }
614
615 #[test]
616 fn intra_file_duplicates_are_found() {
617 let src = format!("{FN_A} {FN_A}");
618 let tokens = quick(&src);
619 let units = vec![function_unit(0, 24), function_unit(24, 48)];
620 let files = [InputFile {
621 tokens: &tokens,
622 units: &units,
623 }];
624 let report = detect(&files, &EngineConfig::default());
625 assert_eq!(report.groups.len(), 1);
626 assert_eq!(report.groups[0].members.len(), 2);
627 assert_eq!(report.groups[0].members[0].file, 0);
628 assert_eq!(report.groups[0].members[1].file, 0);
629 }
630
631 #[test]
632 fn maximal_runs_stop_at_function_boundaries() {
633 let src = format!("{FN_A} {FN_A}");
636 let a = quick(&src);
637 let b = quick(&src);
638 let units = vec![function_unit(0, 24), function_unit(24, 48)];
639 let files = [
640 InputFile {
641 tokens: &a,
642 units: &units,
643 },
644 InputFile {
645 tokens: &b,
646 units: &units,
647 },
648 ];
649 let report = detect(&files, &EngineConfig::default());
650 for group in &report.groups {
651 for member in &group.members {
652 assert!(
653 member.token_end - member.token_start <= 24,
654 "run crossed a function boundary: {member:?}"
655 );
656 }
657 }
658 }
659
660 #[test]
661 fn exhausted_pair_budget_is_reported_not_silent() {
662 let a = quick(FN_A);
663 let b = quick(FN_A);
664 let units_a = vec![function_unit(0, a.len())];
665 let units_b = vec![function_unit(0, b.len())];
666 let files = [
667 InputFile {
668 tokens: &a,
669 units: &units_a,
670 },
671 InputFile {
672 tokens: &b,
673 units: &units_b,
674 },
675 ];
676 let config = EngineConfig {
677 pair_budget: 0,
678 ..EngineConfig::default()
679 };
680 let report = detect(&files, &config);
681 assert!(report.stats.pair_budget_exhausted);
682 assert!(report.groups.is_empty());
683 }
684
685 #[test]
686 fn a_pair_budget_never_reports_a_partial_candidate_class() {
687 let sources: Vec<Vec<Token>> = (0..7)
691 .map(|index| {
692 quick(&format!(
693 "fn function_{index} ( ) {{ let local_{index} = input_{index} + delta_{index} ; emit ( local_{index} , input_{index} , delta_{index} ) ; }}"
694 ))
695 })
696 .collect();
697 let units: Vec<Vec<Unit>> = sources
698 .iter()
699 .map(|tokens| vec![function_unit(0, tokens.len())])
700 .collect();
701 let files: Vec<InputFile<'_>> = sources
702 .iter()
703 .zip(&units)
704 .map(|(tokens, units)| InputFile { tokens, units })
705 .collect();
706 let complete = EngineConfig {
707 min_clone_tokens: 12,
708 posting_cap: 7,
709 pair_budget: 21,
710 ..EngineConfig::default()
711 };
712 let complete_report = detect(&files, &complete);
713 let complete_groups: Vec<_> = complete_report
714 .groups
715 .iter()
716 .filter(|group| group.clone_type == CloneClass::Type2)
717 .collect();
718 assert_eq!(complete_groups.len(), 1, "groups: {complete_groups:#?}");
719 assert_eq!(complete_groups[0].members.len(), 7);
720
721 let truncated = EngineConfig {
722 pair_budget: 3,
723 ..complete
724 };
725 let truncated_report = detect(&files, &truncated);
726 assert!(truncated_report.stats.pair_budget_exhausted);
727 assert_eq!(truncated_report.stats.fragment_pairs_available, 21);
728 assert_eq!(truncated_report.stats.fragment_candidates, 0);
729 assert!(
730 truncated_report.groups.is_empty(),
731 "a partial class must not become a smaller group: {:?}",
732 truncated_report.groups
733 );
734
735 let repeated = quick(FN_A);
739 let repeated_units = vec![function_unit(0, repeated.len())];
740 let repeated_files: Vec<InputFile<'_>> = (0..7)
741 .map(|_| InputFile {
742 tokens: &repeated,
743 units: &repeated_units,
744 })
745 .collect();
746 let raw_report = detect(&repeated_files, &truncated);
747 assert!(raw_report.stats.pair_budget_exhausted);
748 assert!(raw_report.stats.raw_pairs_available >= 21);
749 assert_eq!(raw_report.stats.seed_candidates, 0);
750 assert!(
751 raw_report.groups.is_empty(),
752 "groups: {:?}",
753 raw_report.groups
754 );
755 }
756
757 #[test]
767 fn spending_the_allowance_on_verbatim_copies_still_leaves_renamed_ones_found() {
768 let mut sources = vec![
772 quick(&format!("{FN_A} {FN_OTHER}")),
773 quick(&format!("{FN_A_RENAMED} {FN_OTHER}")),
774 ];
775 sources.extend((0..6).map(|_| quick(FN_OTHER)));
776 let units: Vec<Vec<Unit>> = sources
777 .iter()
778 .enumerate()
779 .map(|(index, tokens)| {
780 if index < 2 {
781 vec![function_unit(0, 26), function_unit(26, tokens.len())]
782 } else {
783 vec![function_unit(0, tokens.len())]
784 }
785 })
786 .collect();
787 let files: Vec<InputFile<'_>> = sources
788 .iter()
789 .zip(&units)
790 .map(|(tokens, units)| InputFile { tokens, units })
791 .collect();
792 let config = EngineConfig {
793 pair_budget: 20,
794 ..EngineConfig::default()
795 };
796 let report = detect(&files, &config);
797 assert!(
798 report.stats.pair_budget_exhausted,
799 "the allowance has to run out for this to be measuring anything"
800 );
801 let found: Vec<CloneClass> = report.groups.iter().map(|group| group.clone_type).collect();
802 assert!(
803 found.contains(&CloneClass::Type2),
804 "the renamed copy is still found: {found:?}"
805 );
806 }
807
808 #[test]
809 fn posting_cap_drops_and_counts_high_frequency_fingerprints() {
810 let a = quick(FN_A);
811 let units: Vec<Unit> = vec![function_unit(0, a.len())];
812 let many: Vec<InputFile<'_>> = (0..3)
813 .map(|_| InputFile {
814 tokens: &a,
815 units: &units,
816 })
817 .collect();
818 let config = EngineConfig {
819 posting_cap: 1,
820 ..EngineConfig::default()
821 };
822 let report = detect(&many, &config);
823 assert!(report.stats.stop_fingerprints > 0);
824 assert!(report.stats.stop_postings > 0);
825 }
826
827 #[test]
828 fn degenerate_repetition_is_marked_low_entropy() {
829 let src = "x ".repeat(30);
831 let a = quick(&src);
832 let b = quick(&src);
833 let files = [
834 InputFile {
835 tokens: &a,
836 units: &[],
837 },
838 InputFile {
839 tokens: &b,
840 units: &[],
841 },
842 ];
843 let report = detect(&files, &EngineConfig::default());
844 assert!(!report.groups.is_empty());
845 assert_eq!(
846 report.groups[0].suppressed,
847 Some(SuppressReason::LowEntropy)
848 );
849 }
850
851 #[test]
852 fn detection_is_deterministic() {
853 let a = quick(&format!("{FN_A} {FN_OTHER}"));
854 let b = quick(FN_A_RENAMED);
855 let units_a = vec![function_unit(0, 24), function_unit(24, a.len())];
856 let units_b = vec![function_unit(0, b.len())];
857 let files = [
858 InputFile {
859 tokens: &a,
860 units: &units_a,
861 },
862 InputFile {
863 tokens: &b,
864 units: &units_b,
865 },
866 ];
867 let first = detect(&files, &EngineConfig::default());
868 let second = detect(&files, &EngineConfig::default());
869 assert_eq!(first.stats, second.stats);
870 assert_eq!(first.groups.len(), second.groups.len());
871 for (x, y) in first.groups.iter().zip(second.groups.iter()) {
872 assert_eq!(x.content_key, y.content_key);
873 assert_eq!(x.members, y.members);
874 }
875 }
876}