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)]
33pub struct CloneFingerprintKey {
34 fragments_digest: u128,
35 locations_digest: u128,
36 token_count: usize,
37 line_count: usize,
38 instance_count: usize,
39}
40
41const _: () = assert!(std::mem::size_of::<CloneFingerprintKey>() <= 64);
42
43impl CloneFingerprintKey {
44 #[must_use]
46 fn from_parts(instances: &[CloneInstance], token_count: usize, line_count: usize) -> Self {
47 Self {
48 fragments_digest: hash_distinct_fragments(instances),
49 locations_digest: hash_sorted_locations(instances),
50 token_count,
51 line_count,
52 instance_count: instances.len(),
53 }
54 }
55
56 fn from_group(group: &CloneGroup) -> Self {
57 Self::from_parts(&group.instances, group.token_count, group.line_count)
58 }
59}
60
61#[derive(Debug, Clone)]
68pub struct CloneFingerprintSet {
69 by_key: FxHashMap<CloneFingerprintKey, String>,
70 key_by_fingerprint: FxHashMap<String, CloneFingerprintKey>,
71}
72
73impl CloneFingerprintSet {
74 #[must_use]
76 pub fn from_groups(groups: &[CloneGroup]) -> Self {
77 let entries: Vec<_> = groups
78 .iter()
79 .map(|group| {
80 let key = CloneFingerprintKey::from_group(group);
81 let hash = hash_instances(&group.instances);
82 (key, hash)
83 })
84 .collect();
85 Self::from_hashed_entries(&entries)
86 }
87
88 #[must_use]
90 pub fn fingerprint_for_group(&self, group: &CloneGroup) -> String {
91 self.by_key
92 .get(&CloneFingerprintKey::from_group(group))
93 .cloned()
94 .unwrap_or_else(|| clone_fingerprint(&group.instances))
95 }
96
97 #[must_use]
99 pub fn ignored_clone_key_for_group(&self, group: &CloneGroup) -> String {
100 format!(
101 "{}:{}",
102 self.fingerprint_for_group(group),
103 group.instances.len()
104 )
105 }
106
107 #[must_use]
109 pub fn fingerprint_for_parts(
110 &self,
111 instances: &[CloneInstance],
112 token_count: usize,
113 line_count: usize,
114 ) -> String {
115 let key = CloneFingerprintKey::from_parts(instances, token_count, line_count);
116 self.by_key
117 .get(&key)
118 .cloned()
119 .unwrap_or_else(|| clone_fingerprint(instances))
120 }
121
122 #[must_use]
128 pub fn find_group<'a>(
129 &self,
130 groups: &'a [CloneGroup],
131 fingerprint: &str,
132 ) -> Option<&'a CloneGroup> {
133 let key = self.key_by_fingerprint.get(fingerprint)?;
134 groups
135 .iter()
136 .find(|group| CloneFingerprintKey::from_group(group) == *key)
137 }
138
139 fn from_hashed_entries(entries: &[(CloneFingerprintKey, u64)]) -> Self {
140 let mut short_counts: FxHashMap<u32, usize> = FxHashMap::default();
141 let mut full_counts: FxHashMap<u64, usize> = FxHashMap::default();
142 for (_, hash) in entries {
143 *short_counts.entry(*hash as u32).or_insert(0) += 1;
144 *full_counts.entry(*hash).or_insert(0) += 1;
145 }
146
147 let mut sorted_entries = entries.to_vec();
148 sorted_entries.sort_unstable_by(|(left_key, left_hash), (right_key, right_hash)| {
149 left_key
150 .cmp(right_key)
151 .then_with(|| left_hash.cmp(right_hash))
152 });
153
154 let mut full_ordinals: FxHashMap<u64, usize> = FxHashMap::default();
155 let mut ambiguous_short_handles: FxHashSet<String> = FxHashSet::default();
156 let mut by_key = FxHashMap::default();
157 let mut key_by_fingerprint = FxHashMap::default();
158
159 for (key, hash) in &sorted_entries {
160 let short = *hash as u32;
161 let short_handle = format!("{FINGERPRINT_PREFIX}{short:08x}");
162 let fingerprint = if short_counts.get(&short).copied().unwrap_or(0) == 1 {
163 short_handle
164 } else {
165 ambiguous_short_handles.insert(short_handle);
166 let full_handle = format!("{FINGERPRINT_PREFIX}{hash:016x}");
167 if full_counts.get(hash).copied().unwrap_or(0) == 1 {
168 full_handle
169 } else {
170 let ordinal = full_ordinals.entry(*hash).or_insert(0);
171 *ordinal += 1;
172 format!("{full_handle}-{ordinal}")
173 }
174 };
175
176 key_by_fingerprint.insert(fingerprint.clone(), key.clone());
177 by_key.insert(key.clone(), fingerprint);
178 }
179
180 for handle in ambiguous_short_handles {
181 key_by_fingerprint.remove(&handle);
182 }
183
184 Self {
185 by_key,
186 key_by_fingerprint,
187 }
188 }
189}
190
191#[must_use]
208pub fn clone_fingerprint(instances: &[CloneInstance]) -> String {
209 fingerprint_for_hash(hash_instances(instances))
210}
211
212#[must_use]
218pub fn fingerprint_for_fragment(fragment: &str) -> String {
219 let sequence = normalized_fragment_sequence(Path::new("fragment.ts"), fragment);
220 fingerprint_for_hash(hash_normalized_sequences(&[sequence]))
221}
222
223fn hash_distinct_fragments(instances: &[CloneInstance]) -> u128 {
224 let mut fragments = instances
225 .iter()
226 .map(|instance| instance.fragment.as_str())
227 .collect::<Vec<_>>();
228 fragments.sort_unstable();
229 fragments.dedup();
230
231 let mut hasher = Xxh3::new();
232 for fragment in fragments {
233 update_hash_bytes(&mut hasher, fragment.as_bytes());
234 }
235 hasher.digest128()
236}
237
238fn hash_sorted_locations(instances: &[CloneInstance]) -> u128 {
239 let mut locations = instances
240 .iter()
241 .map(|instance| {
242 (
243 instance.file.as_path(),
244 instance.start_line,
245 instance.end_line,
246 )
247 })
248 .collect::<Vec<_>>();
249 locations.sort_unstable();
250
251 let mut hasher = Xxh3::new();
252 for (path, start_line, end_line) in locations {
253 update_hash_bytes(&mut hasher, path.as_os_str().as_encoded_bytes());
254 hasher.update(&start_line.to_le_bytes());
255 hasher.update(&end_line.to_le_bytes());
256 }
257 hasher.digest128()
258}
259
260fn update_hash_bytes(hasher: &mut Xxh3, bytes: &[u8]) {
261 hasher.update(&bytes.len().to_le_bytes());
262 hasher.update(bytes);
263}
264
265fn hash_instances(instances: &[CloneInstance]) -> u64 {
266 let mut sequences = distinct_fragment_inputs(instances)
267 .into_iter()
268 .map(|(kind, fragment)| normalized_fragment_sequence(kind.path(), fragment))
269 .collect::<Vec<_>>();
270 sequences.sort_unstable();
271 sequences.dedup();
272 hash_normalized_sequences(&sequences)
273}
274
275fn distinct_fragment_inputs(instances: &[CloneInstance]) -> Vec<(FragmentTokenizationKind, &str)> {
276 let mut fragments = instances
277 .iter()
278 .map(|instance| {
279 (
280 fragment_tokenization_kind(
281 &instance.file,
282 FragmentTokenizationStrategy::Fingerprint,
283 ),
284 instance.fragment.as_str(),
285 )
286 })
287 .collect::<Vec<_>>();
288 fragments.sort_unstable();
289 fragments.dedup();
290 fragments
291}
292
293fn normalized_fragment_sequence(path: &Path, fragment: &str) -> Vec<u64> {
294 let tokens = super::tokenize::tokenize_file(path, fragment, false);
295 super::normalize::normalize_and_hash(&tokens.tokens, DetectionMode::Strict)
296 .into_iter()
297 .map(|token| token.hash)
298 .collect()
299}
300
301fn hash_normalized_sequences(sequences: &[Vec<u64>]) -> u64 {
302 let byte_len = sequences
303 .iter()
304 .map(|sequence| sequence.len().saturating_add(1))
305 .sum::<usize>()
306 .saturating_mul(std::mem::size_of::<u64>());
307 let mut bytes = Vec::with_capacity(byte_len);
308 for sequence in sequences {
309 bytes.extend_from_slice(&(sequence.len() as u64).to_le_bytes());
310 for hash in sequence {
311 bytes.extend_from_slice(&hash.to_le_bytes());
312 }
313 }
314 xxh3_64(&bytes)
315}
316
317fn fingerprint_for_hash(hash: u64) -> String {
318 format!("{FINGERPRINT_PREFIX}{:08x}", hash as u32)
319}
320
321#[must_use]
327pub fn group_refactoring_suggestion(group: &CloneGroup) -> RefactoringSuggestion {
328 let estimated_savings = group.line_count * group.instances.len().saturating_sub(1);
329 RefactoringSuggestion {
330 kind: RefactoringKind::ExtractFunction,
331 description: format!(
332 "Extract the shared {}-line block into one function and call it from {} sites",
333 group.line_count,
334 group.instances.len(),
335 ),
336 estimated_savings,
337 }
338}
339
340#[must_use]
349pub fn dominant_identifier(group: &CloneGroup) -> Option<String> {
350 let fragment = group.instances.first().map(|inst| inst.fragment.as_str())?;
351 let mut counts: FxHashMap<&str, usize> = FxHashMap::default();
352 for word in identifier_words(fragment) {
353 if is_generic_identifier(word) {
354 continue;
355 }
356 *counts.entry(word).or_insert(0) += 1;
357 }
358
359 let mut candidates: Vec<_> = counts
360 .into_iter()
361 .map(|(word, count)| IdentifierCandidate {
362 word,
363 count,
364 score: identifier_score(word, count),
365 })
366 .collect();
367 candidates.sort_by(|a, b| {
368 b.score
369 .cmp(&a.score)
370 .then_with(|| b.count.cmp(&a.count))
371 .then_with(|| a.word.cmp(b.word))
372 });
373
374 let best = candidates.first()?;
375 if best.count < 2 {
376 return None;
377 }
378
379 let runner_up = candidates.get(1);
380 if runner_up.is_some_and(|next| best.score.saturating_sub(next.score) < 2) {
381 return None;
382 }
383
384 if is_plain_single_token(best.word) {
385 let next_count = runner_up.map_or(0, |candidate| candidate.count);
386 if best.count < 3 || best.count < next_count + 2 {
387 return None;
388 }
389 }
390
391 Some(best.word.to_string())
392}
393
394#[derive(Debug)]
395struct IdentifierCandidate<'a> {
396 word: &'a str,
397 count: usize,
398 score: usize,
399}
400
401fn identifier_score(word: &str, count: usize) -> usize {
402 let quality_bonus = if has_identifier_separator_or_case_transition(word) {
403 5
404 } else if word.chars().count() >= 8 {
405 2
406 } else {
407 0
408 };
409 count * 5 + quality_bonus
410}
411
412fn is_plain_single_token(word: &str) -> bool {
413 !has_identifier_separator_or_case_transition(word) && word.chars().count() < 8
414}
415
416fn has_identifier_separator_or_case_transition(word: &str) -> bool {
417 if word.contains('_') || word.contains('$') {
418 return true;
419 }
420
421 let mut previous = None;
422 for ch in word.chars() {
423 if previous.is_some_and(|prev: char| prev.is_ascii_lowercase() && ch.is_ascii_uppercase()) {
424 return true;
425 }
426 previous = Some(ch);
427 }
428 false
429}
430
431fn identifier_words(source: &str) -> impl Iterator<Item = &str> {
433 source
434 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$'))
435 .filter(|word| {
436 !word.is_empty()
437 && word
438 .chars()
439 .next()
440 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
441 })
442}
443
444const GENERIC_IDENTIFIERS: &[&str] = &[
447 "data",
448 "result",
449 "results",
450 "item",
451 "items",
452 "value",
453 "values",
454 "val",
455 "obj",
456 "object",
457 "arr",
458 "array",
459 "list",
460 "map",
461 "set",
462 "key",
463 "keys",
464 "tmp",
465 "temp",
466 "acc",
467 "cur",
468 "curr",
469 "prev",
470 "next",
471 "node",
472 "el",
473 "elem",
474 "element",
475 "args",
476 "arg",
477 "opts",
478 "options",
479 "params",
480 "param",
481 "props",
482 "ctx",
483 "context",
484 "res",
485 "req",
486 "err",
487 "error",
488 "fn",
489 "cb",
490 "callback",
491 "out",
492 "input",
493 "output",
494 "name",
495 "id",
496 "index",
497 "idx",
498 "x",
499 "y",
500 "z",
501 "i",
502 "j",
503 "k",
504 "n",
505 "m",
506 "a",
507 "b",
508 "c",
509 "e",
510 "_",
511 "const",
512 "let",
513 "var",
514 "function",
515 "return",
516 "if",
517 "else",
518 "for",
519 "while",
520 "do",
521 "switch",
522 "case",
523 "break",
524 "continue",
525 "new",
526 "this",
527 "true",
528 "false",
529 "null",
530 "undefined",
531 "void",
532 "typeof",
533 "instanceof",
534 "in",
535 "of",
536 "class",
537 "extends",
538 "super",
539 "import",
540 "export",
541 "from",
542 "default",
543 "async",
544 "await",
545 "yield",
546 "type",
547 "interface",
548 "enum",
549 "as",
550 "is",
551 "keyof",
552 "readonly",
553 "public",
554 "private",
555 "protected",
556 "static",
557 "get",
558 "delete",
559 "throw",
560 "try",
561 "catch",
562 "finally",
563 "string",
564 "number",
565 "boolean",
566 "any",
567 "unknown",
568 "never",
569 "bigint",
570 "symbol",
571 "Math",
572 "JSON",
573 "Object",
574 "Array",
575 "Promise",
576 "BigInt",
577 "Number",
578 "String",
579 "Boolean",
580 "Symbol",
581 "RegExp",
582 "Date",
583];
584
585fn is_generic_identifier(word: &str) -> bool {
586 word.chars().count() == 1 || GENERIC_IDENTIFIERS.contains(&word)
587}
588
589#[cfg(test)]
590mod tests {
591 use std::path::PathBuf;
592
593 use super::*;
594
595 fn instance(fragment: &str) -> CloneInstance {
596 CloneInstance {
597 file: PathBuf::from("a.ts"),
598 start_line: 1,
599 end_line: 5,
600 start_col: 0,
601 end_col: 0,
602 fragment: fragment.to_string(),
603 }
604 }
605
606 fn group(fragments: &[&str], line_count: usize) -> CloneGroup {
607 CloneGroup {
608 instances: fragments.iter().map(|f| instance(f)).collect(),
609 token_count: 40,
610 line_count,
611 similarity: None,
612 }
613 }
614
615 #[test]
616 fn fingerprint_is_stable_and_prefixed() {
617 let g = group(&["foo(bar)", "foo(baz)"], 3);
618 let fp1 = clone_fingerprint(&g.instances);
619 let fp2 = clone_fingerprint(&g.instances);
620 assert_eq!(fp1, fp2);
621 assert!(fp1.starts_with("dup:"));
622 assert_eq!(fp1.len(), "dup:".len() + 8);
623 }
624
625 #[test]
626 fn compact_fingerprint_key_is_independent_of_instance_order() {
627 let mut original = group(&["alpha()", "beta()"], 3);
628 original.instances[0].file = PathBuf::from("src/a.ts");
629 original.instances[0].start_line = 2;
630 original.instances[1].file = PathBuf::from("src/b.ts");
631 original.instances[1].start_line = 8;
632 let mut reordered = original.clone();
633 reordered.instances.reverse();
634
635 assert_eq!(
636 CloneFingerprintKey::from_group(&original),
637 CloneFingerprintKey::from_group(&reordered)
638 );
639 }
640
641 #[test]
642 fn duplicate_fragments_are_deduplicated_before_tokenization() {
643 let mut clones = group(&["alpha()", "alpha()", "alpha()"], 2);
644 clones.instances[0].file = PathBuf::from("src/a.ts");
645 clones.instances[1].file = PathBuf::from("src/b.ts");
646 clones.instances[2].file = PathBuf::from("src/c.ts");
647 assert_eq!(distinct_fragment_inputs(&clones.instances).len(), 1);
648
649 clones.instances[2].file = PathBuf::from("src/c.css");
650 assert_eq!(
651 distinct_fragment_inputs(&clones.instances).len(),
652 2,
653 "equal source still needs separate tokenization when syntax differs"
654 );
655 }
656
657 #[test]
658 fn fingerprint_is_sibling_stable() {
659 let group_a = group(&["computeInvoiceTotal(order)", "computeInvoiceTotal(o)"], 4);
660 let before = clone_fingerprint(&group_a.instances);
661 let _group_b_edited = group(&["totallyDifferentBody()"], 2);
662 let after = clone_fingerprint(&group_a.instances);
663 assert_eq!(before, after);
664 }
665
666 #[test]
667 fn fingerprint_differs_for_different_content() {
668 let a = group(&["alpha()"], 2);
669 let b = group(&["beta()"], 2);
670 assert_ne!(
671 clone_fingerprint(&a.instances),
672 clone_fingerprint(&b.instances)
673 );
674 }
675
676 #[test]
677 fn fingerprint_ignores_formatting_comments_and_line_endings() {
678 let compact = group(
679 &["const total = left + right;", "const total = left + right;"],
680 2,
681 );
682 let formatted = group(
683 &[
684 "const total=left + right; // reviewed\r\n",
685 "/* reviewed */\nconst total = left + right;",
686 ],
687 3,
688 );
689
690 assert_eq!(
691 clone_fingerprint(&compact.instances),
692 clone_fingerprint(&formatted.instances)
693 );
694 }
695
696 #[test]
697 fn fingerprint_changes_when_any_distinct_instance_changes_tokens() {
698 let reviewed = group(&["alpha()", "alpha()"], 2);
699 let edited = group(&["alpha()", "beta()"], 2);
700
701 assert_ne!(
702 clone_fingerprint(&reviewed.instances),
703 clone_fingerprint(&edited.instances)
704 );
705 }
706
707 #[test]
708 fn fingerprint_is_independent_of_instance_order_and_count() {
709 let two = group(&["alpha()", "beta()"], 2);
710 let three_reordered = group(&["beta()", "alpha()", "alpha()"], 2);
711
712 assert_eq!(
713 clone_fingerprint(&two.instances),
714 clone_fingerprint(&three_reordered.instances)
715 );
716
717 let two_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&two));
718 let three_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&three_reordered));
719 assert_ne!(
720 two_set.ignored_clone_key_for_group(&two),
721 three_set.ignored_clone_key_for_group(&three_reordered)
722 );
723 }
724
725 #[test]
726 fn fingerprint_set_widens_only_colliding_short_handles() {
727 let a = group(&["alpha()"], 2);
728 let b = group(&["beta()"], 2);
729 let c = group(&["gamma()"], 2);
730 let entries = vec![
731 (
732 CloneFingerprintKey::from_group(&a),
733 0x0000_0001_1234_5678_u64,
734 ),
735 (
736 CloneFingerprintKey::from_group(&b),
737 0x0000_0002_1234_5678_u64,
738 ),
739 (
740 CloneFingerprintKey::from_group(&c),
741 0x0000_0003_8765_4321_u64,
742 ),
743 ];
744
745 let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
746
747 assert_eq!(
748 fingerprints.fingerprint_for_group(&a),
749 "dup:0000000112345678"
750 );
751 assert_eq!(
752 fingerprints.fingerprint_for_group(&b),
753 "dup:0000000212345678"
754 );
755 assert_eq!(fingerprints.fingerprint_for_group(&c), "dup:87654321");
756 assert!(
757 fingerprints
758 .find_group(&[a.clone(), b.clone(), c.clone()], "dup:12345678")
759 .is_none()
760 );
761 assert_eq!(
762 fingerprints
763 .find_group(&[a, b, c], "dup:0000000212345678")
764 .and_then(|group| group.instances.first())
765 .map(|inst| inst.fragment.as_str()),
766 Some("beta()")
767 );
768 }
769
770 #[test]
771 fn fingerprint_set_suffixes_full_hash_collisions() {
772 let a = group(&["alpha()"], 2);
773 let b = group(&["beta()"], 2);
774 let mut entries = vec![
775 (
776 CloneFingerprintKey::from_group(&a),
777 0x0000_0001_1234_5678_u64,
778 ),
779 (
780 CloneFingerprintKey::from_group(&b),
781 0x0000_0001_1234_5678_u64,
782 ),
783 ];
784
785 let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
786 entries.reverse();
787 let reversed_fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
788
789 let a_fingerprint = fingerprints.fingerprint_for_group(&a);
790 let b_fingerprint = fingerprints.fingerprint_for_group(&b);
791 assert_eq!(
792 a_fingerprint,
793 reversed_fingerprints.fingerprint_for_group(&a)
794 );
795 assert_eq!(
796 b_fingerprint,
797 reversed_fingerprints.fingerprint_for_group(&b)
798 );
799 let mut assigned = vec![a_fingerprint, b_fingerprint];
800 assigned.sort_unstable();
801 assert_eq!(
802 assigned,
803 ["dup:0000000112345678-1", "dup:0000000112345678-2"]
804 );
805 assert!(
806 fingerprints
807 .find_group(&[a.clone(), b.clone()], "dup:12345678")
808 .is_none()
809 );
810 assert!(
811 fingerprints
812 .find_group(&[a, b], "dup:0000000112345678")
813 .is_none()
814 );
815 }
816
817 #[test]
818 fn group_suggestion_savings_is_lines_times_extra_copies() {
819 let g = group(&["x", "x", "x"], 10); let suggestion = group_refactoring_suggestion(&g);
821 assert_eq!(suggestion.kind, RefactoringKind::ExtractFunction);
822 assert_eq!(suggestion.estimated_savings, 20); }
824
825 #[test]
826 fn dominant_identifier_picks_repeated_domain_name() {
827 let g = group(
828 &["function buildInvoice(invoice) { return invoice.total + invoice.tax; }"],
829 3,
830 );
831 assert_eq!(dominant_identifier(&g).as_deref(), Some("invoice"));
832 }
833
834 #[test]
835 fn dominant_identifier_none_on_generic() {
836 let g = group(&["const data = result.map((item) => item.value);"], 3);
837 assert_eq!(dominant_identifier(&g), None);
838 }
839
840 #[test]
841 fn dominant_identifier_skips_ts_primitive_keywords_and_globals() {
842 let g = group(
843 &["const parseUser = z.string(); parseUser(z.number()); parseUser.or(z.string());"],
844 4,
845 );
846 assert_eq!(dominant_identifier(&g).as_deref(), Some("parseUser"));
847 let only_keywords = group(&["const x: string = y as string; return x as any;"], 3);
848 assert_eq!(dominant_identifier(&only_keywords), None);
849 let g_global = group(&["Math.max(Math.floor(Math.abs(v)), 0)"], 3);
850 assert_eq!(dominant_identifier(&g_global), None);
851 }
852
853 #[test]
854 fn dominant_identifier_none_on_single_letter_type_param() {
855 let g = group(
856 &["function id<T>(x: T): T { const a: T = x; return a as T; }"],
857 3,
858 );
859 assert_eq!(dominant_identifier(&g), None);
860 }
861
862 #[test]
863 fn dominant_identifier_none_on_tie() {
864 let g = group(&["alpha(); beta();"], 2); assert_eq!(dominant_identifier(&g), None);
866 }
867
868 #[test]
869 fn dominant_identifier_prefers_structured_names() {
870 let g = group(
871 &["parseSchema(input); parseSchema(cache); helper(); helper();"],
872 3,
873 );
874 assert_eq!(dominant_identifier(&g).as_deref(), Some("parseSchema"));
875 }
876
877 #[test]
878 fn dominant_identifier_requires_plain_token_margin() {
879 let low_signal = group(&["schema(); schema(); parseUser();"], 3);
880 assert_eq!(dominant_identifier(&low_signal), None);
881
882 let strong = group(&["schema(); schema(); schema(); schema(); parseUser();"], 3);
883 assert_eq!(dominant_identifier(&strong).as_deref(), Some("schema"));
884 }
885
886 #[test]
887 fn dominant_identifier_is_stable_across_word_order() {
888 let first = group(
889 &["helper(); parseSchema(input); helper(); parseSchema(cache);"],
890 3,
891 );
892 let second = group(
893 &["parseSchema(input); helper(); parseSchema(cache); helper();"],
894 3,
895 );
896
897 assert_eq!(dominant_identifier(&first), dominant_identifier(&second));
898 assert_eq!(dominant_identifier(&first).as_deref(), Some("parseSchema"));
899 }
900}