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
230#[must_use]
236pub fn fingerprint_for_fragment(fragment: &str) -> String {
237 let sequence = normalized_fragment_sequence(Path::new("fragment.ts"), fragment);
238 fingerprint_for_hash(hash_normalized_sequences(&[sequence]))
239}
240
241fn hash_distinct_fragments(instances: &[CloneInstance]) -> u128 {
242 let mut fragments = instances
243 .iter()
244 .map(|instance| instance.fragment.as_str())
245 .collect::<Vec<_>>();
246 fragments.sort_unstable();
247 fragments.dedup();
248
249 let mut hasher = Xxh3::new();
250 for fragment in fragments {
251 update_hash_bytes(&mut hasher, fragment.as_bytes());
252 }
253 hasher.digest128()
254}
255
256fn sorted_locations(instances: &[CloneInstance]) -> Vec<(&Path, usize, usize)> {
257 let mut locations = instances
258 .iter()
259 .map(|instance| {
260 (
261 instance.file.as_path(),
262 instance.start_line,
263 instance.end_line,
264 )
265 })
266 .collect::<Vec<_>>();
267 locations.sort_unstable();
268 locations
269}
270
271fn hash_sorted_locations(instances: &[CloneInstance]) -> u128 {
272 let mut hasher = Xxh3::new();
273 for (path, start_line, end_line) in sorted_locations(instances) {
274 update_hash_bytes(&mut hasher, path.as_os_str().as_encoded_bytes());
275 hasher.update(&start_line.to_le_bytes());
276 hasher.update(&end_line.to_le_bytes());
277 }
278 hasher.digest128()
279}
280
281fn update_hash_bytes(hasher: &mut Xxh3, bytes: &[u8]) {
282 hasher.update(&bytes.len().to_le_bytes());
283 hasher.update(bytes);
284}
285
286fn hash_instances(instances: &[CloneInstance]) -> u64 {
287 let mut sequences = distinct_fragment_inputs(instances)
288 .into_iter()
289 .map(|(kind, fragment)| normalized_fragment_sequence(kind.path(), fragment))
290 .collect::<Vec<_>>();
291 sequences.sort_unstable();
292 sequences.dedup();
293 hash_normalized_sequences(&sequences)
294}
295
296fn distinct_fragment_inputs(instances: &[CloneInstance]) -> Vec<(FragmentTokenizationKind, &str)> {
297 let mut fragments = instances
298 .iter()
299 .map(|instance| {
300 (
301 fragment_tokenization_kind(
302 &instance.file,
303 FragmentTokenizationStrategy::Fingerprint,
304 ),
305 instance.fragment.as_str(),
306 )
307 })
308 .collect::<Vec<_>>();
309 fragments.sort_unstable();
310 fragments.dedup();
311 fragments
312}
313
314fn normalized_fragment_sequence(path: &Path, fragment: &str) -> Vec<u64> {
315 let tokens = super::tokenize::tokenize_file(path, fragment, false);
316 super::normalize::normalize_and_hash(&tokens.tokens, DetectionMode::Strict)
317 .into_iter()
318 .map(|token| token.hash)
319 .collect()
320}
321
322fn hash_normalized_sequences(sequences: &[Vec<u64>]) -> u64 {
323 let byte_len = sequences
324 .iter()
325 .map(|sequence| sequence.len().saturating_add(1))
326 .sum::<usize>()
327 .saturating_mul(std::mem::size_of::<u64>());
328 let mut bytes = Vec::with_capacity(byte_len);
329 for sequence in sequences {
330 bytes.extend_from_slice(&(sequence.len() as u64).to_le_bytes());
331 for hash in sequence {
332 bytes.extend_from_slice(&hash.to_le_bytes());
333 }
334 }
335 xxh3_64(&bytes)
336}
337
338fn fingerprint_for_hash(hash: u64) -> String {
339 format!("{FINGERPRINT_PREFIX}{:08x}", hash as u32)
340}
341
342#[must_use]
348pub fn group_refactoring_suggestion(group: &CloneGroup) -> RefactoringSuggestion {
349 let estimated_savings = group.line_count * group.instances.len().saturating_sub(1);
350 RefactoringSuggestion {
351 kind: RefactoringKind::ExtractFunction,
352 description: format!(
353 "Extract the shared {}-line block into one function and call it from {} sites",
354 group.line_count,
355 group.instances.len(),
356 ),
357 estimated_savings,
358 }
359}
360
361#[must_use]
370pub fn dominant_identifier(group: &CloneGroup) -> Option<String> {
371 let fragment = group.instances.first().map(|inst| inst.fragment.as_str())?;
372 let mut counts: FxHashMap<&str, usize> = FxHashMap::default();
373 for word in identifier_words(fragment) {
374 if is_generic_identifier(word) {
375 continue;
376 }
377 *counts.entry(word).or_insert(0) += 1;
378 }
379
380 let mut candidates: Vec<_> = counts
381 .into_iter()
382 .map(|(word, count)| IdentifierCandidate {
383 word,
384 count,
385 score: identifier_score(word, count),
386 })
387 .collect();
388 candidates.sort_by(|a, b| {
389 b.score
390 .cmp(&a.score)
391 .then_with(|| b.count.cmp(&a.count))
392 .then_with(|| a.word.cmp(b.word))
393 });
394
395 let best = candidates.first()?;
396 if best.count < 2 {
397 return None;
398 }
399
400 let runner_up = candidates.get(1);
401 if runner_up.is_some_and(|next| best.score.saturating_sub(next.score) < 2) {
402 return None;
403 }
404
405 if is_plain_single_token(best.word) {
406 let next_count = runner_up.map_or(0, |candidate| candidate.count);
407 if best.count < 3 || best.count < next_count + 2 {
408 return None;
409 }
410 }
411
412 Some(best.word.to_string())
413}
414
415#[derive(Debug)]
416struct IdentifierCandidate<'a> {
417 word: &'a str,
418 count: usize,
419 score: usize,
420}
421
422fn identifier_score(word: &str, count: usize) -> usize {
423 let quality_bonus = if has_identifier_separator_or_case_transition(word) {
424 5
425 } else if word.chars().count() >= 8 {
426 2
427 } else {
428 0
429 };
430 count * 5 + quality_bonus
431}
432
433fn is_plain_single_token(word: &str) -> bool {
434 !has_identifier_separator_or_case_transition(word) && word.chars().count() < 8
435}
436
437fn has_identifier_separator_or_case_transition(word: &str) -> bool {
438 if word.contains('_') || word.contains('$') {
439 return true;
440 }
441
442 let mut previous = None;
443 for ch in word.chars() {
444 if previous.is_some_and(|prev: char| prev.is_ascii_lowercase() && ch.is_ascii_uppercase()) {
445 return true;
446 }
447 previous = Some(ch);
448 }
449 false
450}
451
452fn identifier_words(source: &str) -> impl Iterator<Item = &str> {
454 source
455 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$'))
456 .filter(|word| {
457 !word.is_empty()
458 && word
459 .chars()
460 .next()
461 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
462 })
463}
464
465const GENERIC_IDENTIFIERS: &[&str] = &[
468 "data",
469 "result",
470 "results",
471 "item",
472 "items",
473 "value",
474 "values",
475 "val",
476 "obj",
477 "object",
478 "arr",
479 "array",
480 "list",
481 "map",
482 "set",
483 "key",
484 "keys",
485 "tmp",
486 "temp",
487 "acc",
488 "cur",
489 "curr",
490 "prev",
491 "next",
492 "node",
493 "el",
494 "elem",
495 "element",
496 "args",
497 "arg",
498 "opts",
499 "options",
500 "params",
501 "param",
502 "props",
503 "ctx",
504 "context",
505 "res",
506 "req",
507 "err",
508 "error",
509 "fn",
510 "cb",
511 "callback",
512 "out",
513 "input",
514 "output",
515 "name",
516 "id",
517 "index",
518 "idx",
519 "x",
520 "y",
521 "z",
522 "i",
523 "j",
524 "k",
525 "n",
526 "m",
527 "a",
528 "b",
529 "c",
530 "e",
531 "_",
532 "const",
533 "let",
534 "var",
535 "function",
536 "return",
537 "if",
538 "else",
539 "for",
540 "while",
541 "do",
542 "switch",
543 "case",
544 "break",
545 "continue",
546 "new",
547 "this",
548 "true",
549 "false",
550 "null",
551 "undefined",
552 "void",
553 "typeof",
554 "instanceof",
555 "in",
556 "of",
557 "class",
558 "extends",
559 "super",
560 "import",
561 "export",
562 "from",
563 "default",
564 "async",
565 "await",
566 "yield",
567 "type",
568 "interface",
569 "enum",
570 "as",
571 "is",
572 "keyof",
573 "readonly",
574 "public",
575 "private",
576 "protected",
577 "static",
578 "get",
579 "delete",
580 "throw",
581 "try",
582 "catch",
583 "finally",
584 "string",
585 "number",
586 "boolean",
587 "any",
588 "unknown",
589 "never",
590 "bigint",
591 "symbol",
592 "Math",
593 "JSON",
594 "Object",
595 "Array",
596 "Promise",
597 "BigInt",
598 "Number",
599 "String",
600 "Boolean",
601 "Symbol",
602 "RegExp",
603 "Date",
604];
605
606fn is_generic_identifier(word: &str) -> bool {
607 word.chars().count() == 1 || GENERIC_IDENTIFIERS.contains(&word)
608}
609
610#[cfg(test)]
611mod tests {
612 use std::path::PathBuf;
613
614 use super::*;
615
616 fn instance(fragment: &str) -> CloneInstance {
617 CloneInstance {
618 file: PathBuf::from("a.ts"),
619 start_line: 1,
620 end_line: 5,
621 start_col: 0,
622 end_col: 0,
623 fragment: fragment.to_string(),
624 }
625 }
626
627 fn group(fragments: &[&str], line_count: usize) -> CloneGroup {
628 CloneGroup {
629 instances: fragments.iter().map(|f| instance(f)).collect(),
630 token_count: 40,
631 line_count,
632 similarity: None,
633 }
634 }
635
636 fn relocated_collision_groups(root: &Path) -> Vec<CloneGroup> {
637 ["src", "other"]
638 .into_iter()
639 .map(|directory| {
640 let mut clone = group(&["alpha()", "alpha()"], 2);
641 for (index, instance) in clone.instances.iter_mut().enumerate() {
642 instance.file = root.join(directory).join(format!("file-{index}.ts"));
643 }
644 clone
645 })
646 .collect()
647 }
648
649 #[test]
650 fn fingerprint_set_relocation_preserves_collision_identity() {
651 const RELOCATIONS: usize = 32;
652 let groups = relocated_collision_groups(Path::new("/original/checkout"));
653 let fingerprints = CloneFingerprintSet::from_groups(&groups);
654 let expected = fingerprints.fingerprint_for_group(&groups[0]);
655 for index in 0..RELOCATIONS {
656 let root = PathBuf::from(format!("/different/checkout-{index}/with spaces/café"));
657 let mut relocated = relocated_collision_groups(&root);
658 relocated.reverse();
659 for group in &mut relocated {
660 group.instances.reverse();
661 }
662 let actual = CloneFingerprintSet::from_groups(&relocated);
663 assert_eq!(
664 actual.fingerprint_for_group(&relocated[1]),
665 expected,
666 "{root:?}"
667 );
668 assert!(std::ptr::eq(
669 actual
670 .find_group(&relocated, &expected)
671 .expect("relocated trace handle resolves"),
672 &raw const relocated[1],
673 ));
674 assert_eq!(
675 actual.fingerprint_for_parts(
676 &relocated[1].instances,
677 relocated[1].token_count,
678 relocated[1].line_count
679 ),
680 expected,
681 );
682 }
683 }
684
685 #[test]
686 fn corrected_collision_handles_do_not_alias_legacy_ordinals() {
687 let groups = relocated_collision_groups(Path::new("/project"));
688 let fingerprints = CloneFingerprintSet::from_groups(&groups);
689 for group in &groups {
690 let corrected = fingerprints.fingerprint_for_group(group);
691 assert!(corrected.contains("-r"));
692 let legacy = corrected.replacen("-r", "-", 1);
693 assert!(fingerprints.find_group(&groups, &legacy).is_none());
694 assert!(fingerprints.find_group(&groups, &corrected).is_some());
695 }
696 }
697
698 #[test]
699 fn relocated_collision_suppression_matches_only_corrected_handles() {
700 use super::super::types::DuplicationReport;
701 use crate::baseline::{DuplicationBaselineData, filter_new_clone_groups};
702
703 let original = DuplicationReport {
704 clone_groups: relocated_collision_groups(Path::new("/original/checkout")),
705 ..Default::default()
706 };
707 let fingerprints = CloneFingerprintSet::from_groups(&original.clone_groups);
708 let reviewed = fingerprints.ignored_clone_key_for_group(&original.clone_groups[0]);
709 let baseline =
710 DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
711 let root = Path::new("/relocated/with spaces/café");
712 let mut relocated = DuplicationReport {
713 clone_groups: relocated_collision_groups(root),
714 ..Default::default()
715 };
716 relocated.clone_groups.reverse();
717 for group in &mut relocated.clone_groups {
718 group.instances.reverse();
719 }
720 let remaining_location = relocated.clone_groups[0].instances[0].file.clone();
721
722 let mut ignored = relocated.clone();
723 super::super::apply_ignored_clones_filter(&mut ignored, std::slice::from_ref(&reviewed));
724 assert_eq!(ignored.clone_groups.len(), 1);
725 assert_eq!(
726 ignored.clone_groups[0].instances[0].file,
727 remaining_location
728 );
729 assert_eq!(ignored.stats.clone_groups_ignored, 1);
730
731 let mut partial_baseline =
732 DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
733 partial_baseline.normalized_clone_fingerprints = vec![reviewed.clone()];
734 let filtered = filter_new_clone_groups(relocated.clone(), &partial_baseline, root);
735 assert_eq!(filtered.clone_groups.len(), 1);
736 assert_eq!(
737 filtered.clone_groups[0].instances[0].file,
738 remaining_location
739 );
740 assert!(
741 filter_new_clone_groups(relocated.clone(), &baseline, root)
742 .clone_groups
743 .is_empty()
744 );
745
746 let legacy = reviewed.replacen("-r", "-", 1);
747 super::super::apply_ignored_clones_filter(&mut relocated, std::slice::from_ref(&legacy));
748 assert_eq!(relocated.clone_groups.len(), original.clone_groups.len());
749 partial_baseline.normalized_clone_fingerprints = vec![legacy];
750 let filtered = filter_new_clone_groups(relocated, &partial_baseline, root);
753 assert_eq!(filtered.clone_groups.len(), original.clone_groups.len());
754 }
755
756 #[test]
757 fn fingerprint_is_stable_and_prefixed() {
758 let g = group(&["foo(bar)", "foo(baz)"], 3);
759 let fp1 = clone_fingerprint(&g.instances);
760 let fp2 = clone_fingerprint(&g.instances);
761 assert_eq!(fp1, fp2);
762 assert!(fp1.starts_with("dup:"));
763 assert_eq!(fp1.len(), "dup:".len() + 8);
764 }
765
766 #[test]
767 fn compact_fingerprint_key_is_independent_of_instance_order() {
768 let mut original = group(&["alpha()", "beta()"], 3);
769 original.instances[0].file = PathBuf::from("src/a.ts");
770 original.instances[0].start_line = 2;
771 original.instances[1].file = PathBuf::from("src/b.ts");
772 original.instances[1].start_line = 8;
773 let mut reordered = original.clone();
774 reordered.instances.reverse();
775
776 assert_eq!(
777 CloneFingerprintKey::from_group(&original),
778 CloneFingerprintKey::from_group(&reordered)
779 );
780 }
781
782 #[test]
783 fn duplicate_fragments_are_deduplicated_before_tokenization() {
784 let mut clones = group(&["alpha()", "alpha()", "alpha()"], 2);
785 clones.instances[0].file = PathBuf::from("src/a.ts");
786 clones.instances[1].file = PathBuf::from("src/b.ts");
787 clones.instances[2].file = PathBuf::from("src/c.ts");
788 assert_eq!(distinct_fragment_inputs(&clones.instances).len(), 1);
789
790 clones.instances[2].file = PathBuf::from("src/c.css");
791 assert_eq!(
792 distinct_fragment_inputs(&clones.instances).len(),
793 2,
794 "equal source still needs separate tokenization when syntax differs"
795 );
796 }
797
798 #[test]
799 fn fingerprint_is_sibling_stable() {
800 let group_a = group(&["computeInvoiceTotal(order)", "computeInvoiceTotal(o)"], 4);
801 let before = clone_fingerprint(&group_a.instances);
802 let _group_b_edited = group(&["totallyDifferentBody()"], 2);
803 let after = clone_fingerprint(&group_a.instances);
804 assert_eq!(before, after);
805 }
806
807 #[test]
808 fn fingerprint_differs_for_different_content() {
809 let a = group(&["alpha()"], 2);
810 let b = group(&["beta()"], 2);
811 assert_ne!(
812 clone_fingerprint(&a.instances),
813 clone_fingerprint(&b.instances)
814 );
815 }
816
817 #[test]
818 fn fingerprint_ignores_formatting_comments_and_line_endings() {
819 let compact = group(
820 &["const total = left + right;", "const total = left + right;"],
821 2,
822 );
823 let formatted = group(
824 &[
825 "const total=left + right; // reviewed\r\n",
826 "/* reviewed */\nconst total = left + right;",
827 ],
828 3,
829 );
830
831 assert_eq!(
832 clone_fingerprint(&compact.instances),
833 clone_fingerprint(&formatted.instances)
834 );
835 }
836
837 #[test]
838 fn fingerprint_changes_when_any_distinct_instance_changes_tokens() {
839 let reviewed = group(&["alpha()", "alpha()"], 2);
840 let edited = group(&["alpha()", "beta()"], 2);
841
842 assert_ne!(
843 clone_fingerprint(&reviewed.instances),
844 clone_fingerprint(&edited.instances)
845 );
846 }
847
848 #[test]
849 fn fingerprint_is_independent_of_instance_order_and_count() {
850 let two = group(&["alpha()", "beta()"], 2);
851 let three_reordered = group(&["beta()", "alpha()", "alpha()"], 2);
852
853 assert_eq!(
854 clone_fingerprint(&two.instances),
855 clone_fingerprint(&three_reordered.instances)
856 );
857
858 let two_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&two));
859 let three_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&three_reordered));
860 assert_ne!(
861 two_set.ignored_clone_key_for_group(&two),
862 three_set.ignored_clone_key_for_group(&three_reordered)
863 );
864 }
865
866 #[test]
867 fn fingerprint_set_widens_only_colliding_short_handles() {
868 let a = group(&["alpha()"], 2);
869 let b = group(&["beta()"], 2);
870 let c = group(&["gamma()"], 2);
871 let entries = vec![
872 (&a, 0x0000_0001_1234_5678_u64),
873 (&b, 0x0000_0002_1234_5678_u64),
874 (&c, 0x0000_0003_8765_4321_u64),
875 ];
876
877 let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
878
879 assert_eq!(
880 fingerprints.fingerprint_for_group(&a),
881 "dup:0000000112345678"
882 );
883 assert_eq!(
884 fingerprints.fingerprint_for_group(&b),
885 "dup:0000000212345678"
886 );
887 assert_eq!(fingerprints.fingerprint_for_group(&c), "dup:87654321");
888 assert!(
889 fingerprints
890 .find_group(&[a.clone(), b.clone(), c.clone()], "dup:12345678")
891 .is_none()
892 );
893 assert_eq!(
894 fingerprints
895 .find_group(&[a, b, c], "dup:0000000212345678")
896 .and_then(|group| group.instances.first())
897 .map(|inst| inst.fragment.as_str()),
898 Some("beta()")
899 );
900 }
901
902 #[test]
903 fn fingerprint_set_suffixes_full_hash_collisions() {
904 let a = group(&["alpha()"], 2);
905 let b = group(&["beta()"], 2);
906 let mut entries = vec![
907 (&a, 0x0000_0001_1234_5678_u64),
908 (&b, 0x0000_0001_1234_5678_u64),
909 ];
910
911 let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
912 entries.reverse();
913 let reversed_fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
914
915 let a_fingerprint = fingerprints.fingerprint_for_group(&a);
916 let b_fingerprint = fingerprints.fingerprint_for_group(&b);
917 assert_eq!(
918 a_fingerprint,
919 reversed_fingerprints.fingerprint_for_group(&a)
920 );
921 assert_eq!(
922 b_fingerprint,
923 reversed_fingerprints.fingerprint_for_group(&b)
924 );
925 let mut assigned = vec![a_fingerprint, b_fingerprint];
926 assigned.sort_unstable();
927 assert_eq!(
928 assigned,
929 ["dup:0000000112345678-r1", "dup:0000000112345678-r2"]
930 );
931 assert!(
932 fingerprints
933 .find_group(&[a.clone(), b.clone()], "dup:12345678")
934 .is_none()
935 );
936 assert!(
937 fingerprints
938 .find_group(&[a, b], "dup:0000000112345678")
939 .is_none()
940 );
941 }
942
943 #[test]
944 fn group_suggestion_savings_is_lines_times_extra_copies() {
945 let g = group(&["x", "x", "x"], 10); let suggestion = group_refactoring_suggestion(&g);
947 assert_eq!(suggestion.kind, RefactoringKind::ExtractFunction);
948 assert_eq!(suggestion.estimated_savings, 20); }
950
951 #[test]
952 fn dominant_identifier_picks_repeated_domain_name() {
953 let g = group(
954 &["function buildInvoice(invoice) { return invoice.total + invoice.tax; }"],
955 3,
956 );
957 assert_eq!(dominant_identifier(&g).as_deref(), Some("invoice"));
958 }
959
960 #[test]
961 fn dominant_identifier_none_on_generic() {
962 let g = group(&["const data = result.map((item) => item.value);"], 3);
963 assert_eq!(dominant_identifier(&g), None);
964 }
965
966 #[test]
967 fn dominant_identifier_skips_ts_primitive_keywords_and_globals() {
968 let g = group(
969 &["const parseUser = z.string(); parseUser(z.number()); parseUser.or(z.string());"],
970 4,
971 );
972 assert_eq!(dominant_identifier(&g).as_deref(), Some("parseUser"));
973 let only_keywords = group(&["const x: string = y as string; return x as any;"], 3);
974 assert_eq!(dominant_identifier(&only_keywords), None);
975 let g_global = group(&["Math.max(Math.floor(Math.abs(v)), 0)"], 3);
976 assert_eq!(dominant_identifier(&g_global), None);
977 }
978
979 #[test]
980 fn dominant_identifier_none_on_single_letter_type_param() {
981 let g = group(
982 &["function id<T>(x: T): T { const a: T = x; return a as T; }"],
983 3,
984 );
985 assert_eq!(dominant_identifier(&g), None);
986 }
987
988 #[test]
989 fn dominant_identifier_none_on_tie() {
990 let g = group(&["alpha(); beta();"], 2); assert_eq!(dominant_identifier(&g), None);
992 }
993
994 #[test]
995 fn dominant_identifier_prefers_structured_names() {
996 let g = group(
997 &["parseSchema(input); parseSchema(cache); helper(); helper();"],
998 3,
999 );
1000 assert_eq!(dominant_identifier(&g).as_deref(), Some("parseSchema"));
1001 }
1002
1003 #[test]
1004 fn dominant_identifier_requires_plain_token_margin() {
1005 let low_signal = group(&["schema(); schema(); parseUser();"], 3);
1006 assert_eq!(dominant_identifier(&low_signal), None);
1007
1008 let strong = group(&["schema(); schema(); schema(); schema(); parseUser();"], 3);
1009 assert_eq!(dominant_identifier(&strong).as_deref(), Some("schema"));
1010 }
1011
1012 #[test]
1013 fn dominant_identifier_is_stable_across_word_order() {
1014 let first = group(
1015 &["helper(); parseSchema(input); helper(); parseSchema(cache);"],
1016 3,
1017 );
1018 let second = group(
1019 &["parseSchema(input); helper(); parseSchema(cache); helper();"],
1020 3,
1021 );
1022
1023 assert_eq!(dominant_identifier(&first), dominant_identifier(&second));
1024 assert_eq!(dominant_identifier(&first).as_deref(), Some("parseSchema"));
1025 }
1026}