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