1use std::path::Path;
12
13use fallow_config::DetectionMode;
14use rustc_hash::{FxHashMap, FxHashSet};
15use xxhash_rust::xxh3::{Xxh3, xxh3_64};
16
17use super::tokenize::{
18 FragmentTokenizationKind, FragmentTokenizationStrategy, fragment_tokenization_kind,
19};
20use super::types::{CloneGroup, CloneInstance, RefactoringKind, RefactoringSuggestion};
21
22pub const FINGERPRINT_PREFIX: &str = "dup:";
24
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub struct CloneFingerprintKey {
35 fragments_digest: u128,
36 locations_digest: u128,
37 token_count: usize,
38 line_count: usize,
39 instance_count: usize,
40}
41
42const _: () = assert!(std::mem::size_of::<CloneFingerprintKey>() <= 64);
43
44impl CloneFingerprintKey {
45 #[must_use]
47 fn from_parts(instances: &[CloneInstance], token_count: usize, line_count: usize) -> Self {
48 Self {
49 fragments_digest: hash_distinct_fragments(instances),
50 locations_digest: hash_sorted_locations(instances),
51 token_count,
52 line_count,
53 instance_count: instances.len(),
54 }
55 }
56
57 fn from_group(group: &CloneGroup) -> Self {
58 Self::from_parts(&group.instances, group.token_count, group.line_count)
59 }
60}
61
62#[derive(Debug, Clone)]
71pub struct CloneFingerprintSet {
72 by_key: FxHashMap<CloneFingerprintKey, String>,
73 key_by_fingerprint: FxHashMap<String, CloneFingerprintKey>,
74}
75
76impl CloneFingerprintSet {
77 #[must_use]
79 pub fn from_groups(groups: &[CloneGroup]) -> Self {
80 let entries: Vec<_> = groups
81 .iter()
82 .map(|group| (group, hash_instances(&group.instances)))
83 .collect();
84 Self::from_hashed_entries(&entries)
85 }
86
87 #[must_use]
89 pub fn fingerprint_for_group(&self, group: &CloneGroup) -> String {
90 self.by_key
91 .get(&CloneFingerprintKey::from_group(group))
92 .cloned()
93 .unwrap_or_else(|| clone_fingerprint(&group.instances))
94 }
95
96 #[must_use]
98 pub fn ignored_clone_key_for_group(&self, group: &CloneGroup) -> String {
99 format!(
100 "{}:{}",
101 self.fingerprint_for_group(group),
102 group.instances.len()
103 )
104 }
105
106 #[must_use]
108 pub fn fingerprint_for_parts(
109 &self,
110 instances: &[CloneInstance],
111 token_count: usize,
112 line_count: usize,
113 ) -> String {
114 let key = CloneFingerprintKey::from_parts(instances, token_count, line_count);
115 self.by_key
116 .get(&key)
117 .cloned()
118 .unwrap_or_else(|| clone_fingerprint(instances))
119 }
120
121 #[must_use]
127 pub fn find_group<'a>(
128 &self,
129 groups: &'a [CloneGroup],
130 fingerprint: &str,
131 ) -> Option<&'a CloneGroup> {
132 let key = self.key_by_fingerprint.get(fingerprint)?;
133 groups
134 .iter()
135 .find(|group| CloneFingerprintKey::from_group(group) == *key)
136 }
137
138 fn from_hashed_entries(entries: &[(&CloneGroup, u64)]) -> Self {
139 let mut short_counts: FxHashMap<u32, usize> = FxHashMap::default();
140 let mut full_counts: FxHashMap<u64, usize> = FxHashMap::default();
141 for (_, hash) in entries {
142 *short_counts.entry(*hash as u32).or_insert(0) += 1;
143 *full_counts.entry(*hash).or_insert(0) += 1;
144 }
145
146 let mut sorted_entries: Vec<_> = entries
150 .iter()
151 .map(|(group, hash)| {
152 let locations = if full_counts.get(hash).copied().unwrap_or(0) > 1 {
153 sorted_locations(&group.instances)
154 } else {
155 Vec::new()
156 };
157 (CloneFingerprintKey::from_group(group), *hash, locations)
158 })
159 .collect();
160 sorted_entries.sort_unstable_by(
161 |(left, left_hash, left_locations), (right, right_hash, right_locations)| {
162 left.fragments_digest
163 .cmp(&right.fragments_digest)
164 .then_with(|| left_locations.cmp(right_locations))
165 .then_with(|| left.token_count.cmp(&right.token_count))
166 .then_with(|| left.line_count.cmp(&right.line_count))
167 .then_with(|| left.instance_count.cmp(&right.instance_count))
168 .then_with(|| left_hash.cmp(right_hash))
169 },
170 );
171
172 let mut full_ordinals: FxHashMap<u64, usize> = FxHashMap::default();
173 let mut ambiguous_short_handles: FxHashSet<String> = FxHashSet::default();
174 let mut by_key = FxHashMap::default();
175 let mut key_by_fingerprint = FxHashMap::default();
176
177 for (key, hash, _) in &sorted_entries {
178 let short = *hash as u32;
179 let short_handle = format!("{FINGERPRINT_PREFIX}{short:08x}");
180 let fingerprint = if short_counts.get(&short).copied().unwrap_or(0) == 1 {
181 short_handle
182 } else {
183 ambiguous_short_handles.insert(short_handle);
184 let full_handle = format!("{FINGERPRINT_PREFIX}{hash:016x}");
185 if full_counts.get(hash).copied().unwrap_or(0) == 1 {
186 full_handle
187 } else {
188 let ordinal = full_ordinals.entry(*hash).or_insert(0);
189 *ordinal += 1;
190 format!("{full_handle}-r{ordinal}")
191 }
192 };
193
194 key_by_fingerprint.insert(fingerprint.clone(), key.clone());
195 by_key.insert(key.clone(), fingerprint);
196 }
197
198 for handle in ambiguous_short_handles {
199 key_by_fingerprint.remove(&handle);
200 }
201
202 Self {
203 by_key,
204 key_by_fingerprint,
205 }
206 }
207}
208
209#[must_use]
226pub fn clone_fingerprint(instances: &[CloneInstance]) -> String {
227 fingerprint_for_hash(hash_instances(instances))
228}
229
230fn hash_distinct_fragments(instances: &[CloneInstance]) -> u128 {
231 let mut fragments = instances
232 .iter()
233 .map(|instance| instance.fragment.as_str())
234 .collect::<Vec<_>>();
235 fragments.sort_unstable();
236 fragments.dedup();
237
238 let mut hasher = Xxh3::new();
239 for fragment in fragments {
240 update_hash_bytes(&mut hasher, fragment.as_bytes());
241 }
242 hasher.digest128()
243}
244
245fn sorted_locations(instances: &[CloneInstance]) -> Vec<(&Path, usize, usize)> {
246 let mut locations = instances
247 .iter()
248 .map(|instance| {
249 (
250 instance.file.as_path(),
251 instance.start_line,
252 instance.end_line,
253 )
254 })
255 .collect::<Vec<_>>();
256 locations.sort_unstable();
257 locations
258}
259
260fn hash_sorted_locations(instances: &[CloneInstance]) -> u128 {
261 let mut hasher = Xxh3::new();
262 for (path, start_line, end_line) in sorted_locations(instances) {
263 update_hash_bytes(&mut hasher, path.as_os_str().as_encoded_bytes());
264 hasher.update(&start_line.to_le_bytes());
265 hasher.update(&end_line.to_le_bytes());
266 }
267 hasher.digest128()
268}
269
270fn update_hash_bytes(hasher: &mut Xxh3, bytes: &[u8]) {
271 hasher.update(&bytes.len().to_le_bytes());
272 hasher.update(bytes);
273}
274
275fn hash_instances(instances: &[CloneInstance]) -> u64 {
276 let mut sequences = distinct_fragment_inputs(instances)
277 .into_iter()
278 .map(|(kind, fragment)| normalized_fragment_sequence(kind.path(), fragment))
279 .collect::<Vec<_>>();
280 sequences.sort_unstable();
281 sequences.dedup();
282 hash_normalized_sequences(&sequences)
283}
284
285fn distinct_fragment_inputs(instances: &[CloneInstance]) -> Vec<(FragmentTokenizationKind, &str)> {
286 let mut fragments = instances
287 .iter()
288 .map(|instance| {
289 (
290 fragment_tokenization_kind(
291 &instance.file,
292 FragmentTokenizationStrategy::Fingerprint,
293 ),
294 instance.fragment.as_str(),
295 )
296 })
297 .collect::<Vec<_>>();
298 fragments.sort_unstable();
299 fragments.dedup();
300 fragments
301}
302
303fn normalized_fragment_sequence(path: &Path, fragment: &str) -> Vec<u64> {
304 let mut tokens = super::tokenize::tokenize_file(path, fragment, false).tokens;
305 if tokens.is_empty() {
306 tokens = super::tokenize::tokenize_unparsed_fragment(fragment);
310 }
311 super::normalize::normalize_and_hash(&tokens, DetectionMode::Strict)
312 .into_iter()
313 .map(|token| token.hash)
314 .collect()
315}
316
317fn hash_normalized_sequences(sequences: &[Vec<u64>]) -> u64 {
318 let byte_len = sequences
319 .iter()
320 .map(|sequence| sequence.len().saturating_add(1))
321 .sum::<usize>()
322 .saturating_mul(std::mem::size_of::<u64>());
323 let mut bytes = Vec::with_capacity(byte_len);
324 for sequence in sequences {
325 bytes.extend_from_slice(&(sequence.len() as u64).to_le_bytes());
326 for hash in sequence {
327 bytes.extend_from_slice(&hash.to_le_bytes());
328 }
329 }
330 xxh3_64(&bytes)
331}
332
333#[must_use]
342pub fn is_unparsed_collision_key(key: &str) -> bool {
343 let bucket = format!(
344 "{FINGERPRINT_PREFIX}{:016x}-r",
345 hash_normalized_sequences(&[Vec::new()])
346 );
347 key.starts_with(&bucket)
348}
349
350fn fingerprint_for_hash(hash: u64) -> String {
351 format!("{FINGERPRINT_PREFIX}{:08x}", hash as u32)
352}
353
354#[must_use]
360pub fn group_refactoring_suggestion(group: &CloneGroup) -> RefactoringSuggestion {
361 let estimated_savings = group.line_count * group.instances.len().saturating_sub(1);
362 RefactoringSuggestion {
363 kind: RefactoringKind::ExtractFunction,
364 description: format!(
365 "Extract the shared {}-line block into one function and call it from {} sites",
366 group.line_count,
367 group.instances.len(),
368 ),
369 estimated_savings,
370 }
371}
372
373#[must_use]
382pub fn dominant_identifier(group: &CloneGroup) -> Option<String> {
383 let fragment = group.instances.first().map(|inst| inst.fragment.as_str())?;
384 let mut counts: FxHashMap<&str, usize> = FxHashMap::default();
385 for word in identifier_words(fragment) {
386 if is_generic_identifier(word) {
387 continue;
388 }
389 *counts.entry(word).or_insert(0) += 1;
390 }
391
392 let mut candidates: Vec<_> = counts
393 .into_iter()
394 .map(|(word, count)| IdentifierCandidate {
395 word,
396 count,
397 score: identifier_score(word, count),
398 })
399 .collect();
400 candidates.sort_by(|a, b| {
401 b.score
402 .cmp(&a.score)
403 .then_with(|| b.count.cmp(&a.count))
404 .then_with(|| a.word.cmp(b.word))
405 });
406
407 let best = candidates.first()?;
408 if best.count < 2 {
409 return None;
410 }
411
412 let runner_up = candidates.get(1);
413 if runner_up.is_some_and(|next| best.score.saturating_sub(next.score) < 2) {
414 return None;
415 }
416
417 if is_plain_single_token(best.word) {
418 let next_count = runner_up.map_or(0, |candidate| candidate.count);
419 if best.count < 3 || best.count < next_count + 2 {
420 return None;
421 }
422 }
423
424 Some(best.word.to_string())
425}
426
427#[derive(Debug)]
428struct IdentifierCandidate<'a> {
429 word: &'a str,
430 count: usize,
431 score: usize,
432}
433
434fn identifier_score(word: &str, count: usize) -> usize {
435 let quality_bonus = if has_identifier_separator_or_case_transition(word) {
436 5
437 } else if word.chars().count() >= 8 {
438 2
439 } else {
440 0
441 };
442 count * 5 + quality_bonus
443}
444
445fn is_plain_single_token(word: &str) -> bool {
446 !has_identifier_separator_or_case_transition(word) && word.chars().count() < 8
447}
448
449fn has_identifier_separator_or_case_transition(word: &str) -> bool {
450 if word.contains('_') || word.contains('$') {
451 return true;
452 }
453
454 let mut previous = None;
455 for ch in word.chars() {
456 if previous.is_some_and(|prev: char| prev.is_ascii_lowercase() && ch.is_ascii_uppercase()) {
457 return true;
458 }
459 previous = Some(ch);
460 }
461 false
462}
463
464fn identifier_words(source: &str) -> impl Iterator<Item = &str> {
466 source
467 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$'))
468 .filter(|word| {
469 !word.is_empty()
470 && word
471 .chars()
472 .next()
473 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
474 })
475}
476
477const GENERIC_IDENTIFIERS: &[&str] = &[
480 "data",
481 "result",
482 "results",
483 "item",
484 "items",
485 "value",
486 "values",
487 "val",
488 "obj",
489 "object",
490 "arr",
491 "array",
492 "list",
493 "map",
494 "set",
495 "key",
496 "keys",
497 "tmp",
498 "temp",
499 "acc",
500 "cur",
501 "curr",
502 "prev",
503 "next",
504 "node",
505 "el",
506 "elem",
507 "element",
508 "args",
509 "arg",
510 "opts",
511 "options",
512 "params",
513 "param",
514 "props",
515 "ctx",
516 "context",
517 "res",
518 "req",
519 "err",
520 "error",
521 "fn",
522 "cb",
523 "callback",
524 "out",
525 "input",
526 "output",
527 "name",
528 "id",
529 "index",
530 "idx",
531 "x",
532 "y",
533 "z",
534 "i",
535 "j",
536 "k",
537 "n",
538 "m",
539 "a",
540 "b",
541 "c",
542 "e",
543 "_",
544 "const",
545 "let",
546 "var",
547 "function",
548 "return",
549 "if",
550 "else",
551 "for",
552 "while",
553 "do",
554 "switch",
555 "case",
556 "break",
557 "continue",
558 "new",
559 "this",
560 "true",
561 "false",
562 "null",
563 "undefined",
564 "void",
565 "typeof",
566 "instanceof",
567 "in",
568 "of",
569 "class",
570 "extends",
571 "super",
572 "import",
573 "export",
574 "from",
575 "default",
576 "async",
577 "await",
578 "yield",
579 "type",
580 "interface",
581 "enum",
582 "as",
583 "is",
584 "keyof",
585 "readonly",
586 "public",
587 "private",
588 "protected",
589 "static",
590 "get",
591 "delete",
592 "throw",
593 "try",
594 "catch",
595 "finally",
596 "string",
597 "number",
598 "boolean",
599 "any",
600 "unknown",
601 "never",
602 "bigint",
603 "symbol",
604 "Math",
605 "JSON",
606 "Object",
607 "Array",
608 "Promise",
609 "BigInt",
610 "Number",
611 "String",
612 "Boolean",
613 "Symbol",
614 "RegExp",
615 "Date",
616];
617
618fn is_generic_identifier(word: &str) -> bool {
619 word.chars().count() == 1 || GENERIC_IDENTIFIERS.contains(&word)
620}
621
622#[cfg(test)]
623mod tests {
624 use std::path::PathBuf;
625
626 use super::*;
627
628 fn instance(fragment: &str) -> CloneInstance {
629 CloneInstance {
630 is_symlink: false,
631 file: PathBuf::from("a.ts"),
632 start_line: 1,
633 end_line: 5,
634 start_col: 0,
635 end_col: 0,
636 fragment: fragment.to_string(),
637 }
638 }
639
640 fn group(fragments: &[&str], line_count: usize) -> CloneGroup {
641 CloneGroup {
642 instances: fragments.iter().map(|f| instance(f)).collect(),
643 token_count: 40,
644 line_count,
645 similarity: None,
646 }
647 }
648
649 fn relocated_collision_groups(root: &Path) -> Vec<CloneGroup> {
650 ["src", "other"]
651 .into_iter()
652 .map(|directory| {
653 let mut clone = group(&["alpha()", "alpha()"], 2);
654 for (index, instance) in clone.instances.iter_mut().enumerate() {
655 instance.file = root.join(directory).join(format!("file-{index}.ts"));
656 }
657 clone
658 })
659 .collect()
660 }
661
662 fn unclosed_function_fragment(constant: u32) -> String {
665 format!(
666 "export function scale(items: number[]): number[] {{\n const out = items.map((item) => item * {constant});\n return out;\n"
667 )
668 }
669
670 #[test]
671 fn unparseable_fragments_keep_distinct_fingerprints() {
672 let first = unclosed_function_fragment(7);
673 let second = unclosed_function_fragment(13);
674 assert_ne!(
675 hash_instances(&group(&[&first, &first], 3).instances),
676 hash_instances(&group(&[&second, &second], 3).instances),
677 "different code must not share the empty-sequence hash"
678 );
679 }
680
681 #[test]
682 fn detects_keys_from_the_old_empty_sequence_bucket() {
683 assert!(is_unparsed_collision_key("dup:c77b3abb6f87acd9-r3:2"));
684 assert!(!is_unparsed_collision_key("dup:c77b3abb6f87acd9:2"));
685 assert!(!is_unparsed_collision_key("dup:6f87acd9:2"));
686 let fragment = unclosed_function_fragment(7);
687 let fingerprints = CloneFingerprintSet::from_groups(&[group(&[&fragment, &fragment], 3)]);
688 let key = fingerprints.ignored_clone_key_for_group(&group(&[&fragment, &fragment], 3));
689 assert!(!is_unparsed_collision_key(&key));
690 }
691
692 #[test]
693 fn unparseable_fragments_ignore_whitespace_and_comments() {
694 let compact = unclosed_function_fragment(7);
695 let spaced = compact.replace(" return out;", " // done\n return out;");
696 assert_eq!(
697 hash_instances(&group(&[&compact], 3).instances),
698 hash_instances(&group(&[&spaced], 3).instances),
699 );
700 }
701
702 #[test]
703 fn baseline_reports_only_the_new_unparseable_clone() {
704 use super::super::types::DuplicationReport;
705 use crate::baseline::{DuplicationBaselineData, filter_new_clone_groups};
706
707 let root = Path::new("/project");
708 let pair = |constant: u32, name: &str| {
709 let fragment = unclosed_function_fragment(constant);
710 let mut clone = group(&[&fragment, &fragment], 3);
711 for (index, instance) in clone.instances.iter_mut().enumerate() {
712 instance.file = root.join(format!("src/{name}-{index}.ts"));
713 }
714 clone
715 };
716 let baseline_report = DuplicationReport {
717 clone_groups: vec![pair(7, "a"), pair(13, "c"), pair(29, "e")],
718 ..Default::default()
719 };
720 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
721
722 let mut current = baseline_report.clone_groups;
723 current.push(pair(3, "g"));
724 let filtered = filter_new_clone_groups(
725 DuplicationReport {
726 clone_groups: current,
727 ..Default::default()
728 },
729 &baseline,
730 root,
731 );
732 let files: Vec<_> = filtered
733 .clone_groups
734 .iter()
735 .flat_map(|clone| clone.instances.iter().map(|i| i.file.clone()))
736 .collect();
737 assert_eq!(
738 files,
739 vec![root.join("src/g-0.ts"), root.join("src/g-1.ts")]
740 );
741 }
742
743 #[test]
744 fn fingerprint_set_relocation_preserves_collision_identity() {
745 const RELOCATIONS: usize = 32;
746 let groups = relocated_collision_groups(Path::new("/original/checkout"));
747 let fingerprints = CloneFingerprintSet::from_groups(&groups);
748 let expected = fingerprints.fingerprint_for_group(&groups[0]);
749 for index in 0..RELOCATIONS {
750 let root = PathBuf::from(format!("/different/checkout-{index}/with spaces/café"));
751 let mut relocated = relocated_collision_groups(&root);
752 relocated.reverse();
753 for group in &mut relocated {
754 group.instances.reverse();
755 }
756 let actual = CloneFingerprintSet::from_groups(&relocated);
757 assert_eq!(
758 actual.fingerprint_for_group(&relocated[1]),
759 expected,
760 "{root:?}"
761 );
762 assert!(std::ptr::eq(
763 actual
764 .find_group(&relocated, &expected)
765 .expect("relocated trace handle resolves"),
766 &raw const relocated[1],
767 ));
768 assert_eq!(
769 actual.fingerprint_for_parts(
770 &relocated[1].instances,
771 relocated[1].token_count,
772 relocated[1].line_count
773 ),
774 expected,
775 );
776 }
777 }
778
779 #[test]
780 fn corrected_collision_handles_do_not_alias_legacy_ordinals() {
781 let groups = relocated_collision_groups(Path::new("/project"));
782 let fingerprints = CloneFingerprintSet::from_groups(&groups);
783 for group in &groups {
784 let corrected = fingerprints.fingerprint_for_group(group);
785 assert!(corrected.contains("-r"));
786 let legacy = corrected.replacen("-r", "-", 1);
787 assert!(fingerprints.find_group(&groups, &legacy).is_none());
788 assert!(fingerprints.find_group(&groups, &corrected).is_some());
789 }
790 }
791
792 #[test]
793 fn relocated_collision_suppression_matches_only_corrected_handles() {
794 use super::super::types::DuplicationReport;
795 use crate::baseline::{DuplicationBaselineData, filter_new_clone_groups};
796
797 let original = DuplicationReport {
798 clone_groups: relocated_collision_groups(Path::new("/original/checkout")),
799 ..Default::default()
800 };
801 let fingerprints = CloneFingerprintSet::from_groups(&original.clone_groups);
802 let reviewed = fingerprints.ignored_clone_key_for_group(&original.clone_groups[0]);
803 let baseline =
804 DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
805 let root = Path::new("/relocated/with spaces/café");
806 let mut relocated = DuplicationReport {
807 clone_groups: relocated_collision_groups(root),
808 ..Default::default()
809 };
810 relocated.clone_groups.reverse();
811 for group in &mut relocated.clone_groups {
812 group.instances.reverse();
813 }
814 let remaining_location = relocated.clone_groups[0].instances[0].file.clone();
815
816 let mut ignored = relocated.clone();
817 super::super::apply_ignored_clones_filter(&mut ignored, std::slice::from_ref(&reviewed));
818 assert_eq!(ignored.clone_groups.len(), 1);
819 assert_eq!(
820 ignored.clone_groups[0].instances[0].file,
821 remaining_location
822 );
823 assert_eq!(ignored.stats.clone_groups_ignored, 1);
824
825 let mut partial_baseline =
826 DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
827 partial_baseline.normalized_clone_fingerprints = vec![reviewed.clone()];
828 let filtered = filter_new_clone_groups(relocated.clone(), &partial_baseline, root);
829 assert_eq!(filtered.clone_groups.len(), 1);
830 assert_eq!(
831 filtered.clone_groups[0].instances[0].file,
832 remaining_location
833 );
834 assert!(
835 filter_new_clone_groups(relocated.clone(), &baseline, root)
836 .clone_groups
837 .is_empty()
838 );
839
840 let legacy = reviewed.replacen("-r", "-", 1);
841 super::super::apply_ignored_clones_filter(&mut relocated, std::slice::from_ref(&legacy));
842 assert_eq!(relocated.clone_groups.len(), original.clone_groups.len());
843 partial_baseline.normalized_clone_fingerprints = vec![legacy];
844 let filtered = filter_new_clone_groups(relocated, &partial_baseline, root);
847 assert_eq!(filtered.clone_groups.len(), original.clone_groups.len());
848 }
849
850 #[test]
851 fn fingerprint_is_stable_and_prefixed() {
852 let g = group(&["foo(bar)", "foo(baz)"], 3);
853 let fp1 = clone_fingerprint(&g.instances);
854 let fp2 = clone_fingerprint(&g.instances);
855 assert_eq!(fp1, fp2);
856 assert!(fp1.starts_with("dup:"));
857 assert_eq!(fp1.len(), "dup:".len() + 8);
858 }
859
860 #[test]
861 fn compact_fingerprint_key_is_independent_of_instance_order() {
862 let mut original = group(&["alpha()", "beta()"], 3);
863 original.instances[0].file = PathBuf::from("src/a.ts");
864 original.instances[0].start_line = 2;
865 original.instances[1].file = PathBuf::from("src/b.ts");
866 original.instances[1].start_line = 8;
867 let mut reordered = original.clone();
868 reordered.instances.reverse();
869
870 assert_eq!(
871 CloneFingerprintKey::from_group(&original),
872 CloneFingerprintKey::from_group(&reordered)
873 );
874 }
875
876 #[test]
877 fn duplicate_fragments_are_deduplicated_before_tokenization() {
878 let mut clones = group(&["alpha()", "alpha()", "alpha()"], 2);
879 clones.instances[0].file = PathBuf::from("src/a.ts");
880 clones.instances[1].file = PathBuf::from("src/b.ts");
881 clones.instances[2].file = PathBuf::from("src/c.ts");
882 assert_eq!(distinct_fragment_inputs(&clones.instances).len(), 1);
883
884 clones.instances[2].file = PathBuf::from("src/c.css");
885 assert_eq!(
886 distinct_fragment_inputs(&clones.instances).len(),
887 2,
888 "equal source still needs separate tokenization when syntax differs"
889 );
890 }
891
892 #[test]
893 fn fingerprint_is_sibling_stable() {
894 let group_a = group(&["computeInvoiceTotal(order)", "computeInvoiceTotal(o)"], 4);
895 let before = clone_fingerprint(&group_a.instances);
896 let _group_b_edited = group(&["totallyDifferentBody()"], 2);
897 let after = clone_fingerprint(&group_a.instances);
898 assert_eq!(before, after);
899 }
900
901 #[test]
902 fn fingerprint_differs_for_different_content() {
903 let a = group(&["alpha()"], 2);
904 let b = group(&["beta()"], 2);
905 assert_ne!(
906 clone_fingerprint(&a.instances),
907 clone_fingerprint(&b.instances)
908 );
909 }
910
911 #[test]
912 fn fingerprint_ignores_formatting_comments_and_line_endings() {
913 let compact = group(
914 &["const total = left + right;", "const total = left + right;"],
915 2,
916 );
917 let formatted = group(
918 &[
919 "const total=left + right; // reviewed\r\n",
920 "/* reviewed */\nconst total = left + right;",
921 ],
922 3,
923 );
924
925 assert_eq!(
926 clone_fingerprint(&compact.instances),
927 clone_fingerprint(&formatted.instances)
928 );
929 }
930
931 #[test]
932 fn fingerprint_changes_when_any_distinct_instance_changes_tokens() {
933 let reviewed = group(&["alpha()", "alpha()"], 2);
934 let edited = group(&["alpha()", "beta()"], 2);
935
936 assert_ne!(
937 clone_fingerprint(&reviewed.instances),
938 clone_fingerprint(&edited.instances)
939 );
940 }
941
942 #[test]
943 fn fingerprint_is_independent_of_instance_order_and_count() {
944 let two = group(&["alpha()", "beta()"], 2);
945 let three_reordered = group(&["beta()", "alpha()", "alpha()"], 2);
946
947 assert_eq!(
948 clone_fingerprint(&two.instances),
949 clone_fingerprint(&three_reordered.instances)
950 );
951
952 let two_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&two));
953 let three_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&three_reordered));
954 assert_ne!(
955 two_set.ignored_clone_key_for_group(&two),
956 three_set.ignored_clone_key_for_group(&three_reordered)
957 );
958 }
959
960 #[test]
961 fn fingerprint_set_widens_only_colliding_short_handles() {
962 let a = group(&["alpha()"], 2);
963 let b = group(&["beta()"], 2);
964 let c = group(&["gamma()"], 2);
965 let entries = vec![
966 (&a, 0x0000_0001_1234_5678_u64),
967 (&b, 0x0000_0002_1234_5678_u64),
968 (&c, 0x0000_0003_8765_4321_u64),
969 ];
970
971 let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
972
973 assert_eq!(
974 fingerprints.fingerprint_for_group(&a),
975 "dup:0000000112345678"
976 );
977 assert_eq!(
978 fingerprints.fingerprint_for_group(&b),
979 "dup:0000000212345678"
980 );
981 assert_eq!(fingerprints.fingerprint_for_group(&c), "dup:87654321");
982 assert!(
983 fingerprints
984 .find_group(&[a.clone(), b.clone(), c.clone()], "dup:12345678")
985 .is_none()
986 );
987 assert_eq!(
988 fingerprints
989 .find_group(&[a, b, c], "dup:0000000212345678")
990 .and_then(|group| group.instances.first())
991 .map(|inst| inst.fragment.as_str()),
992 Some("beta()")
993 );
994 }
995
996 #[test]
997 fn fingerprint_set_suffixes_full_hash_collisions() {
998 let a = group(&["alpha()"], 2);
999 let b = group(&["beta()"], 2);
1000 let mut entries = vec![
1001 (&a, 0x0000_0001_1234_5678_u64),
1002 (&b, 0x0000_0001_1234_5678_u64),
1003 ];
1004
1005 let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
1006 entries.reverse();
1007 let reversed_fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
1008
1009 let a_fingerprint = fingerprints.fingerprint_for_group(&a);
1010 let b_fingerprint = fingerprints.fingerprint_for_group(&b);
1011 assert_eq!(
1012 a_fingerprint,
1013 reversed_fingerprints.fingerprint_for_group(&a)
1014 );
1015 assert_eq!(
1016 b_fingerprint,
1017 reversed_fingerprints.fingerprint_for_group(&b)
1018 );
1019 let mut assigned = vec![a_fingerprint, b_fingerprint];
1020 assigned.sort_unstable();
1021 assert_eq!(
1022 assigned,
1023 ["dup:0000000112345678-r1", "dup:0000000112345678-r2"]
1024 );
1025 assert!(
1026 fingerprints
1027 .find_group(&[a.clone(), b.clone()], "dup:12345678")
1028 .is_none()
1029 );
1030 assert!(
1031 fingerprints
1032 .find_group(&[a, b], "dup:0000000112345678")
1033 .is_none()
1034 );
1035 }
1036
1037 #[test]
1038 fn group_suggestion_savings_is_lines_times_extra_copies() {
1039 let g = group(&["x", "x", "x"], 10); let suggestion = group_refactoring_suggestion(&g);
1041 assert_eq!(suggestion.kind, RefactoringKind::ExtractFunction);
1042 assert_eq!(suggestion.estimated_savings, 20); }
1044
1045 #[test]
1046 fn dominant_identifier_picks_repeated_domain_name() {
1047 let g = group(
1048 &["function buildInvoice(invoice) { return invoice.total + invoice.tax; }"],
1049 3,
1050 );
1051 assert_eq!(dominant_identifier(&g).as_deref(), Some("invoice"));
1052 }
1053
1054 #[test]
1055 fn dominant_identifier_none_on_generic() {
1056 let g = group(&["const data = result.map((item) => item.value);"], 3);
1057 assert_eq!(dominant_identifier(&g), None);
1058 }
1059
1060 #[test]
1061 fn dominant_identifier_skips_ts_primitive_keywords_and_globals() {
1062 let g = group(
1063 &["const parseUser = z.string(); parseUser(z.number()); parseUser.or(z.string());"],
1064 4,
1065 );
1066 assert_eq!(dominant_identifier(&g).as_deref(), Some("parseUser"));
1067 let only_keywords = group(&["const x: string = y as string; return x as any;"], 3);
1068 assert_eq!(dominant_identifier(&only_keywords), None);
1069 let g_global = group(&["Math.max(Math.floor(Math.abs(v)), 0)"], 3);
1070 assert_eq!(dominant_identifier(&g_global), None);
1071 }
1072
1073 #[test]
1074 fn dominant_identifier_none_on_single_letter_type_param() {
1075 let g = group(
1076 &["function id<T>(x: T): T { const a: T = x; return a as T; }"],
1077 3,
1078 );
1079 assert_eq!(dominant_identifier(&g), None);
1080 }
1081
1082 #[test]
1083 fn dominant_identifier_none_on_tie() {
1084 let g = group(&["alpha(); beta();"], 2); assert_eq!(dominant_identifier(&g), None);
1086 }
1087
1088 #[test]
1089 fn dominant_identifier_prefers_structured_names() {
1090 let g = group(
1091 &["parseSchema(input); parseSchema(cache); helper(); helper();"],
1092 3,
1093 );
1094 assert_eq!(dominant_identifier(&g).as_deref(), Some("parseSchema"));
1095 }
1096
1097 #[test]
1098 fn dominant_identifier_requires_plain_token_margin() {
1099 let low_signal = group(&["schema(); schema(); parseUser();"], 3);
1100 assert_eq!(dominant_identifier(&low_signal), None);
1101
1102 let strong = group(&["schema(); schema(); schema(); schema(); parseUser();"], 3);
1103 assert_eq!(dominant_identifier(&strong).as_deref(), Some("schema"));
1104 }
1105
1106 #[test]
1107 fn dominant_identifier_is_stable_across_word_order() {
1108 let first = group(
1109 &["helper(); parseSchema(input); helper(); parseSchema(cache);"],
1110 3,
1111 );
1112 let second = group(
1113 &["parseSchema(input); helper(); parseSchema(cache); helper();"],
1114 3,
1115 );
1116
1117 assert_eq!(dominant_identifier(&first), dominant_identifier(&second));
1118 assert_eq!(dominant_identifier(&first).as_deref(), Some("parseSchema"));
1119 }
1120}