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 is_symlink: false,
608 file: PathBuf::from("a.ts"),
609 start_line: 1,
610 end_line: 5,
611 start_col: 0,
612 end_col: 0,
613 fragment: fragment.to_string(),
614 }
615 }
616
617 fn group(fragments: &[&str], line_count: usize) -> CloneGroup {
618 CloneGroup {
619 instances: fragments.iter().map(|f| instance(f)).collect(),
620 token_count: 40,
621 line_count,
622 similarity: None,
623 }
624 }
625
626 fn relocated_collision_groups(root: &Path) -> Vec<CloneGroup> {
627 ["src", "other"]
628 .into_iter()
629 .map(|directory| {
630 let mut clone = group(&["alpha()", "alpha()"], 2);
631 for (index, instance) in clone.instances.iter_mut().enumerate() {
632 instance.file = root.join(directory).join(format!("file-{index}.ts"));
633 }
634 clone
635 })
636 .collect()
637 }
638
639 #[test]
640 fn fingerprint_set_relocation_preserves_collision_identity() {
641 const RELOCATIONS: usize = 32;
642 let groups = relocated_collision_groups(Path::new("/original/checkout"));
643 let fingerprints = CloneFingerprintSet::from_groups(&groups);
644 let expected = fingerprints.fingerprint_for_group(&groups[0]);
645 for index in 0..RELOCATIONS {
646 let root = PathBuf::from(format!("/different/checkout-{index}/with spaces/café"));
647 let mut relocated = relocated_collision_groups(&root);
648 relocated.reverse();
649 for group in &mut relocated {
650 group.instances.reverse();
651 }
652 let actual = CloneFingerprintSet::from_groups(&relocated);
653 assert_eq!(
654 actual.fingerprint_for_group(&relocated[1]),
655 expected,
656 "{root:?}"
657 );
658 assert!(std::ptr::eq(
659 actual
660 .find_group(&relocated, &expected)
661 .expect("relocated trace handle resolves"),
662 &raw const relocated[1],
663 ));
664 assert_eq!(
665 actual.fingerprint_for_parts(
666 &relocated[1].instances,
667 relocated[1].token_count,
668 relocated[1].line_count
669 ),
670 expected,
671 );
672 }
673 }
674
675 #[test]
676 fn corrected_collision_handles_do_not_alias_legacy_ordinals() {
677 let groups = relocated_collision_groups(Path::new("/project"));
678 let fingerprints = CloneFingerprintSet::from_groups(&groups);
679 for group in &groups {
680 let corrected = fingerprints.fingerprint_for_group(group);
681 assert!(corrected.contains("-r"));
682 let legacy = corrected.replacen("-r", "-", 1);
683 assert!(fingerprints.find_group(&groups, &legacy).is_none());
684 assert!(fingerprints.find_group(&groups, &corrected).is_some());
685 }
686 }
687
688 #[test]
689 fn relocated_collision_suppression_matches_only_corrected_handles() {
690 use super::super::types::DuplicationReport;
691 use crate::baseline::{DuplicationBaselineData, filter_new_clone_groups};
692
693 let original = DuplicationReport {
694 clone_groups: relocated_collision_groups(Path::new("/original/checkout")),
695 ..Default::default()
696 };
697 let fingerprints = CloneFingerprintSet::from_groups(&original.clone_groups);
698 let reviewed = fingerprints.ignored_clone_key_for_group(&original.clone_groups[0]);
699 let baseline =
700 DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
701 let root = Path::new("/relocated/with spaces/café");
702 let mut relocated = DuplicationReport {
703 clone_groups: relocated_collision_groups(root),
704 ..Default::default()
705 };
706 relocated.clone_groups.reverse();
707 for group in &mut relocated.clone_groups {
708 group.instances.reverse();
709 }
710 let remaining_location = relocated.clone_groups[0].instances[0].file.clone();
711
712 let mut ignored = relocated.clone();
713 super::super::apply_ignored_clones_filter(&mut ignored, std::slice::from_ref(&reviewed));
714 assert_eq!(ignored.clone_groups.len(), 1);
715 assert_eq!(
716 ignored.clone_groups[0].instances[0].file,
717 remaining_location
718 );
719 assert_eq!(ignored.stats.clone_groups_ignored, 1);
720
721 let mut partial_baseline =
722 DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
723 partial_baseline.normalized_clone_fingerprints = vec![reviewed.clone()];
724 let filtered = filter_new_clone_groups(relocated.clone(), &partial_baseline, root);
725 assert_eq!(filtered.clone_groups.len(), 1);
726 assert_eq!(
727 filtered.clone_groups[0].instances[0].file,
728 remaining_location
729 );
730 assert!(
731 filter_new_clone_groups(relocated.clone(), &baseline, root)
732 .clone_groups
733 .is_empty()
734 );
735
736 let legacy = reviewed.replacen("-r", "-", 1);
737 super::super::apply_ignored_clones_filter(&mut relocated, std::slice::from_ref(&legacy));
738 assert_eq!(relocated.clone_groups.len(), original.clone_groups.len());
739 partial_baseline.normalized_clone_fingerprints = vec![legacy];
740 let filtered = filter_new_clone_groups(relocated, &partial_baseline, root);
743 assert_eq!(filtered.clone_groups.len(), original.clone_groups.len());
744 }
745
746 #[test]
747 fn fingerprint_is_stable_and_prefixed() {
748 let g = group(&["foo(bar)", "foo(baz)"], 3);
749 let fp1 = clone_fingerprint(&g.instances);
750 let fp2 = clone_fingerprint(&g.instances);
751 assert_eq!(fp1, fp2);
752 assert!(fp1.starts_with("dup:"));
753 assert_eq!(fp1.len(), "dup:".len() + 8);
754 }
755
756 #[test]
757 fn compact_fingerprint_key_is_independent_of_instance_order() {
758 let mut original = group(&["alpha()", "beta()"], 3);
759 original.instances[0].file = PathBuf::from("src/a.ts");
760 original.instances[0].start_line = 2;
761 original.instances[1].file = PathBuf::from("src/b.ts");
762 original.instances[1].start_line = 8;
763 let mut reordered = original.clone();
764 reordered.instances.reverse();
765
766 assert_eq!(
767 CloneFingerprintKey::from_group(&original),
768 CloneFingerprintKey::from_group(&reordered)
769 );
770 }
771
772 #[test]
773 fn duplicate_fragments_are_deduplicated_before_tokenization() {
774 let mut clones = group(&["alpha()", "alpha()", "alpha()"], 2);
775 clones.instances[0].file = PathBuf::from("src/a.ts");
776 clones.instances[1].file = PathBuf::from("src/b.ts");
777 clones.instances[2].file = PathBuf::from("src/c.ts");
778 assert_eq!(distinct_fragment_inputs(&clones.instances).len(), 1);
779
780 clones.instances[2].file = PathBuf::from("src/c.css");
781 assert_eq!(
782 distinct_fragment_inputs(&clones.instances).len(),
783 2,
784 "equal source still needs separate tokenization when syntax differs"
785 );
786 }
787
788 #[test]
789 fn fingerprint_is_sibling_stable() {
790 let group_a = group(&["computeInvoiceTotal(order)", "computeInvoiceTotal(o)"], 4);
791 let before = clone_fingerprint(&group_a.instances);
792 let _group_b_edited = group(&["totallyDifferentBody()"], 2);
793 let after = clone_fingerprint(&group_a.instances);
794 assert_eq!(before, after);
795 }
796
797 #[test]
798 fn fingerprint_differs_for_different_content() {
799 let a = group(&["alpha()"], 2);
800 let b = group(&["beta()"], 2);
801 assert_ne!(
802 clone_fingerprint(&a.instances),
803 clone_fingerprint(&b.instances)
804 );
805 }
806
807 #[test]
808 fn fingerprint_ignores_formatting_comments_and_line_endings() {
809 let compact = group(
810 &["const total = left + right;", "const total = left + right;"],
811 2,
812 );
813 let formatted = group(
814 &[
815 "const total=left + right; // reviewed\r\n",
816 "/* reviewed */\nconst total = left + right;",
817 ],
818 3,
819 );
820
821 assert_eq!(
822 clone_fingerprint(&compact.instances),
823 clone_fingerprint(&formatted.instances)
824 );
825 }
826
827 #[test]
828 fn fingerprint_changes_when_any_distinct_instance_changes_tokens() {
829 let reviewed = group(&["alpha()", "alpha()"], 2);
830 let edited = group(&["alpha()", "beta()"], 2);
831
832 assert_ne!(
833 clone_fingerprint(&reviewed.instances),
834 clone_fingerprint(&edited.instances)
835 );
836 }
837
838 #[test]
839 fn fingerprint_is_independent_of_instance_order_and_count() {
840 let two = group(&["alpha()", "beta()"], 2);
841 let three_reordered = group(&["beta()", "alpha()", "alpha()"], 2);
842
843 assert_eq!(
844 clone_fingerprint(&two.instances),
845 clone_fingerprint(&three_reordered.instances)
846 );
847
848 let two_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&two));
849 let three_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&three_reordered));
850 assert_ne!(
851 two_set.ignored_clone_key_for_group(&two),
852 three_set.ignored_clone_key_for_group(&three_reordered)
853 );
854 }
855
856 #[test]
857 fn fingerprint_set_widens_only_colliding_short_handles() {
858 let a = group(&["alpha()"], 2);
859 let b = group(&["beta()"], 2);
860 let c = group(&["gamma()"], 2);
861 let entries = vec![
862 (&a, 0x0000_0001_1234_5678_u64),
863 (&b, 0x0000_0002_1234_5678_u64),
864 (&c, 0x0000_0003_8765_4321_u64),
865 ];
866
867 let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
868
869 assert_eq!(
870 fingerprints.fingerprint_for_group(&a),
871 "dup:0000000112345678"
872 );
873 assert_eq!(
874 fingerprints.fingerprint_for_group(&b),
875 "dup:0000000212345678"
876 );
877 assert_eq!(fingerprints.fingerprint_for_group(&c), "dup:87654321");
878 assert!(
879 fingerprints
880 .find_group(&[a.clone(), b.clone(), c.clone()], "dup:12345678")
881 .is_none()
882 );
883 assert_eq!(
884 fingerprints
885 .find_group(&[a, b, c], "dup:0000000212345678")
886 .and_then(|group| group.instances.first())
887 .map(|inst| inst.fragment.as_str()),
888 Some("beta()")
889 );
890 }
891
892 #[test]
893 fn fingerprint_set_suffixes_full_hash_collisions() {
894 let a = group(&["alpha()"], 2);
895 let b = group(&["beta()"], 2);
896 let mut entries = vec![
897 (&a, 0x0000_0001_1234_5678_u64),
898 (&b, 0x0000_0001_1234_5678_u64),
899 ];
900
901 let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
902 entries.reverse();
903 let reversed_fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
904
905 let a_fingerprint = fingerprints.fingerprint_for_group(&a);
906 let b_fingerprint = fingerprints.fingerprint_for_group(&b);
907 assert_eq!(
908 a_fingerprint,
909 reversed_fingerprints.fingerprint_for_group(&a)
910 );
911 assert_eq!(
912 b_fingerprint,
913 reversed_fingerprints.fingerprint_for_group(&b)
914 );
915 let mut assigned = vec![a_fingerprint, b_fingerprint];
916 assigned.sort_unstable();
917 assert_eq!(
918 assigned,
919 ["dup:0000000112345678-r1", "dup:0000000112345678-r2"]
920 );
921 assert!(
922 fingerprints
923 .find_group(&[a.clone(), b.clone()], "dup:12345678")
924 .is_none()
925 );
926 assert!(
927 fingerprints
928 .find_group(&[a, b], "dup:0000000112345678")
929 .is_none()
930 );
931 }
932
933 #[test]
934 fn group_suggestion_savings_is_lines_times_extra_copies() {
935 let g = group(&["x", "x", "x"], 10); let suggestion = group_refactoring_suggestion(&g);
937 assert_eq!(suggestion.kind, RefactoringKind::ExtractFunction);
938 assert_eq!(suggestion.estimated_savings, 20); }
940
941 #[test]
942 fn dominant_identifier_picks_repeated_domain_name() {
943 let g = group(
944 &["function buildInvoice(invoice) { return invoice.total + invoice.tax; }"],
945 3,
946 );
947 assert_eq!(dominant_identifier(&g).as_deref(), Some("invoice"));
948 }
949
950 #[test]
951 fn dominant_identifier_none_on_generic() {
952 let g = group(&["const data = result.map((item) => item.value);"], 3);
953 assert_eq!(dominant_identifier(&g), None);
954 }
955
956 #[test]
957 fn dominant_identifier_skips_ts_primitive_keywords_and_globals() {
958 let g = group(
959 &["const parseUser = z.string(); parseUser(z.number()); parseUser.or(z.string());"],
960 4,
961 );
962 assert_eq!(dominant_identifier(&g).as_deref(), Some("parseUser"));
963 let only_keywords = group(&["const x: string = y as string; return x as any;"], 3);
964 assert_eq!(dominant_identifier(&only_keywords), None);
965 let g_global = group(&["Math.max(Math.floor(Math.abs(v)), 0)"], 3);
966 assert_eq!(dominant_identifier(&g_global), None);
967 }
968
969 #[test]
970 fn dominant_identifier_none_on_single_letter_type_param() {
971 let g = group(
972 &["function id<T>(x: T): T { const a: T = x; return a as T; }"],
973 3,
974 );
975 assert_eq!(dominant_identifier(&g), None);
976 }
977
978 #[test]
979 fn dominant_identifier_none_on_tie() {
980 let g = group(&["alpha(); beta();"], 2); assert_eq!(dominant_identifier(&g), None);
982 }
983
984 #[test]
985 fn dominant_identifier_prefers_structured_names() {
986 let g = group(
987 &["parseSchema(input); parseSchema(cache); helper(); helper();"],
988 3,
989 );
990 assert_eq!(dominant_identifier(&g).as_deref(), Some("parseSchema"));
991 }
992
993 #[test]
994 fn dominant_identifier_requires_plain_token_margin() {
995 let low_signal = group(&["schema(); schema(); parseUser();"], 3);
996 assert_eq!(dominant_identifier(&low_signal), None);
997
998 let strong = group(&["schema(); schema(); schema(); schema(); parseUser();"], 3);
999 assert_eq!(dominant_identifier(&strong).as_deref(), Some("schema"));
1000 }
1001
1002 #[test]
1003 fn dominant_identifier_is_stable_across_word_order() {
1004 let first = group(
1005 &["helper(); parseSchema(input); helper(); parseSchema(cache);"],
1006 3,
1007 );
1008 let second = group(
1009 &["parseSchema(input); helper(); parseSchema(cache); helper();"],
1010 3,
1011 );
1012
1013 assert_eq!(dominant_identifier(&first), dominant_identifier(&second));
1014 assert_eq!(dominant_identifier(&first).as_deref(), Some("parseSchema"));
1015 }
1016}