code-gauge 4.4.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +16 -5
  2. package/dist/crossFileDuplication.cjs +1 -1
  3. package/dist/crossFileDuplication.cjs.map +1 -1
  4. package/dist/crossFileDuplication.d.ts +11 -4
  5. package/dist/crossFileDuplication.js +1 -1
  6. package/dist/crossFileDuplication.js.map +1 -1
  7. package/dist/crossFileNearMiss.cjs +2 -0
  8. package/dist/crossFileNearMiss.cjs.map +1 -0
  9. package/dist/crossFileNearMiss.d.ts +33 -0
  10. package/dist/crossFileNearMiss.js +2 -0
  11. package/dist/crossFileNearMiss.js.map +1 -0
  12. package/dist/diffCommand.cjs +1 -1
  13. package/dist/diffCommand.cjs.map +1 -1
  14. package/dist/diffCommand.js +3 -3
  15. package/dist/diffCommand.js.map +1 -1
  16. package/dist/duplication.cjs +1 -1
  17. package/dist/duplication.cjs.map +1 -1
  18. package/dist/duplication.d.ts +11 -0
  19. package/dist/duplication.js +1 -1
  20. package/dist/duplication.js.map +1 -1
  21. package/dist/metrics.cjs +1 -1
  22. package/dist/metrics.cjs.map +1 -1
  23. package/dist/metrics.d.ts +10 -0
  24. package/dist/metrics.js +1 -1
  25. package/dist/metrics.js.map +1 -1
  26. package/dist/nativeMetrics.cjs +2 -2
  27. package/dist/nativeMetrics.cjs.map +1 -1
  28. package/dist/nativeMetrics.d.ts +7 -2
  29. package/dist/nativeMetrics.js +2 -2
  30. package/dist/nativeMetrics.js.map +1 -1
  31. package/dist/scan.cjs +1 -1
  32. package/dist/scan.cjs.map +1 -1
  33. package/dist/scan.d.ts +12 -1
  34. package/dist/scan.js +1 -1
  35. package/dist/scan.js.map +1 -1
  36. package/dist/types.d.ts +4 -2
  37. package/native/src/dep_degree.rs +2 -3
  38. package/native/src/duplication.rs +490 -337
  39. package/native/src/functions.rs +1 -1
  40. package/native/src/lib.rs +7 -2
  41. package/native/src/measure.rs +35 -11
  42. package/native/src/near_miss.rs +455 -0
  43. package/native/src/types.rs +5 -0
  44. package/package.json +10 -10
@@ -4,13 +4,14 @@ use std::collections::{HashMap, HashSet};
4
4
  use std::sync::OnceLock;
5
5
  use tree_sitter::Node;
6
6
 
7
+ use crate::near_miss::{Block, Matcher, PairMatch, FILTRATION_PERCENT, MAX_LENGTH_RATIO};
7
8
  use crate::types::{
8
9
  CrossFileCandidate, CrossFileToken, CrossFileTokenRange, DuplicateBlockOccurrence,
9
10
  DuplicationMetrics,
10
11
  };
11
12
  use crate::util::{all_children, is_identifier_leaf, named_children, node_text, to_int32, Source};
12
13
 
13
- /// Block-like nodes considered as whole-subtree duplicate candidates; see duplication.ts.
14
+ /// Block-like nodes considered as whole-subtree duplicate candidates.
14
15
  const DUPLICATE_BLOCK_TYPES: &[&str] = &[
15
16
  "statement_block",
16
17
  "block",
@@ -314,13 +315,6 @@ impl Default for DuplicationSettings {
314
315
  }
315
316
  }
316
317
  }
317
- /// N-gram size for the near-miss candidate index (NIL's default); see duplication.ts.
318
- const NEAR_MISS_NGRAM_SIZE: usize = 5;
319
- /// Filtration threshold: shared distinct n-grams over the smaller set; see duplication.ts.
320
- const NEAR_MISS_FILTRATION_PERCENT: usize = 10;
321
- /// Exclusive bound on shared content-bearing tokens (names and literal values); see duplication.ts.
322
- const MIN_CONTENT_SIMILARITY_PERCENT: usize = 50;
323
-
324
318
  /// See isLiteralDense in duplication.ts: >= 20% literal values marks a region as data-like.
325
319
  fn is_literal_dense(literal_count: usize, token_count: usize) -> bool {
326
320
  literal_count * 5 >= token_count
@@ -377,15 +371,17 @@ struct DuplicateCandidate {
377
371
  end_line: usize,
378
372
  }
379
373
 
380
- /// Detects copy-pasted regions within a file; a faithful port of measureDuplication in
381
- /// duplication.ts, including its JavaScript int32 hash arithmetic and insertion-order maps.
382
- pub fn measure_duplication(
383
- root: Node<'_>,
384
- code_line_numbers: &HashSet<usize>,
385
- code: &Source<'_>,
386
- settings: &DuplicationSettings,
387
- ) -> DuplicationMetrics {
388
- let mut tokens: Vec<Token<'_>> = Vec::new();
374
+ /// A file's normalized token stream with the block and statement structure clone detection
375
+ /// matches over, built once per parse and shared by within-file and cross-file detection.
376
+ pub struct TokenizedSource<'a> {
377
+ tokens: Vec<Token<'a>>,
378
+ block_ranges: Vec<TokenRange>,
379
+ container_statement_ranges: Vec<Vec<TokenRange>>,
380
+ literal_count_prefix: Vec<usize>,
381
+ }
382
+
383
+ pub fn tokenize<'a>(root: Node<'_>, code: &Source<'a>) -> TokenizedSource<'a> {
384
+ let mut tokens: Vec<Token<'a>> = Vec::new();
389
385
  let mut block_ranges: Vec<TokenRange> = Vec::new();
390
386
  let mut container_statement_ranges: Vec<Vec<TokenRange>> = Vec::new();
391
387
  collect_tokens(
@@ -395,66 +391,71 @@ pub fn measure_duplication(
395
391
  &mut block_ranges,
396
392
  &mut container_statement_ranges,
397
393
  );
398
-
399
394
  let literal_count_prefix = build_literal_count_prefix(&tokens);
395
+ TokenizedSource {
396
+ tokens,
397
+ block_ranges,
398
+ container_statement_ranges,
399
+ literal_count_prefix,
400
+ }
401
+ }
402
+
403
+ /// Detects copy-pasted regions within a file. Fingerprints replicate the JavaScript int32 hash
404
+ /// arithmetic of fingerprintKey in duplication.ts, so its candidates group with the window
405
+ /// candidates cross-file matching fingerprints in TypeScript.
406
+ pub fn measure_duplication(
407
+ source: &TokenizedSource<'_>,
408
+ code_line_numbers: &HashSet<usize>,
409
+ settings: &DuplicationSettings,
410
+ ) -> DuplicationMetrics {
411
+ let tokens = &source.tokens;
412
+ let literal_count_prefix = &source.literal_count_prefix;
400
413
  let mut candidates = collect_block_candidates(
401
- &tokens,
402
- &literal_count_prefix,
403
- &block_ranges,
414
+ tokens,
415
+ literal_count_prefix,
416
+ &source.block_ranges,
404
417
  settings.min_tokens,
405
418
  );
406
419
  candidates.extend(collect_sequence_candidates(
407
- &tokens,
408
- &literal_count_prefix,
409
- &container_statement_ranges,
420
+ tokens,
421
+ literal_count_prefix,
422
+ &source.container_statement_ranges,
410
423
  settings.min_tokens,
411
424
  ));
412
425
  let counted = select_maximal_duplicates(candidates);
413
426
  let mut groups = merge_adjacent_groups(to_counted_groups(&counted), settings.max_gap_tokens);
414
- let near_miss = collect_near_miss_groups(
415
- &tokens,
416
- &literal_count_prefix,
417
- &block_ranges,
418
- settings,
419
- &mut groups,
420
- );
427
+ let near_miss = collect_near_miss_groups(source, settings, &mut groups);
421
428
  // Near-miss clustering can merge exact groups away, leaving empty entries behind.
422
429
  groups.retain(|group| !group.is_empty());
423
430
  groups.extend(near_miss);
424
- summarize_duplicates(&groups, code_line_numbers, &tokens)
431
+ summarize_duplicates(&groups, code_line_numbers, tokens)
425
432
  }
426
433
 
427
434
  /// Collects one file's contribution to cross-file clone detection: catalogued candidates (whole
428
- /// block subtrees plus each statement container's full run) together with the normalized token
429
- /// stream and statement structure. A faithful port of collectCrossFileDuplicateCandidates in
430
- /// duplication.ts. Source indexes are emitted in UTF-16 code units (the tree is parsed from
431
- /// UTF-16, so node byte offsets are halved) to match JavaScript string indexes.
435
+ /// block subtrees plus each statement container's full run), the normalized token stream and
436
+ /// statement structure, and the blocks near-miss comparison considers. Source indexes are emitted
437
+ /// in UTF-16 code units (the tree is parsed from UTF-16, so node byte offsets are halved) to match
438
+ /// JavaScript string indexes.
432
439
  pub fn collect_cross_file_file_data(
433
- root: Node<'_>,
434
- code: &Source<'_>,
440
+ source: &TokenizedSource<'_>,
435
441
  min_tokens: usize,
436
442
  ) -> (
437
443
  Vec<CrossFileCandidate>,
438
444
  Vec<CrossFileToken>,
439
445
  Vec<Vec<CrossFileTokenRange>>,
446
+ Vec<CrossFileTokenRange>,
440
447
  ) {
441
- let mut tokens: Vec<Token<'_>> = Vec::new();
442
- let mut block_ranges: Vec<TokenRange> = Vec::new();
443
- let mut container_statement_ranges: Vec<Vec<TokenRange>> = Vec::new();
444
- collect_tokens(
445
- root,
446
- code,
447
- &mut tokens,
448
- &mut block_ranges,
449
- &mut container_statement_ranges,
448
+ let tokens = &source.tokens;
449
+ let literal_count_prefix = &source.literal_count_prefix;
450
+ let mut candidates = collect_block_candidates(
451
+ tokens,
452
+ literal_count_prefix,
453
+ &source.block_ranges,
454
+ min_tokens,
450
455
  );
451
- let literal_count_prefix = build_literal_count_prefix(&tokens);
452
-
453
- let mut candidates =
454
- collect_block_candidates(&tokens, &literal_count_prefix, &block_ranges, min_tokens);
455
456
  // Single-statement containers are catalogued too: a file whose only top-level statement is not
456
457
  // a block type (a lone exported table) must still be matchable when wholly copied.
457
- for statements in &container_statement_ranges {
458
+ for statements in &source.container_statement_ranges {
458
459
  let (Some(first), Some(last)) = (statements.first(), statements.last()) else {
459
460
  continue;
460
461
  };
@@ -465,8 +466,8 @@ pub fn collect_cross_file_file_data(
465
466
  let fingerprint = format!(
466
467
  "s:{}",
467
468
  fingerprint_key(
468
- &tokens,
469
- &literal_count_prefix,
469
+ tokens,
470
+ literal_count_prefix,
470
471
  first.start_token_index,
471
472
  last.end_token_index
472
473
  )
@@ -507,29 +508,34 @@ pub fn collect_cross_file_file_data(
507
508
  end_row: token.end_row,
508
509
  })
509
510
  .collect();
510
- let container_statement_payloads = container_statement_ranges
511
+ let container_statement_payloads = source
512
+ .container_statement_ranges
511
513
  .iter()
512
- .map(|statements| {
513
- statements
514
- .iter()
515
- .map(|range| CrossFileTokenRange {
516
- start_token_index: range.start_token_index,
517
- end_token_index: range.end_token_index,
518
- start_index: range.start_index / 2,
519
- end_index: range.end_index / 2,
520
- start_line: range.start_line,
521
- end_line: range.end_line,
522
- })
523
- .collect()
524
- })
514
+ .map(|statements| statements.iter().map(to_token_range_payload).collect())
515
+ .collect();
516
+ let near_miss_block_payloads = select_near_miss_blocks(source, min_tokens)
517
+ .into_iter()
518
+ .map(to_token_range_payload)
525
519
  .collect();
526
520
  (
527
521
  candidate_payloads,
528
522
  token_payloads,
529
523
  container_statement_payloads,
524
+ near_miss_block_payloads,
530
525
  )
531
526
  }
532
527
 
528
+ fn to_token_range_payload(range: &TokenRange) -> CrossFileTokenRange {
529
+ CrossFileTokenRange {
530
+ start_token_index: range.start_token_index,
531
+ end_token_index: range.end_token_index,
532
+ start_index: range.start_index / 2,
533
+ end_index: range.end_index / 2,
534
+ start_line: range.start_line,
535
+ end_line: range.end_line,
536
+ }
537
+ }
538
+
533
539
  fn collect_tokens<'a>(
534
540
  root: Node<'_>,
535
541
  code: &Source<'a>,
@@ -577,8 +583,9 @@ fn collect_tokens<'a>(
577
583
  statement_ranges.push(child_range);
578
584
  }
579
585
  }
580
- // Single-statement containers are recorded too, mirroring collectTokens in
581
- // duplication.ts: window enumeration needs two statements and yields nothing for them.
586
+ // Single-statement containers are recorded too: window enumeration needs two
587
+ // statements and yields nothing for them, but cross-file matching catalogues each
588
+ // container's full run.
582
589
  if is_container && !statement_ranges.is_empty() {
583
590
  container_statement_ranges.push(statement_ranges);
584
591
  }
@@ -731,8 +738,8 @@ fn make_text_token<'a>(
731
738
  }
732
739
  }
733
740
 
734
- /// The value of a literal as folded into literal-dense fingerprints; see literalValueText in
735
- /// duplication.ts for the delimiter-independence rationale mirrored here.
741
+ /// The value of a literal as folded into literal-dense fingerprints, independent of its delimiter
742
+ /// spelling (quote style, C# verbatim prefix) so equal values in differently quoted copies match.
736
743
  fn literal_value_text<'a>(node: Node<'_>, kind: &str, code: &Source<'a>) -> Cow<'a, str> {
737
744
  if kind != "#str" && kind != "#char" {
738
745
  return Cow::Borrowed(node_text(node, code));
@@ -759,8 +766,8 @@ fn literal_value_text<'a>(node: Node<'_>, kind: &str, code: &Source<'a>) -> Cow<
759
766
  Cow::Borrowed(strip_matching_quotes(text))
760
767
  }
761
768
 
762
- /// Strips one matching pair of surrounding ASCII quotes, matching stripMatchingQuotes in
763
- /// duplication.ts (quote characters are ASCII, so byte indexing is UTF-8 safe).
769
+ /// Strips one matching pair of surrounding ASCII quotes (quote characters are ASCII, so byte
770
+ /// indexing is UTF-8 safe).
764
771
  fn strip_matching_quotes(text: &str) -> &str {
765
772
  let bytes = text.as_bytes();
766
773
  if bytes.len() >= 2 {
@@ -989,7 +996,7 @@ struct ContainerWindows {
989
996
  statement_hashes: Vec<i32>,
990
997
  }
991
998
 
992
- /// Enumerates runs of consecutive sibling statements; see collectSequenceCandidates in
999
+ /// Enumerates runs of consecutive sibling statements; see collectSequenceWindowCandidates in
993
1000
  /// duplication.ts for the maximality and sub-window rules replicated here.
994
1001
  fn collect_sequence_candidates(
995
1002
  tokens: &[Token<'_>],
@@ -1319,7 +1326,7 @@ fn combine_hashes(hash: i64, value: i64) -> i64 {
1319
1326
  (to_int32(hash).wrapping_mul(31)) as i64 + value
1320
1327
  }
1321
1328
 
1322
- /// Keeps only maximal, non-overlapping duplicates; see selectMaximalDuplicates in duplication.ts.
1329
+ /// Keeps only maximal, non-overlapping duplicates; see selectMaximalGroups in duplicateSelection.ts.
1323
1330
  fn select_maximal_duplicates(
1324
1331
  candidates: Vec<DuplicateCandidate>,
1325
1332
  ) -> IndexMap<std::rc::Rc<str>, Vec<DuplicateCandidate>> {
@@ -1474,8 +1481,9 @@ fn merge_adjacent_groups(
1474
1481
  let mut restart = true;
1475
1482
  while restart {
1476
1483
  restart = false;
1477
- 'outer: for left_index in 0..groups.len() {
1478
- for right_index in left_index + 1..groups.len() {
1484
+ let partners_by_group = collect_gap_adjacent_partners(&groups, max_gap_tokens);
1485
+ 'outer: for (left_index, partners) in partners_by_group.iter().enumerate() {
1486
+ for &right_index in partners {
1479
1487
  let forward =
1480
1488
  merge_groups(&groups[left_index], &groups[right_index], max_gap_tokens);
1481
1489
  let swapped = forward.is_none();
@@ -1516,6 +1524,43 @@ fn merge_adjacent_groups(
1516
1524
  groups
1517
1525
  }
1518
1526
 
1527
+ /// Per group index, the ascending indexes of later groups that merge_groups can pair with it;
1528
+ /// see collectGapAdjacentPartners in duplication.ts.
1529
+ fn collect_gap_adjacent_partners(
1530
+ groups: &[Vec<CountedOccurrence>],
1531
+ max_gap_tokens: usize,
1532
+ ) -> Vec<Vec<usize>> {
1533
+ let mut starts: Vec<(usize, usize)> = groups
1534
+ .iter()
1535
+ .enumerate()
1536
+ .flat_map(|(group_index, group)| {
1537
+ group
1538
+ .iter()
1539
+ .map(move |occurrence| (occurrence.start_token_index, group_index))
1540
+ })
1541
+ .collect();
1542
+ starts.sort_unstable();
1543
+ let mut partners: Vec<std::collections::BTreeSet<usize>> =
1544
+ vec![std::collections::BTreeSet::new(); groups.len()];
1545
+ for (group_index, group) in groups.iter().enumerate() {
1546
+ for occurrence in group {
1547
+ let first = starts.partition_point(|&(start, _)| start < occurrence.end_token_index);
1548
+ for &(start, other) in &starts[first..] {
1549
+ if start > occurrence.end_token_index + max_gap_tokens {
1550
+ break;
1551
+ }
1552
+ if other != group_index {
1553
+ partners[other.min(group_index)].insert(other.max(group_index));
1554
+ }
1555
+ }
1556
+ }
1557
+ }
1558
+ partners
1559
+ .into_iter()
1560
+ .map(|set| set.into_iter().collect())
1561
+ .collect()
1562
+ }
1563
+
1519
1564
  fn group_sort_key(group: &[CountedOccurrence]) -> (usize, usize) {
1520
1565
  group
1521
1566
  .first()
@@ -1611,79 +1656,136 @@ fn merge_groups(
1611
1656
  })
1612
1657
  }
1613
1658
 
1659
+ /// A verified near-miss pair as (block, core, block, core); a `None` core is a whole-block match.
1660
+ type MatchEdge = (usize, Option<(usize, usize)>, usize, Option<(usize, usize)>);
1661
+
1614
1662
  /// Detects near-miss (Type-3) clone groups among block candidates the exact pipeline left
1615
- /// unreported; a faithful port of collectNearMissGroups in duplication.ts (NIL-style n-gram
1616
- /// filtration, then token-level LCS with NiCad-style per-fragment similarity, then transitive
1617
- /// clustering of verified pairs).
1663
+ /// unreported: NIL-style n-gram filtration, then pair verification (near_miss::Matcher), then
1664
+ /// transitive clustering of verified pairs (crossFileNearMiss.ts applies the same model across
1665
+ /// files). A block that matched only locally is reported as its matched cores (overlapping cores
1666
+ /// merged), each clustered with its own partners, so code no verified pair matched never counts
1667
+ /// as duplicated.
1618
1668
  fn collect_near_miss_groups(
1619
- tokens: &[Token<'_>],
1620
- literal_count_prefix: &[usize],
1621
- block_ranges: &[TokenRange],
1669
+ source: &TokenizedSource<'_>,
1622
1670
  settings: &DuplicationSettings,
1623
1671
  reported_groups: &mut [Vec<CountedOccurrence>],
1624
1672
  ) -> Vec<Vec<CountedOccurrence>> {
1625
1673
  if settings.min_similarity_percent >= 100 {
1626
1674
  return Vec::new();
1627
1675
  }
1628
- let mut eligible: Vec<&TokenRange> = block_ranges
1629
- .iter()
1630
- .filter(|range| {
1631
- let token_count = range.end_token_index - range.start_token_index;
1632
- let literal_count = literal_count_prefix
1633
- .get(range.end_token_index)
1634
- .copied()
1635
- .unwrap_or(0)
1636
- - literal_count_prefix
1637
- .get(range.start_token_index)
1638
- .copied()
1639
- .unwrap_or(0);
1640
- token_count >= settings.min_tokens && !is_literal_dense(literal_count, token_count)
1641
- })
1642
- .collect();
1643
- eligible.sort_by_key(|range| {
1644
- (
1645
- range.start_token_index,
1646
- std::cmp::Reverse(range.end_token_index),
1647
- )
1648
- });
1649
- let comparable = select_comparable_blocks(&eligible);
1676
+ let tokens = &source.tokens;
1677
+ let comparable = select_near_miss_blocks(source, settings.min_tokens);
1650
1678
  if comparable.len() < 2 {
1651
1679
  return Vec::new();
1652
1680
  }
1653
1681
 
1654
- // Reported-group indices whose occurrences overlap each comparable block: such blocks anchor
1655
- // near-miss comparisons but are never re-reported.
1656
- let touched_groups_by_block: Vec<Vec<usize>> = comparable
1682
+ // Reported-group indices whose occurrences overlap a token range: near-miss nodes covering
1683
+ // such content anchor comparisons but are never re-reported.
1684
+ let touched_groups_in = |start: usize, end: usize| -> Vec<usize> {
1685
+ reported_groups
1686
+ .iter()
1687
+ .enumerate()
1688
+ .filter(|(_, group)| {
1689
+ group.iter().any(|occurrence| {
1690
+ occurrence.start_token_index < end && start < occurrence.end_token_index
1691
+ })
1692
+ })
1693
+ .map(|(group_index, _)| group_index)
1694
+ .collect()
1695
+ };
1696
+
1697
+ let (symbols, is_content) = to_symbol_stream(tokens);
1698
+ let statements = top_level_statement_finder(&source.container_statement_ranges);
1699
+ let mut blocks: Vec<Block> = comparable
1657
1700
  .iter()
1658
1701
  .map(|range| {
1659
- reported_groups
1660
- .iter()
1661
- .enumerate()
1662
- .filter(|(_, group)| {
1663
- group.iter().any(|occurrence| {
1664
- occurrence.start_token_index < range.end_token_index
1665
- && range.start_token_index < occurrence.end_token_index
1666
- })
1667
- })
1668
- .map(|(group_index, _)| group_index)
1669
- .collect()
1702
+ Block::new(
1703
+ &symbols,
1704
+ &is_content,
1705
+ range.start_token_index,
1706
+ range.end_token_index,
1707
+ statements(range.start_token_index, range.end_token_index),
1708
+ )
1670
1709
  })
1671
1710
  .collect();
1711
+ let matcher = Matcher::new(
1712
+ &mut blocks,
1713
+ settings.min_tokens,
1714
+ settings.min_similarity_percent,
1715
+ );
1672
1716
 
1673
- // Interned per call so a file's symbol ids (and thus its n-gram hashes) never depend on which
1674
- // other files the process measured before it.
1675
- let mut symbol_id_by_token_hashes: HashMap<(i32, i32, i32, i32), i32> = HashMap::new();
1676
- let sequences: Vec<NormalizedBlock> = comparable
1677
- .iter()
1678
- .map(|range| normalize_block_sequence(tokens, range, &mut symbol_id_by_token_hashes))
1679
- .collect();
1680
- let ngram_sets: Vec<HashSet<i32>> = sequences
1717
+ let block_touched: Vec<bool> = comparable
1681
1718
  .iter()
1682
- .map(|block| collect_ngram_set(&block.sequence))
1719
+ .map(|range| !touched_groups_in(range.start_token_index, range.end_token_index).is_empty())
1683
1720
  .collect();
1684
- let shared_counts = count_shared_ngrams(&ngram_sets);
1721
+ let mut edges: Vec<MatchEdge> = Vec::new();
1722
+ for_each_candidate_pair(
1723
+ &blocks,
1724
+ settings.min_similarity_percent,
1725
+ |left_index, right_index| match matcher.verify(&blocks[left_index], &blocks[right_index]) {
1726
+ None => {}
1727
+ // A whole match between two blocks that both overlap reported content could never join
1728
+ // a group, and recording it would collapse the blocks' core nodes.
1729
+ Some(PairMatch::Whole)
1730
+ if !(block_touched[left_index] && block_touched[right_index]) =>
1731
+ {
1732
+ edges.push((left_index, None, right_index, None))
1733
+ }
1734
+ Some(PairMatch::Whole) => {}
1735
+ Some(PairMatch::Local(cores)) => {
1736
+ for (left_core, right_core) in cores {
1737
+ edges.push((left_index, Some(left_core), right_index, Some(right_core)));
1738
+ }
1739
+ }
1740
+ },
1741
+ );
1742
+
1743
+ // Clustering runs over (block, core) nodes: a block with a recorded whole match is one
1744
+ // node, and otherwise each union of its overlapping local cores is its own node, so disjoint
1745
+ // cores matched with different partners fall into separate groups.
1746
+ let mut matched_whole = vec![false; comparable.len()];
1747
+ let mut local_cores: Vec<Vec<(usize, usize)>> = vec![Vec::new(); comparable.len()];
1748
+ for &(left_index, left_core, right_index, right_core) in &edges {
1749
+ for (index, core) in [(left_index, left_core), (right_index, right_core)] {
1750
+ match core {
1751
+ Some(core) => local_cores[index].push(core),
1752
+ None => matched_whole[index] = true,
1753
+ }
1754
+ }
1755
+ }
1756
+ let mut node_blocks: Vec<usize> = Vec::new();
1757
+ let mut node_spans: Vec<Option<(usize, usize)>> = Vec::new();
1758
+ let mut first_node_by_block: Vec<usize> = Vec::with_capacity(comparable.len());
1759
+ for index in 0..comparable.len() {
1760
+ first_node_by_block.push(node_blocks.len());
1761
+ let cores = if matched_whole[index] {
1762
+ Vec::new()
1763
+ } else {
1764
+ merge_overlapping_cores(&local_cores[index])
1765
+ };
1766
+ if cores.is_empty() {
1767
+ node_blocks.push(index);
1768
+ node_spans.push(None);
1769
+ }
1770
+ for core in cores {
1771
+ node_blocks.push(index);
1772
+ node_spans.push(Some(core));
1773
+ }
1774
+ }
1775
+ let node_of = |index: usize, core: Option<(usize, usize)>| {
1776
+ let first = first_node_by_block[index];
1777
+ match core {
1778
+ Some(core) if !matched_whole[index] => (first..node_blocks.len())
1779
+ .take_while(|&node| node_blocks[node] == index)
1780
+ .find(|&node| {
1781
+ node_spans[node].is_some_and(|span| span.0 <= core.0 && core.1 <= span.1)
1782
+ })
1783
+ .expect("every local core lies in one of its block's merged cores"),
1784
+ _ => first,
1785
+ }
1786
+ };
1685
1787
 
1686
- let mut parent: Vec<usize> = (0..comparable.len()).collect();
1788
+ let mut parent: Vec<usize> = (0..node_blocks.len()).collect();
1687
1789
  fn find(parent: &mut [usize], mut index: usize) -> usize {
1688
1790
  let mut root = index;
1689
1791
  while parent[root] != root {
@@ -1696,52 +1798,84 @@ fn collect_near_miss_groups(
1696
1798
  }
1697
1799
  root
1698
1800
  }
1699
- for (&(left_index, right_index), &shared) in &shared_counts {
1700
- let left = &sequences[left_index];
1701
- let right = &sequences[right_index];
1702
- // Two already-reported blocks have nothing new to contribute to each other.
1703
- if !touched_groups_by_block[left_index].is_empty()
1704
- && !touched_groups_by_block[right_index].is_empty()
1705
- {
1706
- continue;
1707
- }
1708
- let min_ngrams = ngram_sets[left_index]
1709
- .len()
1710
- .min(ngram_sets[right_index].len());
1711
- if shared * 100 < NEAR_MISS_FILTRATION_PERCENT * min_ngrams {
1712
- continue;
1713
- }
1714
- // A structural match must be backed by shared content (names and literal values); the
1715
- // bound is exclusive, matching duplication.ts.
1716
- if content_overlap(left, right) * 100
1717
- <= MIN_CONTENT_SIMILARITY_PERCENT * left.content_total.max(right.content_total)
1801
+ // Coverage is judged per node: a core is covered only when a reported occurrence overlaps
1802
+ // the core itself, not merely elsewhere in its block.
1803
+ let node_range = |node: usize| {
1804
+ node_spans[node].unwrap_or((
1805
+ comparable[node_blocks[node]].start_token_index,
1806
+ comparable[node_blocks[node]].end_token_index,
1807
+ ))
1808
+ };
1809
+ let touched_groups_by_node: Vec<Vec<usize>> = (0..node_blocks.len())
1810
+ .map(|node| {
1811
+ let (start, end) = node_range(node);
1812
+ touched_groups_in(start, end)
1813
+ })
1814
+ .collect();
1815
+ for &(left_index, left_core, right_index, right_core) in &edges {
1816
+ let (left_node, right_node) = (
1817
+ node_of(left_index, left_core),
1818
+ node_of(right_index, right_core),
1819
+ );
1820
+ // Two already-reported nodes have nothing new to contribute to each other.
1821
+ if !touched_groups_by_node[left_node].is_empty()
1822
+ && !touched_groups_by_node[right_node].is_empty()
1718
1823
  {
1719
1824
  continue;
1720
1825
  }
1721
- // Per-fragment similarity against the larger block (NiCad semantics).
1722
- if lcs_length(&left.sequence, &right.sequence) * 100
1723
- >= settings.min_similarity_percent * left.sequence.len().max(right.sequence.len())
1724
- {
1725
- let left_root = find(&mut parent, left_index);
1726
- let right_root = find(&mut parent, right_index);
1727
- parent[left_root.max(right_root)] = left_root.min(right_root);
1728
- }
1826
+ let left_root = find(&mut parent, left_node);
1827
+ let right_root = find(&mut parent, right_node);
1828
+ parent[left_root.max(right_root)] = left_root.min(right_root);
1729
1829
  }
1730
1830
 
1731
1831
  let mut members_by_root: IndexMap<usize, Vec<usize>> = IndexMap::new();
1732
- for index in 0..comparable.len() {
1733
- let root = find(&mut parent, index);
1734
- members_by_root.entry(root).or_default().push(index);
1735
- }
1736
- let to_occurrence = |range: &TokenRange| CountedOccurrence {
1737
- shared_with_merged_group: false,
1738
- segments: vec![(range.start_token_index, range.end_token_index)],
1739
- token_count: range.end_token_index - range.start_token_index,
1740
- start_token_index: range.start_token_index,
1741
- end_token_index: range.end_token_index,
1742
- start_line: range.start_line,
1743
- end_line: range.end_line,
1832
+ for node in 0..node_blocks.len() {
1833
+ let root = find(&mut parent, node);
1834
+ members_by_root.entry(root).or_default().push(node);
1835
+ }
1836
+ // A group's nodes from one block become ONE occurrence whose segments are its cores, so the
1837
+ // fragment-weighted count charges the block as one copy (as for gapped clones), not once per
1838
+ // core.
1839
+ let to_occurrences = |nodes: &[usize]| -> Vec<CountedOccurrence> {
1840
+ let mut spans_by_block: IndexMap<usize, Vec<Option<(usize, usize)>>> = IndexMap::new();
1841
+ for &node in nodes {
1842
+ spans_by_block
1843
+ .entry(node_blocks[node])
1844
+ .or_default()
1845
+ .push(node_spans[node]);
1846
+ }
1847
+ spans_by_block
1848
+ .into_iter()
1849
+ .map(|(block, spans)| {
1850
+ let range = comparable[block];
1851
+ let mut segments: Vec<(usize, usize)> = spans
1852
+ .iter()
1853
+ .map(|span| span.unwrap_or((range.start_token_index, range.end_token_index)))
1854
+ .collect();
1855
+ segments.sort_unstable();
1856
+ let whole = spans.iter().any(Option::is_none);
1857
+ let (start, end) = (segments[0].0, segments[segments.len() - 1].1);
1858
+ CountedOccurrence {
1859
+ shared_with_merged_group: false,
1860
+ token_count: segments.iter().map(|segment| segment.1 - segment.0).sum(),
1861
+ segments,
1862
+ start_token_index: start,
1863
+ end_token_index: end,
1864
+ start_line: if whole {
1865
+ range.start_line
1866
+ } else {
1867
+ tokens[start].start_row + 1
1868
+ },
1869
+ end_line: if whole {
1870
+ range.end_line
1871
+ } else {
1872
+ tokens[end - 1].end_row + 1
1873
+ },
1874
+ }
1875
+ })
1876
+ .collect()
1744
1877
  };
1878
+ let touched_groups_of = |node: usize| &touched_groups_by_node[node];
1745
1879
  let mut groups: Vec<Vec<CountedOccurrence>> = Vec::new();
1746
1880
  for members in members_by_root.values() {
1747
1881
  if members.len() < 2 {
@@ -1750,38 +1884,36 @@ fn collect_near_miss_groups(
1750
1884
  let uncovered: Vec<usize> = members
1751
1885
  .iter()
1752
1886
  .copied()
1753
- .filter(|&index| touched_groups_by_block[index].is_empty())
1887
+ .filter(|&index| touched_groups_of(index).is_empty())
1754
1888
  .collect();
1755
1889
  let covered: Vec<usize> = members
1756
1890
  .iter()
1757
1891
  .copied()
1758
- .filter(|&index| !touched_groups_by_block[index].is_empty())
1892
+ .filter(|&index| !touched_groups_of(index).is_empty())
1759
1893
  .collect();
1760
1894
  if covered.is_empty() {
1761
- groups.push(
1762
- members
1763
- .iter()
1764
- .map(|&index| to_occurrence(comparable[index]))
1765
- .collect(),
1766
- );
1895
+ let occurrences = to_occurrences(members);
1896
+ if occurrences.len() >= 2 {
1897
+ groups.push(occurrences);
1898
+ }
1767
1899
  continue;
1768
1900
  }
1769
1901
  if uncovered.is_empty() {
1770
1902
  continue;
1771
1903
  }
1772
- // An anchored cluster extends a reported group only when that group lies entirely inside
1773
- // the cluster; see collectNearMissGroups in duplication.ts.
1904
+ // An anchored cluster extends a reported group only when every occurrence of that group
1905
+ // overlaps one of the cluster's member nodes: an occurrence disjoint from all members
1906
+ // reports content the cluster does not share.
1774
1907
  let overlaps_member = |occurrence: &CountedOccurrence| {
1775
1908
  members.iter().any(|&index| {
1776
- let range = comparable[index];
1777
- occurrence.start_token_index < range.end_token_index
1778
- && range.start_token_index < occurrence.end_token_index
1909
+ let (start, end) = node_range(index);
1910
+ occurrence.start_token_index < end && start < occurrence.end_token_index
1779
1911
  })
1780
1912
  };
1781
1913
  // Ascending by construction: BTreeSet iteration is sorted and filter preserves order.
1782
1914
  let fully_clustered: Vec<usize> = covered
1783
1915
  .iter()
1784
- .flat_map(|&index| touched_groups_by_block[index].iter().copied())
1916
+ .flat_map(|&index| touched_groups_of(index).iter().copied())
1785
1917
  .collect::<std::collections::BTreeSet<usize>>()
1786
1918
  .into_iter()
1787
1919
  .filter(|&group_index| {
@@ -1790,33 +1922,57 @@ fn collect_near_miss_groups(
1790
1922
  })
1791
1923
  .collect();
1792
1924
  if let Some((&target_index, source_indexes)) = fully_clustered.split_first() {
1793
- // Rebuild the component as ONE group with one coalesced occurrence per member block;
1794
- // see collectNearMissGroups in duplication.ts.
1925
+ // Rebuild the component as ONE group with one coalesced occurrence per member block:
1926
+ // the fragments every node of a block overlaps are collected together, since a block's
1927
+ // cores are parts of one copy.
1795
1928
  let mut consumed: HashSet<(usize, usize)> = HashSet::new();
1796
1929
  let mut merged: Vec<CountedOccurrence> = Vec::new();
1930
+ let mut unanchored_nodes: Vec<usize> = Vec::new();
1931
+ let mut nodes_by_block: IndexMap<usize, Vec<usize>> = IndexMap::new();
1797
1932
  for &member_index in members {
1798
- let range = comparable[member_index];
1933
+ nodes_by_block
1934
+ .entry(node_blocks[member_index])
1935
+ .or_default()
1936
+ .push(member_index);
1937
+ }
1938
+ for block_nodes in nodes_by_block.values() {
1799
1939
  // Occurrences of ONE group are distinct copies; only fragments from DIFFERENT
1800
1940
  // groups belong to the same copy. Consecutive position-order slices keep the
1801
- // coalesced spans disjoint; see collectNearMissGroups in duplication.ts.
1941
+ // coalesced spans disjoint.
1802
1942
  let mut fragments: Vec<(CountedOccurrence, usize)> = Vec::new();
1803
- for &group_index in &fully_clustered {
1804
- for (occurrence_index, occurrence) in
1805
- reported_groups[group_index].iter().enumerate()
1806
- {
1807
- if !consumed.contains(&(group_index, occurrence_index))
1808
- && occurrence.start_token_index < range.end_token_index
1809
- && range.start_token_index < occurrence.end_token_index
1943
+ let mut plain_nodes: Vec<usize> = Vec::new();
1944
+ for &node in block_nodes {
1945
+ let (range_start, range_end) = node_range(node);
1946
+ let fragment_count = fragments.len();
1947
+ for &group_index in &fully_clustered {
1948
+ for (occurrence_index, occurrence) in
1949
+ reported_groups[group_index].iter().enumerate()
1810
1950
  {
1811
- consumed.insert((group_index, occurrence_index));
1812
- fragments.push((occurrence.clone(), group_index));
1951
+ if !consumed.contains(&(group_index, occurrence_index))
1952
+ && occurrence.start_token_index < range_end
1953
+ && range_start < occurrence.end_token_index
1954
+ {
1955
+ consumed.insert((group_index, occurrence_index));
1956
+ fragments.push((occurrence.clone(), group_index));
1957
+ }
1813
1958
  }
1814
1959
  }
1960
+ if fragments.len() == fragment_count && touched_groups_of(node).is_empty() {
1961
+ plain_nodes.push(node);
1962
+ }
1963
+ }
1964
+ if fragments.is_empty() {
1965
+ unanchored_nodes.extend(plain_nodes);
1966
+ } else {
1967
+ // An untouched core of a block that also holds fragments is part of the same
1968
+ // copy; a group index no reported group uses keeps it in that copy.
1969
+ for occurrence in to_occurrences(&plain_nodes) {
1970
+ fragments.push((occurrence, usize::MAX));
1971
+ }
1815
1972
  }
1816
1973
  fragments.sort_by_key(|(occurrence, _)| {
1817
1974
  (occurrence.start_token_index, occurrence.end_token_index)
1818
1975
  });
1819
- let had_fragments = !fragments.is_empty();
1820
1976
  let mut copy_parts: Vec<CountedOccurrence> = Vec::new();
1821
1977
  let mut copy_groups: HashSet<usize> = HashSet::new();
1822
1978
  for (occurrence, group_index) in fragments {
@@ -1830,10 +1986,8 @@ fn collect_near_miss_groups(
1830
1986
  if !copy_parts.is_empty() {
1831
1987
  merged.push(coalesce_occurrences(copy_parts));
1832
1988
  }
1833
- if !had_fragments && touched_groups_by_block[member_index].is_empty() {
1834
- merged.push(to_occurrence(comparable[member_index]));
1835
- }
1836
1989
  }
1990
+ merged.extend(to_occurrences(&unanchored_nodes));
1837
1991
  merged.sort_by_key(|occurrence| {
1838
1992
  (occurrence.start_token_index, occurrence.end_token_index)
1839
1993
  });
@@ -1846,22 +2000,46 @@ fn collect_near_miss_groups(
1846
2000
  for &source_index in source_indexes {
1847
2001
  reported_groups[source_index].clear();
1848
2002
  }
1849
- } else if uncovered.len() >= 2 {
1850
- groups.push(
1851
- uncovered
1852
- .iter()
1853
- .map(|&index| to_occurrence(comparable[index]))
1854
- .collect(),
1855
- );
2003
+ } else {
2004
+ let occurrences = to_occurrences(&uncovered);
2005
+ if occurrences.len() >= 2 {
2006
+ groups.push(occurrences);
2007
+ }
1856
2008
  }
1857
2009
  }
1858
2010
  groups.sort_by_key(|group| group_sort_key(group));
1859
2011
  groups
1860
2012
  }
1861
2013
 
1862
- /// Keeps the block ranges the near-miss phase compares; a faithful port of selectComparableBlocks
1863
- /// in duplication.ts (wrappers whose subtree branches into two or more disjoint eligible
1864
- /// sub-blocks are descended through; linear chains keep their top).
2014
+ /// The mutually disjoint blocks near-miss comparison considers: at least `min_tokens` long, not
2015
+ /// literal-dense (data tables are compared by value, not shape), and selected by
2016
+ /// select_comparable_blocks.
2017
+ fn select_near_miss_blocks<'s>(
2018
+ source: &'s TokenizedSource<'_>,
2019
+ min_tokens: usize,
2020
+ ) -> Vec<&'s TokenRange> {
2021
+ let literal_count_prefix = &source.literal_count_prefix;
2022
+ let mut eligible: Vec<&TokenRange> = source
2023
+ .block_ranges
2024
+ .iter()
2025
+ .filter(|range| {
2026
+ let token_count = range.end_token_index - range.start_token_index;
2027
+ let literal_count = literal_count_prefix[range.end_token_index]
2028
+ - literal_count_prefix[range.start_token_index];
2029
+ token_count >= min_tokens && !is_literal_dense(literal_count, token_count)
2030
+ })
2031
+ .collect();
2032
+ eligible.sort_by_key(|range| {
2033
+ (
2034
+ range.start_token_index,
2035
+ std::cmp::Reverse(range.end_token_index),
2036
+ )
2037
+ });
2038
+ select_comparable_blocks(&eligible)
2039
+ }
2040
+
2041
+ /// Keeps the block ranges the near-miss phase compares: wrappers whose subtree branches into two
2042
+ /// or more disjoint eligible sub-blocks are descended through; linear chains keep their top.
1865
2043
  fn select_comparable_blocks<'a>(eligible: &[&'a TokenRange]) -> Vec<&'a TokenRange> {
1866
2044
  struct ForestNode<'a> {
1867
2045
  range: &'a TokenRange,
@@ -1919,8 +2097,7 @@ fn select_comparable_blocks<'a>(eligible: &[&'a TokenRange]) -> Vec<&'a TokenRan
1919
2097
  kept
1920
2098
  }
1921
2099
 
1922
- /// One copy's fragments (an exact prefix and suffix split by a large edit) as one occurrence;
1923
- /// mirrors coalesceOccurrences in duplication.ts.
2100
+ /// One copy's fragments (an exact prefix and suffix split by a large edit) as one occurrence.
1924
2101
  fn coalesce_occurrences(occurrences: Vec<CountedOccurrence>) -> CountedOccurrence {
1925
2102
  if occurrences.len() == 1 {
1926
2103
  return occurrences.into_iter().next().expect("non-empty");
@@ -1959,149 +2136,125 @@ fn coalesce_occurrences(occurrences: Vec<CountedOccurrence>) -> CountedOccurrenc
1959
2136
  }
1960
2137
  }
1961
2138
 
1962
- struct NormalizedBlock {
1963
- sequence: Vec<i32>,
1964
- /// Occurrences per content-bearing symbol (names and literal values), for the content gate.
1965
- content_count_by_symbol: HashMap<i32, usize>,
1966
- content_total: usize,
2139
+ /// The unions of overlapping cores, in position order.
2140
+ fn merge_overlapping_cores(cores: &[(usize, usize)]) -> Vec<(usize, usize)> {
2141
+ let mut sorted = cores.to_vec();
2142
+ sorted.sort_unstable();
2143
+ let mut merged: Vec<(usize, usize)> = Vec::new();
2144
+ for (start, end) in sorted {
2145
+ match merged.last_mut() {
2146
+ Some(last) if start < last.1 => last.1 = last.1.max(end),
2147
+ _ => merged.push((start, end)),
2148
+ }
2149
+ }
2150
+ merged
1967
2151
  }
1968
2152
 
1969
- /// A block's tokens as comparable integers; see normalizeBlockSequence in duplication.ts
1970
- /// (literal VALUES are folded into the symbol, unlike the exact fingerprint's kind tags).
1971
- fn normalize_block_sequence(
1972
- tokens: &[Token<'_>],
1973
- range: &TokenRange,
1974
- symbol_id_by_token_hashes: &mut HashMap<(i32, i32, i32, i32), i32>,
1975
- ) -> NormalizedBlock {
1976
- let mut sequence = Vec::with_capacity(range.end_token_index - range.start_token_index);
1977
- let mut index_by_identifier: HashMap<&str, i32> = HashMap::new();
1978
- let mut content_count_by_symbol: HashMap<i32, usize> = HashMap::new();
1979
- let mut content_total = 0usize;
1980
- for token in &tokens[range.start_token_index..range.end_token_index.min(tokens.len())] {
1981
- let value = if token.is_id {
1982
- let next_index = index_by_identifier.len() as i32;
1983
- let identifier_index = *index_by_identifier
1984
- .entry(token.text.as_ref())
1985
- .or_insert(next_index);
1986
- -(identifier_index + 1)
1987
- } else {
1988
- let next_id = symbol_id_by_token_hashes.len() as i32;
1989
- let id = *symbol_id_by_token_hashes
2153
+ /// The file's tokens as a near-miss symbol stream: identifiers as -(file-level id + 1), every
2154
+ /// other token interned from its hash pairs (literal VALUES folded in, unlike the exact
2155
+ /// fingerprint's kind tags), plus which tokens are content-bearing (names and literal values).
2156
+ /// Interned per call so a file's symbol ids (and thus its n-gram hashes) never depend on which
2157
+ /// other files the process measured before it.
2158
+ fn to_symbol_stream(tokens: &[Token<'_>]) -> (Vec<i32>, Vec<bool>) {
2159
+ let mut symbol_by_token_hashes: HashMap<(i32, i32, i32, i32), i32> = HashMap::new();
2160
+ let mut id_by_identifier: HashMap<&str, i32> = HashMap::new();
2161
+ tokens
2162
+ .iter()
2163
+ .map(|token| {
2164
+ if token.is_id {
2165
+ let next_id = id_by_identifier.len() as i32;
2166
+ let id = *id_by_identifier
2167
+ .entry(token.text.as_ref())
2168
+ .or_insert(next_id);
2169
+ return (-(id + 1), false);
2170
+ }
2171
+ let next_symbol = symbol_by_token_hashes.len() as i32;
2172
+ let symbol = *symbol_by_token_hashes
1990
2173
  .entry((
1991
2174
  token.text_hash,
1992
2175
  token.text_hash2,
1993
2176
  token.literal_hash.unwrap_or(0),
1994
2177
  token.literal_hash2.unwrap_or(0),
1995
2178
  ))
1996
- .or_insert(next_id);
1997
- if token.is_name || token.literal_hash.is_some() {
1998
- *content_count_by_symbol.entry(id).or_insert(0) += 1;
1999
- content_total += 1;
2000
- }
2001
- id
2002
- };
2003
- sequence.push(value);
2004
- }
2005
- NormalizedBlock {
2006
- sequence,
2007
- content_count_by_symbol,
2008
- content_total,
2009
- }
2010
- }
2011
-
2012
- /// Multiset overlap of two blocks' content-bearing symbols, for the content gate.
2013
- fn content_overlap(left: &NormalizedBlock, right: &NormalizedBlock) -> usize {
2014
- let (smaller, larger) =
2015
- if left.content_count_by_symbol.len() <= right.content_count_by_symbol.len() {
2016
- (left, right)
2017
- } else {
2018
- (right, left)
2019
- };
2020
- smaller
2021
- .content_count_by_symbol
2022
- .iter()
2023
- .map(|(symbol, count)| {
2024
- (*count).min(
2025
- larger
2026
- .content_count_by_symbol
2027
- .get(symbol)
2028
- .copied()
2029
- .unwrap_or(0),
2030
- )
2179
+ .or_insert(next_symbol);
2180
+ (symbol, token.is_name || token.literal_hash.is_some())
2031
2181
  })
2032
- .sum()
2182
+ .unzip()
2033
2183
  }
2034
2184
 
2035
- /// The distinct 5-gram hashes of a normalized block sequence, matching collectNgramSet exactly.
2036
- fn collect_ngram_set(sequence: &[i32]) -> HashSet<i32> {
2037
- if sequence.len() < NEAR_MISS_NGRAM_SIZE {
2038
- return HashSet::new();
2039
- }
2040
- // Exact upper bound: one n-gram per window, and most windows hash distinctly.
2041
- let mut ngrams = HashSet::with_capacity(sequence.len() - NEAR_MISS_NGRAM_SIZE + 1);
2042
- for window in sequence.windows(NEAR_MISS_NGRAM_SIZE) {
2043
- let mut hash: i32 = 5381;
2044
- for &value in window {
2045
- hash = hash.wrapping_mul(31).wrapping_add(value);
2185
+ /// Returns a lookup of the outermost container statements inside a token range, excluding a
2186
+ /// statement spanning the whole range (the block itself).
2187
+ fn top_level_statement_finder(
2188
+ container_statement_ranges: &[Vec<TokenRange>],
2189
+ ) -> impl Fn(usize, usize) -> Vec<(usize, usize)> {
2190
+ let mut statements: Vec<(usize, usize)> = container_statement_ranges
2191
+ .iter()
2192
+ .flatten()
2193
+ .map(|range| (range.start_token_index, range.end_token_index))
2194
+ .filter(|(start, end)| start < end)
2195
+ .collect();
2196
+ statements.sort_by_key(|&(start, end)| (start, std::cmp::Reverse(end)));
2197
+ move |start, end| {
2198
+ let mut top_level: Vec<(usize, usize)> = Vec::new();
2199
+ let first = statements.partition_point(|statement| statement.0 < start);
2200
+ for &statement in statements[first..]
2201
+ .iter()
2202
+ .take_while(|statement| statement.0 < end)
2203
+ {
2204
+ let nested = top_level.last().is_some_and(|last| statement.0 < last.1);
2205
+ if statement.1 <= end && statement != (start, end) && !nested {
2206
+ top_level.push(statement);
2207
+ }
2046
2208
  }
2047
- ngrams.insert(hash);
2209
+ top_level
2048
2210
  }
2049
- ngrams
2050
2211
  }
2051
2212
 
2052
- /// Shared distinct-n-gram counts per block pair (left < right).
2053
- fn count_shared_ngrams(ngram_sets: &[HashSet<i32>]) -> HashMap<(usize, usize), usize> {
2054
- let mut blocks_by_ngram: HashMap<i32, Vec<usize>> = HashMap::new();
2055
- for (block_index, ngrams) in ngram_sets.iter().enumerate() {
2056
- for &ngram in ngrams {
2057
- blocks_by_ngram.entry(ngram).or_default().push(block_index);
2058
- }
2059
- }
2060
- let mut shared_counts: HashMap<(usize, usize), usize> = HashMap::new();
2061
- for blocks in blocks_by_ngram.values() {
2062
- for (position, &left_index) in blocks.iter().enumerate() {
2063
- // Bucket indices are appended in ascending block order, so left < right already.
2064
- for &right_index in &blocks[position + 1..] {
2065
- *shared_counts.entry((left_index, right_index)).or_insert(0) += 1;
2213
+ /// Visits every block pair sharing at least FILTRATION_PERCENT of the smaller block's distinct
2214
+ /// n-grams, except pairs whose length ratio rules out both whole-block similarity and
2215
+ /// MAX_LENGTH_RATIO; the same scan as forEachCandidatePair in crossFileNearMiss.ts. Blocks are
2216
+ /// visited in ascending length, so each posting list is scanned backwards only while its blocks are
2217
+ /// long enough, and shared counts accumulate in a dense counter instead of a pair map.
2218
+ fn for_each_candidate_pair(
2219
+ blocks: &[Block],
2220
+ min_similarity_percent: usize,
2221
+ mut visit: impl FnMut(usize, usize),
2222
+ ) {
2223
+ let mut order: Vec<usize> = (0..blocks.len()).collect();
2224
+ order.sort_by_key(|&index| blocks[index].len());
2225
+ let mut postings: HashMap<i32, Vec<usize>> = HashMap::new();
2226
+ let mut shared_counts = vec![0usize; blocks.len()];
2227
+ let mut touched: Vec<usize> = Vec::new();
2228
+ for right in order {
2229
+ let length = blocks[right].len();
2230
+ let min_left_length = length
2231
+ .div_ceil(MAX_LENGTH_RATIO)
2232
+ .min((min_similarity_percent * length).div_ceil(100));
2233
+ for &ngram in &blocks[right].ngrams {
2234
+ let posting = postings.entry(ngram).or_default();
2235
+ for &left in posting.iter().rev() {
2236
+ if blocks[left].len() < min_left_length {
2237
+ break;
2238
+ }
2239
+ if shared_counts[left] == 0 {
2240
+ touched.push(left);
2241
+ }
2242
+ shared_counts[left] += 1;
2066
2243
  }
2244
+ posting.push(right);
2067
2245
  }
2068
- }
2069
- shared_counts
2070
- }
2071
-
2072
- /// Longest-common-subsequence LENGTH via the Allison–Dix bit-parallel recurrence. Only the length
2073
- /// is needed and LCS length is algorithm-independent, so u64 words are safe even though the
2074
- /// TypeScript port in src/duplication.ts uses 32-bit words.
2075
- fn lcs_length(a: &[i32], b: &[i32]) -> usize {
2076
- if a.is_empty() || b.is_empty() {
2077
- return 0;
2078
- }
2079
- let word_count = a.len().div_ceil(64);
2080
- let mut position_masks: HashMap<i32, Vec<u64>> = HashMap::new();
2081
- for (index, &symbol) in a.iter().enumerate() {
2082
- position_masks
2083
- .entry(symbol)
2084
- .or_insert_with(|| vec![0; word_count])[index / 64] |= 1u64 << (index % 64);
2085
- }
2086
-
2087
- let mut v = vec![0u64; word_count];
2088
- for symbol in b {
2089
- let match_mask = position_masks.get(symbol);
2090
- // `(v << 1) | 1` shifts a carry bit across words; subtraction borrows across words.
2091
- let mut shift_carry = 1u64;
2092
- let mut borrow = 0u64;
2093
- for (word, slot) in v.iter_mut().enumerate() {
2094
- let previous = *slot;
2095
- let x = match_mask.map_or(0, |mask| mask[word]) | previous;
2096
- let shifted = (previous << 1) | shift_carry;
2097
- shift_carry = previous >> 63;
2098
- let (partial, underflow1) = x.overflowing_sub(shifted);
2099
- let (difference, underflow2) = partial.overflowing_sub(borrow);
2100
- borrow = u64::from(underflow1 || underflow2);
2101
- *slot = x & !difference;
2246
+ // Ascending so the visit order does not depend on the n-gram set's iteration order.
2247
+ touched.sort_unstable();
2248
+ for &left in &touched {
2249
+ let shared = std::mem::take(&mut shared_counts[left]);
2250
+ if shared * 100
2251
+ >= FILTRATION_PERCENT * blocks[left].ngrams.len().min(blocks[right].ngrams.len())
2252
+ {
2253
+ visit(left, right);
2254
+ }
2102
2255
  }
2256
+ touched.clear();
2103
2257
  }
2104
- v.iter().map(|word| word.count_ones() as usize).sum()
2105
2258
  }
2106
2259
 
2107
2260
  /// Redundant copies one group adds to duplicate_block_count; a faithful port of