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);
832 assert_eq!(filtered.clone_groups.len(), 1);
833 assert!(
834 filtered.clone_groups[0]
835 .instances
836 .iter()
837 .any(|instance| instance.file == remaining_location)
838 );
839 assert!(
840 filter_new_clone_groups(relocated.clone(), &baseline, root)
841 .clone_groups
842 .is_empty()
843 );
844
845 let legacy = reviewed.replacen("-r", "-", 1);
846 super::super::apply_ignored_clones_filter(&mut relocated, std::slice::from_ref(&legacy));
847 assert_eq!(relocated.clone_groups.len(), original.clone_groups.len());
848 partial_baseline.normalized_clone_fingerprints = vec![legacy];
849 let filtered = filter_new_clone_groups(relocated, &partial_baseline, root);
852 assert_eq!(filtered.clone_groups.len(), original.clone_groups.len());
853 }
854
855 #[test]
856 fn fingerprint_is_stable_and_prefixed() {
857 let g = group(&["foo(bar)", "foo(baz)"], 3);
858 let fp1 = clone_fingerprint(&g.instances);
859 let fp2 = clone_fingerprint(&g.instances);
860 assert_eq!(fp1, fp2);
861 assert!(fp1.starts_with("dup:"));
862 assert_eq!(fp1.len(), "dup:".len() + 8);
863 }
864
865 #[test]
866 fn compact_fingerprint_key_is_independent_of_instance_order() {
867 let mut original = group(&["alpha()", "beta()"], 3);
868 original.instances[0].file = PathBuf::from("src/a.ts");
869 original.instances[0].start_line = 2;
870 original.instances[1].file = PathBuf::from("src/b.ts");
871 original.instances[1].start_line = 8;
872 let mut reordered = original.clone();
873 reordered.instances.reverse();
874
875 assert_eq!(
876 CloneFingerprintKey::from_group(&original),
877 CloneFingerprintKey::from_group(&reordered)
878 );
879 }
880
881 #[test]
882 fn duplicate_fragments_are_deduplicated_before_tokenization() {
883 let mut clones = group(&["alpha()", "alpha()", "alpha()"], 2);
884 clones.instances[0].file = PathBuf::from("src/a.ts");
885 clones.instances[1].file = PathBuf::from("src/b.ts");
886 clones.instances[2].file = PathBuf::from("src/c.ts");
887 assert_eq!(distinct_fragment_inputs(&clones.instances).len(), 1);
888
889 clones.instances[2].file = PathBuf::from("src/c.css");
890 assert_eq!(
891 distinct_fragment_inputs(&clones.instances).len(),
892 2,
893 "equal source still needs separate tokenization when syntax differs"
894 );
895 }
896
897 #[test]
898 fn fingerprint_is_sibling_stable() {
899 let group_a = group(&["computeInvoiceTotal(order)", "computeInvoiceTotal(o)"], 4);
900 let before = clone_fingerprint(&group_a.instances);
901 let _group_b_edited = group(&["totallyDifferentBody()"], 2);
902 let after = clone_fingerprint(&group_a.instances);
903 assert_eq!(before, after);
904 }
905
906 #[test]
907 fn fingerprint_differs_for_different_content() {
908 let a = group(&["alpha()"], 2);
909 let b = group(&["beta()"], 2);
910 assert_ne!(
911 clone_fingerprint(&a.instances),
912 clone_fingerprint(&b.instances)
913 );
914 }
915
916 #[test]
917 fn fingerprint_ignores_formatting_comments_and_line_endings() {
918 let compact = group(
919 &["const total = left + right;", "const total = left + right;"],
920 2,
921 );
922 let formatted = group(
923 &[
924 "const total=left + right; // reviewed\r\n",
925 "/* reviewed */\nconst total = left + right;",
926 ],
927 3,
928 );
929
930 assert_eq!(
931 clone_fingerprint(&compact.instances),
932 clone_fingerprint(&formatted.instances)
933 );
934 }
935
936 #[test]
937 fn fingerprint_changes_when_any_distinct_instance_changes_tokens() {
938 let reviewed = group(&["alpha()", "alpha()"], 2);
939 let edited = group(&["alpha()", "beta()"], 2);
940
941 assert_ne!(
942 clone_fingerprint(&reviewed.instances),
943 clone_fingerprint(&edited.instances)
944 );
945 }
946
947 #[test]
948 fn fingerprint_is_independent_of_instance_order_and_count() {
949 let two = group(&["alpha()", "beta()"], 2);
950 let three_reordered = group(&["beta()", "alpha()", "alpha()"], 2);
951
952 assert_eq!(
953 clone_fingerprint(&two.instances),
954 clone_fingerprint(&three_reordered.instances)
955 );
956
957 let two_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&two));
958 let three_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&three_reordered));
959 assert_ne!(
960 two_set.ignored_clone_key_for_group(&two),
961 three_set.ignored_clone_key_for_group(&three_reordered)
962 );
963 }
964
965 #[test]
966 fn fingerprint_set_widens_only_colliding_short_handles() {
967 let a = group(&["alpha()"], 2);
968 let b = group(&["beta()"], 2);
969 let c = group(&["gamma()"], 2);
970 let entries = vec![
971 (&a, 0x0000_0001_1234_5678_u64),
972 (&b, 0x0000_0002_1234_5678_u64),
973 (&c, 0x0000_0003_8765_4321_u64),
974 ];
975
976 let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
977
978 assert_eq!(
979 fingerprints.fingerprint_for_group(&a),
980 "dup:0000000112345678"
981 );
982 assert_eq!(
983 fingerprints.fingerprint_for_group(&b),
984 "dup:0000000212345678"
985 );
986 assert_eq!(fingerprints.fingerprint_for_group(&c), "dup:87654321");
987 assert!(
988 fingerprints
989 .find_group(&[a.clone(), b.clone(), c.clone()], "dup:12345678")
990 .is_none()
991 );
992 assert_eq!(
993 fingerprints
994 .find_group(&[a, b, c], "dup:0000000212345678")
995 .and_then(|group| group.instances.first())
996 .map(|inst| inst.fragment.as_str()),
997 Some("beta()")
998 );
999 }
1000
1001 #[test]
1002 fn fingerprint_set_suffixes_full_hash_collisions() {
1003 let a = group(&["alpha()"], 2);
1004 let b = group(&["beta()"], 2);
1005 let mut entries = vec![
1006 (&a, 0x0000_0001_1234_5678_u64),
1007 (&b, 0x0000_0001_1234_5678_u64),
1008 ];
1009
1010 let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
1011 entries.reverse();
1012 let reversed_fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
1013
1014 let a_fingerprint = fingerprints.fingerprint_for_group(&a);
1015 let b_fingerprint = fingerprints.fingerprint_for_group(&b);
1016 assert_eq!(
1017 a_fingerprint,
1018 reversed_fingerprints.fingerprint_for_group(&a)
1019 );
1020 assert_eq!(
1021 b_fingerprint,
1022 reversed_fingerprints.fingerprint_for_group(&b)
1023 );
1024 let mut assigned = vec![a_fingerprint, b_fingerprint];
1025 assigned.sort_unstable();
1026 assert_eq!(
1027 assigned,
1028 ["dup:0000000112345678-r1", "dup:0000000112345678-r2"]
1029 );
1030 assert!(
1031 fingerprints
1032 .find_group(&[a.clone(), b.clone()], "dup:12345678")
1033 .is_none()
1034 );
1035 assert!(
1036 fingerprints
1037 .find_group(&[a, b], "dup:0000000112345678")
1038 .is_none()
1039 );
1040 }
1041
1042 #[test]
1043 fn group_suggestion_savings_is_lines_times_extra_copies() {
1044 let g = group(&["x", "x", "x"], 10); let suggestion = group_refactoring_suggestion(&g);
1046 assert_eq!(suggestion.kind, RefactoringKind::ExtractFunction);
1047 assert_eq!(suggestion.estimated_savings, 20); }
1049
1050 #[test]
1051 fn dominant_identifier_picks_repeated_domain_name() {
1052 let g = group(
1053 &["function buildInvoice(invoice) { return invoice.total + invoice.tax; }"],
1054 3,
1055 );
1056 assert_eq!(dominant_identifier(&g).as_deref(), Some("invoice"));
1057 }
1058
1059 #[test]
1060 fn dominant_identifier_none_on_generic() {
1061 let g = group(&["const data = result.map((item) => item.value);"], 3);
1062 assert_eq!(dominant_identifier(&g), None);
1063 }
1064
1065 #[test]
1066 fn dominant_identifier_skips_ts_primitive_keywords_and_globals() {
1067 let g = group(
1068 &["const parseUser = z.string(); parseUser(z.number()); parseUser.or(z.string());"],
1069 4,
1070 );
1071 assert_eq!(dominant_identifier(&g).as_deref(), Some("parseUser"));
1072 let only_keywords = group(&["const x: string = y as string; return x as any;"], 3);
1073 assert_eq!(dominant_identifier(&only_keywords), None);
1074 let g_global = group(&["Math.max(Math.floor(Math.abs(v)), 0)"], 3);
1075 assert_eq!(dominant_identifier(&g_global), None);
1076 }
1077
1078 #[test]
1079 fn dominant_identifier_none_on_single_letter_type_param() {
1080 let g = group(
1081 &["function id<T>(x: T): T { const a: T = x; return a as T; }"],
1082 3,
1083 );
1084 assert_eq!(dominant_identifier(&g), None);
1085 }
1086
1087 #[test]
1088 fn dominant_identifier_none_on_tie() {
1089 let g = group(&["alpha(); beta();"], 2); assert_eq!(dominant_identifier(&g), None);
1091 }
1092
1093 #[test]
1094 fn dominant_identifier_prefers_structured_names() {
1095 let g = group(
1096 &["parseSchema(input); parseSchema(cache); helper(); helper();"],
1097 3,
1098 );
1099 assert_eq!(dominant_identifier(&g).as_deref(), Some("parseSchema"));
1100 }
1101
1102 #[test]
1103 fn dominant_identifier_requires_plain_token_margin() {
1104 let low_signal = group(&["schema(); schema(); parseUser();"], 3);
1105 assert_eq!(dominant_identifier(&low_signal), None);
1106
1107 let strong = group(&["schema(); schema(); schema(); schema(); parseUser();"], 3);
1108 assert_eq!(dominant_identifier(&strong).as_deref(), Some("schema"));
1109 }
1110
1111 #[test]
1112 fn dominant_identifier_is_stable_across_word_order() {
1113 let first = group(
1114 &["helper(); parseSchema(input); helper(); parseSchema(cache);"],
1115 3,
1116 );
1117 let second = group(
1118 &["parseSchema(input); helper(); parseSchema(cache); helper();"],
1119 3,
1120 );
1121
1122 assert_eq!(dominant_identifier(&first), dominant_identifier(&second));
1123 assert_eq!(dominant_identifier(&first).as_deref(), Some("parseSchema"));
1124 }
1125}