code-gauge 3.0.0 → 4.0.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 (79) hide show
  1. package/README.md +83 -21
  2. package/dist/cli.cjs +3 -3
  3. package/dist/cli.cjs.map +1 -1
  4. package/dist/cli.js +3 -3
  5. package/dist/cli.js.map +1 -1
  6. package/dist/cliConfig.cjs +1 -1
  7. package/dist/cliConfig.cjs.map +1 -1
  8. package/dist/cliConfig.d.ts +11 -0
  9. package/dist/cliConfig.js +1 -1
  10. package/dist/cliConfig.js.map +1 -1
  11. package/dist/crossFileDuplication.cjs +1 -1
  12. package/dist/crossFileDuplication.cjs.map +1 -1
  13. package/dist/crossFileDuplication.js +1 -1
  14. package/dist/crossFileDuplication.js.map +1 -1
  15. package/dist/diffCommand.cjs +5 -0
  16. package/dist/diffCommand.cjs.map +1 -0
  17. package/dist/diffCommand.d.ts +17 -0
  18. package/dist/diffCommand.js +5 -0
  19. package/dist/diffCommand.js.map +1 -0
  20. package/dist/duplication.cjs +1 -1
  21. package/dist/duplication.cjs.map +1 -1
  22. package/dist/duplication.d.ts +20 -24
  23. package/dist/duplication.js +1 -1
  24. package/dist/duplication.js.map +1 -1
  25. package/dist/git.cjs +2 -0
  26. package/dist/git.cjs.map +1 -0
  27. package/dist/git.d.ts +27 -0
  28. package/dist/git.js +2 -0
  29. package/dist/git.js.map +1 -0
  30. package/dist/index.cjs +1 -1
  31. package/dist/index.d.ts +3 -2
  32. package/dist/index.js +1 -1
  33. package/dist/languages.cjs +1 -1
  34. package/dist/languages.cjs.map +1 -1
  35. package/dist/languages.d.ts +5 -0
  36. package/dist/languages.js +1 -1
  37. package/dist/languages.js.map +1 -1
  38. package/dist/metrics.cjs +1 -1
  39. package/dist/metrics.cjs.map +1 -1
  40. package/dist/metrics.d.ts +18 -5
  41. package/dist/metrics.js +1 -1
  42. package/dist/metrics.js.map +1 -1
  43. package/dist/nativeMetrics.cjs +3 -1
  44. package/dist/nativeMetrics.cjs.map +1 -1
  45. package/dist/nativeMetrics.d.ts +29 -10
  46. package/dist/nativeMetrics.js +3 -1
  47. package/dist/nativeMetrics.js.map +1 -1
  48. package/dist/regressionGate.cjs +2 -0
  49. package/dist/regressionGate.cjs.map +1 -0
  50. package/dist/regressionGate.d.ts +106 -0
  51. package/dist/regressionGate.js +2 -0
  52. package/dist/regressionGate.js.map +1 -0
  53. package/dist/scan.cjs +2 -0
  54. package/dist/scan.cjs.map +1 -0
  55. package/dist/scan.d.ts +55 -0
  56. package/dist/scan.js +2 -0
  57. package/dist/scan.js.map +1 -0
  58. package/dist/types.d.ts +18 -13
  59. package/native/Cargo.lock +523 -0
  60. package/native/Cargo.toml +45 -0
  61. package/native/build.rs +3 -0
  62. package/native/src/complexity.rs +627 -0
  63. package/native/src/dep_degree.rs +253 -0
  64. package/native/src/duplication.rs +2007 -0
  65. package/native/src/functions.rs +345 -0
  66. package/native/src/languages.rs +647 -0
  67. package/native/src/lib.rs +101 -0
  68. package/native/src/measure.rs +590 -0
  69. package/native/src/ncss.rs +263 -0
  70. package/native/src/types.rs +135 -0
  71. package/native/src/util.rs +139 -0
  72. package/package.json +16 -19
  73. package/scripts/buildNative.mjs +25 -0
  74. package/scripts/installNative.mjs +96 -0
  75. package/dist/ncss.cjs +0 -2
  76. package/dist/ncss.cjs.map +0 -1
  77. package/dist/ncss.d.ts +0 -17
  78. package/dist/ncss.js +0 -2
  79. package/dist/ncss.js.map +0 -1
@@ -0,0 +1,2007 @@
1
+ use indexmap::IndexMap;
2
+ use std::borrow::Cow;
3
+ use std::collections::{HashMap, HashSet};
4
+ use std::sync::OnceLock;
5
+ use tree_sitter::Node;
6
+
7
+ use crate::types::{
8
+ CrossFileCandidate, CrossFileToken, CrossFileTokenRange, DuplicateBlockOccurrence,
9
+ DuplicationMetrics,
10
+ };
11
+ use crate::util::{all_children, named_children, node_text, to_int32, Source};
12
+
13
+ /// Block-like nodes considered as whole-subtree duplicate candidates; see duplication.ts.
14
+ const DUPLICATE_BLOCK_TYPES: &[&str] = &[
15
+ "statement_block",
16
+ "block",
17
+ "compound_statement",
18
+ "body_statement",
19
+ "constructor_body",
20
+ "do_block",
21
+ "if_statement",
22
+ "for_statement",
23
+ "for_in_statement",
24
+ "enhanced_for_statement",
25
+ "for_range_loop",
26
+ "while_statement",
27
+ "do_statement",
28
+ "try_statement",
29
+ "try_with_resources_statement",
30
+ "with_statement",
31
+ "switch_statement",
32
+ "switch_expression",
33
+ "switch_case",
34
+ "switch_block_statement_group",
35
+ "switch_rule",
36
+ "case_clause",
37
+ "case_statement",
38
+ "match_statement",
39
+ "match_arm",
40
+ "except_clause",
41
+ "catch_clause",
42
+ "finally_clause",
43
+ "elif_clause",
44
+ "ensure",
45
+ "expression_statement",
46
+ "return_statement",
47
+ "return_expression",
48
+ "if_expression",
49
+ "for_expression",
50
+ "while_expression",
51
+ "loop_expression",
52
+ "match_expression",
53
+ "jsx_element",
54
+ "jsx_self_closing_element",
55
+ "if",
56
+ "unless",
57
+ "case",
58
+ "case_match",
59
+ "while",
60
+ "until",
61
+ "for",
62
+ "begin",
63
+ "when",
64
+ ];
65
+
66
+ /// Nodes whose direct named children form statement sequences scanned for copy-pasted runs.
67
+ const STATEMENT_CONTAINER_TYPES: &[&str] = &[
68
+ "program",
69
+ "source_file",
70
+ "translation_unit",
71
+ "module",
72
+ "statement_block",
73
+ "block",
74
+ "compound_statement",
75
+ "body_statement",
76
+ "constructor_body",
77
+ "class_body",
78
+ "block_body",
79
+ "do_block",
80
+ "do",
81
+ "ensure",
82
+ "then",
83
+ "else",
84
+ "case_statement",
85
+ "switch_block_statement_group",
86
+ "switch_rule",
87
+ "expression_case",
88
+ "type_case",
89
+ "communication_case",
90
+ "default_case",
91
+ ];
92
+
93
+ /// Identifier leaves anonymized by occurrence order so consistently renamed copies still match.
94
+ const ANONYMIZED_IDENTIFIER_TYPES: &[&str] = &[
95
+ "identifier",
96
+ "constant",
97
+ "instance_variable",
98
+ "class_variable",
99
+ "global_variable",
100
+ ];
101
+
102
+ const SHORTHAND_PROPERTY_TYPES: &[&str] = &[
103
+ "shorthand_property_identifier",
104
+ "shorthand_property_identifier_pattern",
105
+ ];
106
+
107
+ /// Literal leaves normalized to a kind tag so copies differing only in literal values still match.
108
+ const LITERAL_KIND_BY_TYPE: &[(&str, &str)] = &[
109
+ ("number", "#num"),
110
+ ("number_literal", "#num"),
111
+ ("integer", "#num"),
112
+ ("float", "#num"),
113
+ ("integer_literal", "#num"),
114
+ ("float_literal", "#num"),
115
+ ("int_literal", "#num"),
116
+ ("rune_literal", "#char"),
117
+ ("imaginary_literal", "#num"),
118
+ ("decimal_integer_literal", "#num"),
119
+ ("hex_integer_literal", "#num"),
120
+ ("octal_integer_literal", "#num"),
121
+ ("binary_integer_literal", "#num"),
122
+ ("decimal_floating_point_literal", "#num"),
123
+ ("hex_floating_point_literal", "#num"),
124
+ ("string_fragment", "#str"),
125
+ ("multiline_string_fragment", "#str"),
126
+ ("string_content", "#str"),
127
+ ("raw_string_content", "#str"),
128
+ ("heredoc_content", "#str"),
129
+ ("heredoc_beginning", "#heredoc"),
130
+ ("heredoc_end", "#heredoc"),
131
+ ("string", "#str"),
132
+ ("template_string", "#str"),
133
+ ("string_literal", "#str"),
134
+ ("interpreted_string_literal", "#str"),
135
+ ("raw_string_literal", "#str"),
136
+ ("raw_string", "#str"),
137
+ ("escape_sequence", "#str"),
138
+ ("char_literal", "#char"),
139
+ ("character_literal", "#char"),
140
+ ("character", "#char"),
141
+ ("regex_pattern", "#regex"),
142
+ ];
143
+
144
+ const COMMENT_TYPES: &[&str] = &["comment", "line_comment", "block_comment"];
145
+
146
+ /// Children of a string node that carry only literal content; anything else is interpolation.
147
+ const STRING_FRAGMENT_TYPES: &[&str] = &[
148
+ "string_fragment",
149
+ "multiline_string_fragment",
150
+ "string_content",
151
+ "raw_string_content",
152
+ "escape_sequence",
153
+ "heredoc_content",
154
+ "string_start",
155
+ "string_end",
156
+ ];
157
+
158
+ /// Grammar fields whose plain-`identifier` leaves are semantic API names, kept verbatim.
159
+ const SEMANTIC_NAME_FIELD_BY_PARENT_TYPE: &[(&str, &str)] = &[
160
+ ("call_expression", "function"),
161
+ ("method_invocation", "name"),
162
+ ("call", "method"),
163
+ ("attribute", "attribute"),
164
+ ("macro_invocation", "macro"),
165
+ ("field_access", "field"),
166
+ ("new_expression", "constructor"),
167
+ ("keyword_argument", "name"),
168
+ ("element_value_pair", "key"),
169
+ ("generic_function", "function"),
170
+ ("template_function", "name"),
171
+ ];
172
+
173
+ /// Kind tags whose raw source text re-enters the fingerprint in literal-dense (data-like) regions.
174
+ const VALUE_CARRYING_LITERAL_KINDS: &[&str] = &["#num", "#str", "#char", "#regex"];
175
+
176
+ /// String children that carry actual content (STRING_FRAGMENT_TYPES minus the delimiter nodes).
177
+ const STRING_CONTENT_FRAGMENT_TYPES: &[&str] = &[
178
+ "string_fragment",
179
+ "multiline_string_fragment",
180
+ "string_content",
181
+ "raw_string_content",
182
+ "escape_sequence",
183
+ "heredoc_content",
184
+ ];
185
+
186
+ const MIN_SEQUENCE_STATEMENT_COUNT: usize = 2;
187
+ const MAX_SEQUENCE_STATEMENT_COUNT: usize = 100;
188
+ const MAX_SELECTION_RERUN_COUNT: usize = 20;
189
+
190
+ /// Detection settings, defaulting to defaultDuplicationOptions in src/duplication.ts.
191
+ #[derive(Clone, Copy)]
192
+ pub struct DuplicationSettings {
193
+ /// Minimum normalized token count for a region to be considered for duplication.
194
+ pub min_tokens: usize,
195
+ /// Maximum normalized-token gap between adjacent duplicate groups merged into one gapped clone.
196
+ pub max_gap_tokens: usize,
197
+ /// Minimum LCS similarity percent for near-miss (Type-3) clone blocks; 100 disables near-miss.
198
+ pub min_similarity_percent: usize,
199
+ }
200
+
201
+ impl Default for DuplicationSettings {
202
+ fn default() -> Self {
203
+ DuplicationSettings {
204
+ min_tokens: 40,
205
+ max_gap_tokens: 30,
206
+ min_similarity_percent: 70,
207
+ }
208
+ }
209
+ }
210
+ /// N-gram size for the near-miss candidate index (NIL's default); see duplication.ts.
211
+ const NEAR_MISS_NGRAM_SIZE: usize = 5;
212
+ /// Filtration threshold: shared distinct n-grams over the smaller set; see duplication.ts.
213
+ const NEAR_MISS_FILTRATION_PERCENT: usize = 10;
214
+ /// Exclusive bound on shared content-bearing tokens (names and literal values); see duplication.ts.
215
+ const MIN_CONTENT_SIMILARITY_PERCENT: usize = 50;
216
+
217
+ /// See isLiteralDense in duplication.ts: >= 20% literal values marks a region as data-like.
218
+ fn is_literal_dense(literal_count: usize, token_count: usize) -> bool {
219
+ literal_count * 5 >= token_count
220
+ }
221
+
222
+ fn literal_kind_by_type() -> &'static HashMap<&'static str, &'static str> {
223
+ static MAP: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
224
+ MAP.get_or_init(|| LITERAL_KIND_BY_TYPE.iter().copied().collect())
225
+ }
226
+
227
+ fn semantic_name_field_by_parent_type() -> &'static HashMap<&'static str, &'static str> {
228
+ static MAP: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
229
+ MAP.get_or_init(|| SEMANTIC_NAME_FIELD_BY_PARENT_TYPE.iter().copied().collect())
230
+ }
231
+
232
+ fn pascal_case_regex() -> &'static regex::Regex {
233
+ static REGEX: OnceLock<regex::Regex> = OnceLock::new();
234
+ REGEX.get_or_init(|| regex::Regex::new(r"^\p{Lu}").unwrap())
235
+ }
236
+
237
+ struct Token<'a> {
238
+ is_id: bool,
239
+ text: Cow<'a, str>,
240
+ /// Two independent hashes of `text` (djb2 and FNV-1a); see the Token doc in duplication.ts.
241
+ text_hash: i32,
242
+ text_hash2: i32,
243
+ /// Hash pair of a value-carrying literal's value, folded into data-like region fingerprints.
244
+ literal_hash: Option<i32>,
245
+ literal_hash2: Option<i32>,
246
+ /// True for verbatim-kept NAMES (named grammar leaves); see the Token doc in duplication.ts.
247
+ is_name: bool,
248
+ start_row: usize,
249
+ end_row: usize,
250
+ }
251
+
252
+ struct TokenRange {
253
+ start_token_index: usize,
254
+ end_token_index: usize,
255
+ start_index: usize,
256
+ end_index: usize,
257
+ start_line: usize,
258
+ end_line: usize,
259
+ }
260
+
261
+ #[derive(Clone)]
262
+ struct DuplicateCandidate {
263
+ fingerprint: std::rc::Rc<str>,
264
+ token_count: usize,
265
+ start_token_index: usize,
266
+ end_token_index: usize,
267
+ start_index: usize,
268
+ end_index: usize,
269
+ start_line: usize,
270
+ end_line: usize,
271
+ }
272
+
273
+ /// Detects copy-pasted regions within a file; a faithful port of measureDuplication in
274
+ /// duplication.ts, including its JavaScript int32 hash arithmetic and insertion-order maps.
275
+ pub fn measure_duplication(
276
+ root: Node<'_>,
277
+ code_line_numbers: &HashSet<usize>,
278
+ code: &Source<'_>,
279
+ settings: &DuplicationSettings,
280
+ ) -> DuplicationMetrics {
281
+ let mut tokens: Vec<Token<'_>> = Vec::new();
282
+ let mut block_ranges: Vec<TokenRange> = Vec::new();
283
+ let mut container_statement_ranges: Vec<Vec<TokenRange>> = Vec::new();
284
+ collect_tokens(
285
+ root,
286
+ code,
287
+ &mut tokens,
288
+ &mut block_ranges,
289
+ &mut container_statement_ranges,
290
+ );
291
+
292
+ let literal_count_prefix = build_literal_count_prefix(&tokens);
293
+ let mut candidates = collect_block_candidates(
294
+ &tokens,
295
+ &literal_count_prefix,
296
+ &block_ranges,
297
+ settings.min_tokens,
298
+ );
299
+ candidates.extend(collect_sequence_candidates(
300
+ &tokens,
301
+ &literal_count_prefix,
302
+ &container_statement_ranges,
303
+ settings.min_tokens,
304
+ ));
305
+ let counted = select_maximal_duplicates(candidates);
306
+ let mut groups = merge_adjacent_groups(to_counted_groups(&counted), settings.max_gap_tokens);
307
+ let near_miss = collect_near_miss_groups(
308
+ &tokens,
309
+ &literal_count_prefix,
310
+ &block_ranges,
311
+ settings,
312
+ &mut groups,
313
+ );
314
+ // Near-miss clustering can merge exact groups away, leaving empty entries behind.
315
+ groups.retain(|group| !group.is_empty());
316
+ groups.extend(near_miss);
317
+ summarize_duplicates(&groups, code_line_numbers, &tokens)
318
+ }
319
+
320
+ /// Collects one file's contribution to cross-file clone detection: catalogued candidates (whole
321
+ /// block subtrees plus each statement container's full run) together with the normalized token
322
+ /// stream and statement structure. A faithful port of collectCrossFileDuplicateCandidates in
323
+ /// duplication.ts. Source indexes are emitted in UTF-16 code units (the tree is parsed from
324
+ /// UTF-16, so node byte offsets are halved) to match JavaScript string indexes.
325
+ pub fn collect_cross_file_file_data(
326
+ root: Node<'_>,
327
+ code: &Source<'_>,
328
+ min_tokens: usize,
329
+ ) -> (
330
+ Vec<CrossFileCandidate>,
331
+ Vec<CrossFileToken>,
332
+ Vec<Vec<CrossFileTokenRange>>,
333
+ ) {
334
+ let mut tokens: Vec<Token<'_>> = Vec::new();
335
+ let mut block_ranges: Vec<TokenRange> = Vec::new();
336
+ let mut container_statement_ranges: Vec<Vec<TokenRange>> = Vec::new();
337
+ collect_tokens(
338
+ root,
339
+ code,
340
+ &mut tokens,
341
+ &mut block_ranges,
342
+ &mut container_statement_ranges,
343
+ );
344
+ let literal_count_prefix = build_literal_count_prefix(&tokens);
345
+
346
+ let mut candidates =
347
+ collect_block_candidates(&tokens, &literal_count_prefix, &block_ranges, min_tokens);
348
+ // Single-statement containers are catalogued too: a file whose only top-level statement is not
349
+ // a block type (a lone exported table) must still be matchable when wholly copied.
350
+ for statements in &container_statement_ranges {
351
+ let (Some(first), Some(last)) = (statements.first(), statements.last()) else {
352
+ continue;
353
+ };
354
+ let token_count = last.end_token_index - first.start_token_index;
355
+ if token_count < min_tokens {
356
+ continue;
357
+ }
358
+ let fingerprint = format!(
359
+ "s:{}",
360
+ fingerprint_key(
361
+ &tokens,
362
+ &literal_count_prefix,
363
+ first.start_token_index,
364
+ last.end_token_index
365
+ )
366
+ );
367
+ candidates.push(to_candidate(
368
+ fingerprint,
369
+ first.start_token_index,
370
+ last.end_token_index,
371
+ first,
372
+ last,
373
+ ));
374
+ }
375
+
376
+ let candidate_payloads = dedupe_by_region(candidates)
377
+ .into_iter()
378
+ .map(|candidate| CrossFileCandidate {
379
+ fingerprint: candidate.fingerprint.to_string(),
380
+ token_count: candidate.token_count,
381
+ start_token_index: candidate.start_token_index,
382
+ end_token_index: candidate.end_token_index,
383
+ start_index: candidate.start_index / 2,
384
+ end_index: candidate.end_index / 2,
385
+ start_line: candidate.start_line,
386
+ end_line: candidate.end_line,
387
+ })
388
+ .collect();
389
+ let token_payloads = tokens
390
+ .iter()
391
+ .map(|token| CrossFileToken {
392
+ kind: if token.is_id { "id" } else { "text" },
393
+ text: token.text.to_string(),
394
+ text_hash: token.text_hash,
395
+ text_hash2: token.text_hash2,
396
+ literal_hash: token.literal_hash,
397
+ literal_hash2: token.literal_hash2,
398
+ is_name: token.is_name,
399
+ start_row: token.start_row,
400
+ end_row: token.end_row,
401
+ })
402
+ .collect();
403
+ let container_statement_payloads = container_statement_ranges
404
+ .iter()
405
+ .map(|statements| {
406
+ statements
407
+ .iter()
408
+ .map(|range| CrossFileTokenRange {
409
+ start_token_index: range.start_token_index,
410
+ end_token_index: range.end_token_index,
411
+ start_index: range.start_index / 2,
412
+ end_index: range.end_index / 2,
413
+ start_line: range.start_line,
414
+ end_line: range.end_line,
415
+ })
416
+ .collect()
417
+ })
418
+ .collect();
419
+ (
420
+ candidate_payloads,
421
+ token_payloads,
422
+ container_statement_payloads,
423
+ )
424
+ }
425
+
426
+ fn collect_tokens<'a>(
427
+ root: Node<'_>,
428
+ code: &Source<'a>,
429
+ tokens: &mut Vec<Token<'a>>,
430
+ block_ranges: &mut Vec<TokenRange>,
431
+ container_statement_ranges: &mut Vec<Vec<TokenRange>>,
432
+ ) {
433
+ fn visit<'a>(
434
+ node: Node<'_>,
435
+ code: &Source<'a>,
436
+ tokens: &mut Vec<Token<'a>>,
437
+ block_ranges: &mut Vec<TokenRange>,
438
+ container_statement_ranges: &mut Vec<Vec<TokenRange>>,
439
+ ) -> TokenRange {
440
+ let start_token_index = tokens.len();
441
+ let atomic_kind = if node.child_count() == 0 {
442
+ None
443
+ } else {
444
+ atomic_literal_kind(node)
445
+ };
446
+ if node.child_count() == 0 {
447
+ append_leaf_token(node, code, tokens);
448
+ } else if let Some(atomic_kind) = atomic_kind {
449
+ // Interpolation-free strings collapse to their kind tag so copies differing only in
450
+ // quote style or content still match.
451
+ tokens.push(make_text_token(
452
+ Cow::Borrowed(atomic_kind),
453
+ Some(literal_value_text(node, atomic_kind, code)),
454
+ false,
455
+ node.start_position().row,
456
+ node.end_position().row,
457
+ ));
458
+ } else if !COMMENT_TYPES.contains(&node.kind()) {
459
+ let mut statement_ranges: Vec<TokenRange> = Vec::new();
460
+ let is_container = node.is_named() && STATEMENT_CONTAINER_TYPES.contains(&node.kind());
461
+ for child in all_children(node) {
462
+ let child_range = visit(
463
+ child,
464
+ code,
465
+ tokens,
466
+ block_ranges,
467
+ container_statement_ranges,
468
+ );
469
+ if is_container && child.is_named() && !COMMENT_TYPES.contains(&child.kind()) {
470
+ statement_ranges.push(child_range);
471
+ }
472
+ }
473
+ // Single-statement containers are recorded too, mirroring collectTokens in
474
+ // duplication.ts: window enumeration needs two statements and yields nothing for them.
475
+ if is_container && !statement_ranges.is_empty() {
476
+ container_statement_ranges.push(statement_ranges);
477
+ }
478
+ }
479
+
480
+ let range = TokenRange {
481
+ start_token_index,
482
+ end_token_index: tokens.len(),
483
+ start_index: node.start_byte(),
484
+ end_index: node.end_byte(),
485
+ start_line: node.start_position().row + 1,
486
+ end_line: node.end_position().row + 1,
487
+ };
488
+ if node.is_named() && DUPLICATE_BLOCK_TYPES.contains(&node.kind()) {
489
+ block_ranges.push(TokenRange { ..range });
490
+ }
491
+ range
492
+ }
493
+
494
+ visit(root, code, tokens, block_ranges, container_statement_ranges);
495
+ }
496
+
497
+ /// The kind tag of a string-like node with no interpolation, or None to descend normally.
498
+ fn atomic_literal_kind(node: Node<'_>) -> Option<&'static str> {
499
+ let kind = if node.is_named() {
500
+ literal_kind_by_type().get(node.kind()).copied()
501
+ } else {
502
+ None
503
+ };
504
+ let kind = kind?;
505
+ if named_children(node)
506
+ .iter()
507
+ .all(|child| STRING_FRAGMENT_TYPES.contains(&child.kind()))
508
+ {
509
+ Some(kind)
510
+ } else {
511
+ None
512
+ }
513
+ }
514
+
515
+ fn append_leaf_token<'a>(node: Node<'_>, code: &Source<'a>, tokens: &mut Vec<Token<'a>>) {
516
+ if COMMENT_TYPES.contains(&node.kind()) {
517
+ return;
518
+ }
519
+
520
+ let start_row = node.start_position().row;
521
+ let end_row = node.end_position().row;
522
+ if node.is_named() && SHORTHAND_PROPERTY_TYPES.contains(&node.kind()) {
523
+ let text = node_text(node, code);
524
+ tokens.push(make_text_token(
525
+ Cow::Borrowed(text),
526
+ None,
527
+ true,
528
+ start_row,
529
+ end_row,
530
+ ));
531
+ tokens.push(make_text_token(
532
+ Cow::Borrowed(":"),
533
+ None,
534
+ false,
535
+ start_row,
536
+ end_row,
537
+ ));
538
+ tokens.push(Token {
539
+ is_id: true,
540
+ text: Cow::Borrowed(text),
541
+ text_hash: 0,
542
+ text_hash2: 0,
543
+ literal_hash: None,
544
+ literal_hash2: None,
545
+ is_name: false,
546
+ start_row,
547
+ end_row,
548
+ });
549
+ return;
550
+ }
551
+
552
+ if node.is_named()
553
+ && ANONYMIZED_IDENTIFIER_TYPES.contains(&node.kind())
554
+ && !is_semantic_name_leaf(node, code)
555
+ {
556
+ tokens.push(Token {
557
+ is_id: true,
558
+ text: Cow::Borrowed(node_text(node, code)),
559
+ text_hash: 0,
560
+ text_hash2: 0,
561
+ literal_hash: None,
562
+ literal_hash2: None,
563
+ is_name: false,
564
+ start_row,
565
+ end_row,
566
+ });
567
+ return;
568
+ }
569
+
570
+ // Anything else keeps its text: keywords, operators, punctuation, and semantic names.
571
+ let literal_kind = if node.is_named() {
572
+ literal_kind_by_type().get(node.kind()).copied()
573
+ } else {
574
+ None
575
+ };
576
+ tokens.push(match literal_kind {
577
+ Some(kind) => make_text_token(
578
+ Cow::Borrowed(kind),
579
+ Some(literal_value_text(node, kind, code)),
580
+ false,
581
+ start_row,
582
+ end_row,
583
+ ),
584
+ None => make_text_token(
585
+ Cow::Borrowed(node_text(node, code)),
586
+ None,
587
+ node.is_named(),
588
+ start_row,
589
+ end_row,
590
+ ),
591
+ });
592
+ }
593
+
594
+ fn make_text_token<'a>(
595
+ text: Cow<'a, str>,
596
+ literal_value_text: Option<Cow<'a, str>>,
597
+ is_name: bool,
598
+ start_row: usize,
599
+ end_row: usize,
600
+ ) -> Token<'a> {
601
+ let text_hash = hash_text(&text);
602
+ let text_hash2 = hash_text2(&text);
603
+ let (literal_hash, literal_hash2) = match literal_value_text {
604
+ Some(value) if VALUE_CARRYING_LITERAL_KINDS.contains(&text.as_ref()) => {
605
+ (Some(hash_text(&value)), Some(hash_text2(&value)))
606
+ }
607
+ _ => (None, None),
608
+ };
609
+ Token {
610
+ is_id: false,
611
+ text,
612
+ text_hash,
613
+ text_hash2,
614
+ literal_hash,
615
+ literal_hash2,
616
+ is_name,
617
+ start_row,
618
+ end_row,
619
+ }
620
+ }
621
+
622
+ /// The value of a literal as folded into literal-dense fingerprints; see literalValueText in
623
+ /// duplication.ts for the delimiter-independence rationale mirrored here.
624
+ fn literal_value_text<'a>(node: Node<'_>, kind: &str, code: &Source<'a>) -> Cow<'a, str> {
625
+ if kind != "#str" && kind != "#char" {
626
+ return Cow::Borrowed(node_text(node, code));
627
+ }
628
+ // Fragment leaves already carry bare content; a quote appearing there is content.
629
+ if STRING_CONTENT_FRAGMENT_TYPES.contains(&node.kind()) {
630
+ return Cow::Borrowed(node_text(node, code));
631
+ }
632
+ let fragments: Vec<&str> = named_children(node)
633
+ .iter()
634
+ .filter(|child| STRING_CONTENT_FRAGMENT_TYPES.contains(&child.kind()))
635
+ .map(|child| node_text(*child, code))
636
+ .collect();
637
+ if !fragments.is_empty() {
638
+ return Cow::Owned(fragments.concat());
639
+ }
640
+ Cow::Borrowed(strip_matching_quotes(node_text(node, code)))
641
+ }
642
+
643
+ /// Strips one matching pair of surrounding ASCII quotes, matching stripMatchingQuotes in
644
+ /// duplication.ts (quote characters are ASCII, so byte indexing is UTF-8 safe).
645
+ fn strip_matching_quotes(text: &str) -> &str {
646
+ let bytes = text.as_bytes();
647
+ if bytes.len() >= 2 {
648
+ let first = bytes[0];
649
+ if (first == b'"' || first == b'\'' || first == b'`') && bytes[bytes.len() - 1] == first {
650
+ return &text[1..text.len() - 1];
651
+ }
652
+ }
653
+ text
654
+ }
655
+
656
+ /// literal_count_prefix[i] = value-carrying literal tokens in tokens[0..i), for O(1) density checks.
657
+ fn build_literal_count_prefix(tokens: &[Token<'_>]) -> Vec<usize> {
658
+ let mut prefix = vec![0usize; tokens.len() + 1];
659
+ for (index, token) in tokens.iter().enumerate() {
660
+ prefix[index + 1] = prefix[index] + usize::from(token.literal_hash.is_some());
661
+ }
662
+ prefix
663
+ }
664
+
665
+ fn is_semantic_name_leaf(node: Node<'_>, code: &Source<'_>) -> bool {
666
+ let Some(parent) = node.parent() else {
667
+ return false;
668
+ };
669
+
670
+ // Java method references (`Foo::bar`) name their identifiers without grammar fields.
671
+ if parent.kind() == "method_reference" {
672
+ return true;
673
+ }
674
+
675
+ // `call` names its callee `method` in Ruby but `function` in Python; accept both fields.
676
+ if parent.kind() == "call"
677
+ && parent
678
+ .child_by_field_name("function")
679
+ .is_some_and(|function| function.id() == node.id())
680
+ {
681
+ return true;
682
+ }
683
+
684
+ // A Ruby constant receiving a call (`Alpha.new(...)`) names the invoked API.
685
+ if node.kind() == "constant"
686
+ && parent.kind() == "call"
687
+ && parent
688
+ .child_by_field_name("receiver")
689
+ .is_some_and(|receiver| receiver.id() == node.id())
690
+ {
691
+ return true;
692
+ }
693
+
694
+ // Java static receivers (`Alpha.run(...)`) name the invoked type; PascalCase is the
695
+ // discriminator because the tokenizer has no symbol table.
696
+ if parent.kind() == "method_invocation"
697
+ && parent
698
+ .child_by_field_name("object")
699
+ .is_some_and(|object| object.id() == node.id())
700
+ && pascal_case_regex().is_match(node_text(node, code))
701
+ {
702
+ return true;
703
+ }
704
+
705
+ // Qualified/generic callees are semantic in call position only.
706
+ if (parent.kind() == "scoped_identifier" || parent.kind() == "qualified_identifier")
707
+ && (parent
708
+ .child_by_field_name("name")
709
+ .is_some_and(|name| name.id() == node.id())
710
+ || parent
711
+ .child_by_field_name("path")
712
+ .is_some_and(|path| path.id() == node.id()))
713
+ {
714
+ let mut outer = parent;
715
+ while let Some(outer_parent) = outer.parent() {
716
+ if matches!(
717
+ outer_parent.kind(),
718
+ "scoped_identifier"
719
+ | "qualified_identifier"
720
+ | "generic_function"
721
+ | "template_function"
722
+ ) {
723
+ outer = outer_parent;
724
+ } else {
725
+ break;
726
+ }
727
+ }
728
+ if outer.parent().is_some_and(|call| {
729
+ call.kind() == "call_expression"
730
+ && call
731
+ .child_by_field_name("function")
732
+ .is_some_and(|function| function.id() == outer.id())
733
+ }) {
734
+ return true;
735
+ }
736
+ }
737
+
738
+ // Go struct-literal keys (`Config{Timeout: ...}`) have no `key` field in the grammar.
739
+ if parent.kind() == "literal_element"
740
+ && parent.parent().is_some_and(|grandparent| {
741
+ grandparent.kind() == "keyed_element"
742
+ && grandparent
743
+ .named_child(0)
744
+ .is_some_and(|first| first.id() == parent.id())
745
+ })
746
+ {
747
+ return true;
748
+ }
749
+
750
+ semantic_name_field_by_parent_type()
751
+ .get(parent.kind())
752
+ .is_some_and(|field| {
753
+ parent
754
+ .child_by_field_name(*field)
755
+ .is_some_and(|child| child.id() == node.id())
756
+ })
757
+ }
758
+
759
+ fn collect_block_candidates(
760
+ tokens: &[Token<'_>],
761
+ literal_count_prefix: &[usize],
762
+ block_ranges: &[TokenRange],
763
+ min_tokens: usize,
764
+ ) -> Vec<DuplicateCandidate> {
765
+ let mut candidates = Vec::new();
766
+ for range in block_ranges {
767
+ let token_count = range.end_token_index - range.start_token_index;
768
+ if token_count < min_tokens {
769
+ continue;
770
+ }
771
+ let fingerprint = format!(
772
+ "b:{}",
773
+ fingerprint_key(
774
+ tokens,
775
+ literal_count_prefix,
776
+ range.start_token_index,
777
+ range.end_token_index
778
+ )
779
+ );
780
+ candidates.push(to_candidate(
781
+ fingerprint,
782
+ range.start_token_index,
783
+ range.end_token_index,
784
+ range,
785
+ range,
786
+ ));
787
+ }
788
+ candidates
789
+ }
790
+
791
+ struct WindowOccurrences {
792
+ count: usize,
793
+ /// usize::MAX once occurrences span more than one container (the TS port uses -1).
794
+ container_index: usize,
795
+ min_start: usize,
796
+ max_start: usize,
797
+ }
798
+
799
+ #[derive(Clone, Copy, PartialEq, Eq, Hash)]
800
+ struct SequenceWindow {
801
+ container_index: usize,
802
+ start: usize,
803
+ length: usize,
804
+ }
805
+
806
+ struct ContainerWindows {
807
+ /// window_keys_by_start[start][length] is the rolling-hash key, None below the size thresholds.
808
+ window_keys_by_start: Vec<Vec<Option<i64>>>,
809
+ /// Per-statement fingerprint hashes, for the distinct-shape requirement on windows.
810
+ statement_hashes: Vec<i32>,
811
+ }
812
+
813
+ /// Enumerates runs of consecutive sibling statements; see collectSequenceCandidates in
814
+ /// duplication.ts for the maximality and sub-window rules replicated here.
815
+ fn collect_sequence_candidates(
816
+ tokens: &[Token<'_>],
817
+ literal_count_prefix: &[usize],
818
+ containers: &[Vec<TokenRange>],
819
+ min_tokens: usize,
820
+ ) -> Vec<DuplicateCandidate> {
821
+ let mut candidates = Vec::new();
822
+ let mut occurrences_by_window_key: HashMap<i64, WindowOccurrences> = HashMap::new();
823
+ let container_windows: Vec<ContainerWindows> = containers
824
+ .iter()
825
+ .map(|statements| enumerate_container_windows(tokens, statements, min_tokens))
826
+ .collect();
827
+ for (container_index, windows) in container_windows.iter().enumerate() {
828
+ for (start, row) in windows.window_keys_by_start.iter().enumerate() {
829
+ for window_key in row.iter().flatten() {
830
+ match occurrences_by_window_key.get_mut(window_key) {
831
+ Some(occurrences) => {
832
+ occurrences.count += 1;
833
+ if occurrences.container_index != container_index {
834
+ occurrences.container_index = usize::MAX;
835
+ }
836
+ occurrences.min_start = occurrences.min_start.min(start);
837
+ occurrences.max_start = occurrences.max_start.max(start);
838
+ }
839
+ None => {
840
+ occurrences_by_window_key.insert(
841
+ *window_key,
842
+ WindowOccurrences {
843
+ count: 1,
844
+ container_index,
845
+ min_start: start,
846
+ max_start: start,
847
+ },
848
+ );
849
+ }
850
+ }
851
+ }
852
+ }
853
+ }
854
+
855
+ // A window only "repeats" when two of its occurrences can coexist without overlapping.
856
+ let repeats = |window_key: Option<i64>, length: usize| -> bool {
857
+ let Some(window_key) = window_key else {
858
+ return false;
859
+ };
860
+ occurrences_by_window_key
861
+ .get(&window_key)
862
+ .is_some_and(|occurrences| {
863
+ occurrences.count >= 2
864
+ && (occurrences.container_index == usize::MAX
865
+ || occurrences.max_start - occurrences.min_start >= length)
866
+ })
867
+ };
868
+
869
+ // A window whose statements all share one normalized shape is a homogeneous preamble, not a
870
+ // copy-paste: two distinct per-statement shapes are required.
871
+ let has_distinct_statements = |window: SequenceWindow| -> bool {
872
+ let hashes = container_windows
873
+ .get(window.container_index)
874
+ .map(|windows| windows.statement_hashes.as_slice())
875
+ .unwrap_or(&[]);
876
+ let first_hash = hashes.get(window.start);
877
+ for index in window.start + 1..window.start + window.length {
878
+ if hashes.get(index) != first_hash {
879
+ return true;
880
+ }
881
+ }
882
+ false
883
+ };
884
+
885
+ let window_key_at =
886
+ |container_index: usize, start: Option<usize>, length: usize| -> Option<i64> {
887
+ let start = start?;
888
+ container_windows
889
+ .get(container_index)?
890
+ .window_keys_by_start
891
+ .get(start)?
892
+ .get(length)
893
+ .copied()
894
+ .flatten()
895
+ };
896
+
897
+ let mut maximal_windows: Vec<SequenceWindow> = Vec::new();
898
+ for (container_index, windows) in container_windows.iter().enumerate() {
899
+ for (start, row) in windows.window_keys_by_start.iter().enumerate() {
900
+ for (length, window_key) in row.iter().enumerate() {
901
+ if !repeats(*window_key, length)
902
+ || !has_distinct_statements(SequenceWindow {
903
+ container_index,
904
+ start,
905
+ length,
906
+ })
907
+ {
908
+ continue;
909
+ }
910
+ // Dominated windows are skipped: the one-statement extension also repeats.
911
+ let extended_right = window_key_at(container_index, Some(start), length + 1);
912
+ let extended_left =
913
+ window_key_at(container_index, start.checked_sub(1), length + 1);
914
+ if repeats(extended_right, length + 1) || repeats(extended_left, length + 1) {
915
+ continue;
916
+ }
917
+ maximal_windows.push(SequenceWindow {
918
+ container_index,
919
+ start,
920
+ length,
921
+ });
922
+ }
923
+ }
924
+ }
925
+
926
+ // Every emitted window exposes its repeating, unvisited sub-windows; lengths strictly
927
+ // decrease, so the worklist terminates.
928
+ let mut visited: HashSet<SequenceWindow> = maximal_windows.iter().copied().collect();
929
+ let mut frontier = maximal_windows;
930
+ while !frontier.is_empty() {
931
+ let mut emitted: Vec<SequenceWindow> = Vec::new();
932
+ for window in &frontier {
933
+ let statements = containers.get(window.container_index);
934
+ let first = statements.and_then(|statements| statements.get(window.start));
935
+ let last =
936
+ statements.and_then(|statements| statements.get(window.start + window.length - 1));
937
+ let (Some(first), Some(last)) = (first, last) else {
938
+ continue;
939
+ };
940
+ let fingerprint = format!(
941
+ "s:{}",
942
+ fingerprint_key(
943
+ tokens,
944
+ literal_count_prefix,
945
+ first.start_token_index,
946
+ last.end_token_index
947
+ )
948
+ );
949
+ candidates.push(to_candidate(
950
+ fingerprint,
951
+ first.start_token_index,
952
+ last.end_token_index,
953
+ first,
954
+ last,
955
+ ));
956
+ emitted.push(*window);
957
+ }
958
+ frontier = Vec::new();
959
+ for window in emitted {
960
+ for start in [window.start, window.start + 1] {
961
+ let sub_window = SequenceWindow {
962
+ container_index: window.container_index,
963
+ start,
964
+ length: window.length - 1,
965
+ };
966
+ let sub_window_key =
967
+ window_key_at(window.container_index, Some(start), sub_window.length);
968
+ if visited.contains(&sub_window)
969
+ || !repeats(sub_window_key, sub_window.length)
970
+ || !has_distinct_statements(sub_window)
971
+ {
972
+ continue;
973
+ }
974
+ visited.insert(sub_window);
975
+ frontier.push(sub_window);
976
+ }
977
+ }
978
+ }
979
+ candidates
980
+ }
981
+
982
+ fn enumerate_container_windows(
983
+ tokens: &[Token<'_>],
984
+ statements: &[TokenRange],
985
+ min_tokens: usize,
986
+ ) -> ContainerWindows {
987
+ let statement_hashes: Vec<i32> = statements
988
+ .iter()
989
+ .map(|statement| {
990
+ fingerprint_hash(
991
+ tokens,
992
+ statement.start_token_index,
993
+ statement.end_token_index,
994
+ )
995
+ })
996
+ .collect();
997
+ let mut window_keys_by_start: Vec<Vec<Option<i64>>> = Vec::new();
998
+ for start in 0..statements.len() {
999
+ let mut row: Vec<Option<i64>> = Vec::new();
1000
+ let mut hash: i64 = 5381;
1001
+ let mut token_count: usize = 0;
1002
+ let max_end = statements.len().min(start + MAX_SEQUENCE_STATEMENT_COUNT);
1003
+ for end in start..max_end {
1004
+ let statement = &statements[end];
1005
+ let statement_hash = statement_hashes[end];
1006
+ hash = combine_hashes(hash, statement_hash as i64);
1007
+ token_count += statement.end_token_index - statement.start_token_index;
1008
+ let statement_count = end - start + 1;
1009
+ let key =
1010
+ if statement_count >= MIN_SEQUENCE_STATEMENT_COUNT && token_count >= min_tokens {
1011
+ Some(combine_hashes(hash, statement_count as i64))
1012
+ } else {
1013
+ None
1014
+ };
1015
+ if row.len() <= statement_count {
1016
+ row.resize(statement_count + 1, None);
1017
+ }
1018
+ row[statement_count] = key;
1019
+ }
1020
+ window_keys_by_start.push(row);
1021
+ }
1022
+ ContainerWindows {
1023
+ window_keys_by_start,
1024
+ statement_hashes,
1025
+ }
1026
+ }
1027
+
1028
+ fn to_candidate(
1029
+ fingerprint: String,
1030
+ start_token_index: usize,
1031
+ end_token_index: usize,
1032
+ first: &TokenRange,
1033
+ last: &TokenRange,
1034
+ ) -> DuplicateCandidate {
1035
+ DuplicateCandidate {
1036
+ fingerprint: fingerprint.into(),
1037
+ token_count: end_token_index - start_token_index,
1038
+ start_token_index,
1039
+ end_token_index,
1040
+ start_index: first.start_index,
1041
+ end_index: last.end_index,
1042
+ start_line: first.start_line,
1043
+ end_line: last.end_line,
1044
+ }
1045
+ }
1046
+
1047
+ /// Content key of a token range; see fingerprintKey in duplication.ts for the format and rationale.
1048
+ fn fingerprint_key(
1049
+ tokens: &[Token<'_>],
1050
+ literal_count_prefix: &[usize],
1051
+ start_token_index: usize,
1052
+ end_token_index: usize,
1053
+ ) -> String {
1054
+ let clamped_end = end_token_index.min(tokens.len());
1055
+ let literal_count = literal_count_prefix.get(clamped_end).copied().unwrap_or(0)
1056
+ - literal_count_prefix
1057
+ .get(start_token_index)
1058
+ .copied()
1059
+ .unwrap_or(0);
1060
+ let literal_dense = is_literal_dense(literal_count, end_token_index - start_token_index);
1061
+ let (primary, secondary) =
1062
+ fingerprint_hash_pair(tokens, start_token_index, end_token_index, literal_dense);
1063
+ format!(
1064
+ "{primary}:{secondary}:{}",
1065
+ end_token_index - start_token_index
1066
+ )
1067
+ }
1068
+
1069
+ /// A single 32-bit summary of a range for the coarse rolling-hash phase. Deliberately
1070
+ /// density-agnostic; see fingerprintHash in duplication.ts for the rationale.
1071
+ fn fingerprint_hash(tokens: &[Token<'_>], start_token_index: usize, end_token_index: usize) -> i32 {
1072
+ let (primary, secondary) =
1073
+ fingerprint_hash_pair(tokens, start_token_index, end_token_index, false);
1074
+ primary ^ secondary.wrapping_mul(31)
1075
+ }
1076
+
1077
+ /// Two independent 32-bit hashes over the normalized token sequence, replicating the JavaScript
1078
+ /// int32 arithmetic of fingerprintHashPair in duplication.ts exactly.
1079
+ fn fingerprint_hash_pair(
1080
+ tokens: &[Token<'_>],
1081
+ start_token_index: usize,
1082
+ end_token_index: usize,
1083
+ fold_literal_values: bool,
1084
+ ) -> (i32, i32) {
1085
+ let clamped_end = end_token_index.min(tokens.len());
1086
+ let mut index_by_identifier: HashMap<&str, usize> = HashMap::new();
1087
+ let mut index_hashes: Vec<(i32, i32)> = Vec::new();
1088
+ let mut primary: i32 = 5381;
1089
+ let mut secondary: i32 = 52_711;
1090
+ for token in &tokens[start_token_index..clamped_end] {
1091
+ // Each accumulator consumes its own independent per-token hash; see fingerprintHashPair
1092
+ // in duplication.ts.
1093
+ let (part, part2) = if token.is_id {
1094
+ let next_index = index_by_identifier.len();
1095
+ let identifier_index = *index_by_identifier
1096
+ .entry(token.text.as_ref())
1097
+ .or_insert(next_index);
1098
+ if identifier_index == index_hashes.len() {
1099
+ let name = format!("${identifier_index}");
1100
+ index_hashes.push((hash_text(&name), hash_text2(&name)));
1101
+ }
1102
+ index_hashes[identifier_index]
1103
+ } else {
1104
+ (token.text_hash, token.text_hash2)
1105
+ };
1106
+ primary = primary.wrapping_mul(31).wrapping_add(part);
1107
+ secondary = secondary.wrapping_mul(37) ^ part2;
1108
+ if fold_literal_values {
1109
+ if let (Some(literal_hash), Some(literal_hash2)) =
1110
+ (token.literal_hash, token.literal_hash2)
1111
+ {
1112
+ primary = primary.wrapping_mul(31).wrapping_add(literal_hash);
1113
+ secondary = secondary.wrapping_mul(37) ^ literal_hash2;
1114
+ }
1115
+ }
1116
+ }
1117
+ (primary, secondary)
1118
+ }
1119
+
1120
+ /// djb2-style hash over UTF-16 code units, matching hashText in duplication.ts exactly.
1121
+ pub fn hash_text(text: &str) -> i32 {
1122
+ let mut hash: i32 = 5381;
1123
+ for unit in text.encode_utf16() {
1124
+ hash = hash.wrapping_mul(33) ^ (unit as i32);
1125
+ }
1126
+ hash
1127
+ }
1128
+
1129
+ /// FNV-1a over UTF-16 code units, matching hashText2 in duplication.ts exactly.
1130
+ fn hash_text2(text: &str) -> i32 {
1131
+ let mut hash: i32 = -2_128_831_035; // 2166136261 as int32 (the FNV-1a offset basis)
1132
+ for unit in text.encode_utf16() {
1133
+ hash = (hash ^ (unit as i32)).wrapping_mul(16_777_619);
1134
+ }
1135
+ hash
1136
+ }
1137
+
1138
+ /// `Math.imul(hash, 31) + value`: the sum is NOT wrapped to int32 in JS, so it stays i64 here.
1139
+ fn combine_hashes(hash: i64, value: i64) -> i64 {
1140
+ (to_int32(hash).wrapping_mul(31)) as i64 + value
1141
+ }
1142
+
1143
+ /// Keeps only maximal, non-overlapping duplicates; see selectMaximalDuplicates in duplication.ts.
1144
+ fn select_maximal_duplicates(
1145
+ candidates: Vec<DuplicateCandidate>,
1146
+ ) -> IndexMap<std::rc::Rc<str>, Vec<DuplicateCandidate>> {
1147
+ let mut by_fingerprint: IndexMap<std::rc::Rc<str>, Vec<DuplicateCandidate>> = IndexMap::new();
1148
+ for candidate in candidates {
1149
+ by_fingerprint
1150
+ .entry(candidate.fingerprint.clone())
1151
+ .or_default()
1152
+ .push(candidate);
1153
+ }
1154
+
1155
+ let groups: Vec<Vec<DuplicateCandidate>> = by_fingerprint
1156
+ .into_values()
1157
+ .map(dedupe_by_region)
1158
+ .filter(|group| group.len() >= 2)
1159
+ .collect();
1160
+ // Greedy order ranks by total coverage (region size × copies).
1161
+ let group_size_by_fingerprint: HashMap<std::rc::Rc<str>, usize> = groups
1162
+ .iter()
1163
+ .map(|group| {
1164
+ (
1165
+ group
1166
+ .first()
1167
+ .map(|first| first.fingerprint.clone())
1168
+ .unwrap_or_else(|| std::rc::Rc::from("")),
1169
+ group.len(),
1170
+ )
1171
+ })
1172
+ .collect();
1173
+ let coverage = |candidate: &DuplicateCandidate| -> usize {
1174
+ candidate.token_count
1175
+ * group_size_by_fingerprint
1176
+ .get(&candidate.fingerprint)
1177
+ .copied()
1178
+ .unwrap_or(1)
1179
+ };
1180
+ let mut duplicates: Vec<DuplicateCandidate> = groups.into_iter().flatten().collect();
1181
+ duplicates.sort_by_key(|candidate| std::cmp::Reverse(coverage(candidate)));
1182
+
1183
+ // Greedy selection can keep a candidate whose group ends up below two survivors; the largest
1184
+ // failed group is removed and the selection reruns, one group at a time.
1185
+ let mut rerun = 0;
1186
+ loop {
1187
+ let mut kept_regions: Vec<(usize, usize)> = Vec::new();
1188
+ let mut counted: IndexMap<std::rc::Rc<str>, Vec<DuplicateCandidate>> = IndexMap::new();
1189
+ for candidate in &duplicates {
1190
+ if kept_regions
1191
+ .iter()
1192
+ .any(|region| region.0 < candidate.end_index && candidate.start_index < region.1)
1193
+ {
1194
+ continue;
1195
+ }
1196
+ kept_regions.push((candidate.start_index, candidate.end_index));
1197
+ counted
1198
+ .entry(candidate.fingerprint.clone())
1199
+ .or_default()
1200
+ .push(candidate.clone());
1201
+ }
1202
+
1203
+ let mut failed_fingerprint: Option<std::rc::Rc<str>> = None;
1204
+ let mut failed_token_count: i64 = -1;
1205
+ for (fingerprint, group) in &counted {
1206
+ let token_count = group
1207
+ .first()
1208
+ .map(|first| first.token_count as i64)
1209
+ .unwrap_or(0);
1210
+ if group.len() < 2 && token_count > failed_token_count {
1211
+ failed_fingerprint = Some(fingerprint.clone());
1212
+ failed_token_count = token_count;
1213
+ }
1214
+ }
1215
+ // No failed fingerprint means every counted group kept at least two survivors.
1216
+ let Some(failed_fingerprint) = failed_fingerprint else {
1217
+ return counted;
1218
+ };
1219
+ if rerun >= MAX_SELECTION_RERUN_COUNT {
1220
+ counted.retain(|_, group| group.len() >= 2);
1221
+ return counted;
1222
+ }
1223
+
1224
+ duplicates.retain(|candidate| candidate.fingerprint != failed_fingerprint);
1225
+ rerun += 1;
1226
+ }
1227
+ }
1228
+
1229
+ /// Drops candidates covering the same source region (a block and the statement run spanning it).
1230
+ fn dedupe_by_region(group: Vec<DuplicateCandidate>) -> Vec<DuplicateCandidate> {
1231
+ let mut by_region: IndexMap<(usize, usize), DuplicateCandidate> = IndexMap::new();
1232
+ for candidate in group {
1233
+ let key = (candidate.start_index, candidate.end_index);
1234
+ match by_region.get(&key) {
1235
+ Some(existing) if candidate.token_count <= existing.token_count => {}
1236
+ _ => {
1237
+ by_region.insert(key, candidate);
1238
+ }
1239
+ }
1240
+ }
1241
+ by_region.into_values().collect()
1242
+ }
1243
+
1244
+ /// A contiguous run of matched tokens; gapped (merged) duplicates carry several per occurrence.
1245
+ #[derive(Clone)]
1246
+ struct CountedOccurrence {
1247
+ segments: Vec<(usize, usize)>,
1248
+ /// Set on a retained group's occurrences that a partial gapped merge also paired into a
1249
+ /// merged group: their spans are counted there, so block counting must not count them again.
1250
+ shared_with_merged_group: bool,
1251
+ /// Sum of segment token counts (the gap tokens are not matched content).
1252
+ token_count: usize,
1253
+ start_token_index: usize,
1254
+ end_token_index: usize,
1255
+ start_line: usize,
1256
+ end_line: usize,
1257
+ }
1258
+
1259
+ fn to_counted_groups(
1260
+ counted: &IndexMap<std::rc::Rc<str>, Vec<DuplicateCandidate>>,
1261
+ ) -> Vec<Vec<CountedOccurrence>> {
1262
+ let mut groups: Vec<Vec<CountedOccurrence>> = Vec::new();
1263
+ for group in counted.values() {
1264
+ let mut occurrences: Vec<CountedOccurrence> = group
1265
+ .iter()
1266
+ .map(|candidate| CountedOccurrence {
1267
+ shared_with_merged_group: false,
1268
+ segments: vec![(candidate.start_token_index, candidate.end_token_index)],
1269
+ token_count: candidate.token_count,
1270
+ start_token_index: candidate.start_token_index,
1271
+ end_token_index: candidate.end_token_index,
1272
+ start_line: candidate.start_line,
1273
+ end_line: candidate.end_line,
1274
+ })
1275
+ .collect();
1276
+ occurrences
1277
+ .sort_by_key(|occurrence| (occurrence.start_token_index, occurrence.end_token_index));
1278
+ groups.push(occurrences);
1279
+ }
1280
+ groups
1281
+ }
1282
+
1283
+ /// Merges duplicate groups separated by a small token gap into one gapped (Type-3) clone group;
1284
+ /// see mergeAdjacentGroups in duplication.ts for the pairing, partial-merge (unequal
1285
+ /// cardinalities: the fully-paired group is subsumed, the other is retained with all its
1286
+ /// occurrences), and fixpoint/termination rules replicated here.
1287
+ fn merge_adjacent_groups(
1288
+ mut groups: Vec<Vec<CountedOccurrence>>,
1289
+ max_gap_tokens: usize,
1290
+ ) -> Vec<Vec<CountedOccurrence>> {
1291
+ if max_gap_tokens == 0 || groups.len() < 2 {
1292
+ return groups;
1293
+ }
1294
+ groups.sort_by_key(|group| group_sort_key(group));
1295
+ let mut restart = true;
1296
+ while restart {
1297
+ restart = false;
1298
+ 'outer: for left_index in 0..groups.len() {
1299
+ for right_index in left_index + 1..groups.len() {
1300
+ let forward =
1301
+ merge_groups(&groups[left_index], &groups[right_index], max_gap_tokens);
1302
+ let swapped = forward.is_none();
1303
+ let result = forward.or_else(|| {
1304
+ merge_groups(&groups[right_index], &groups[left_index], max_gap_tokens)
1305
+ });
1306
+ let Some(result) = result else {
1307
+ continue;
1308
+ };
1309
+ let (left_consumed, right_consumed) = if swapped {
1310
+ (result.second_consumed, result.first_consumed)
1311
+ } else {
1312
+ (result.first_consumed, result.second_consumed)
1313
+ };
1314
+ // A partial merge retains the not-fully-consumed group with ALL its occurrences,
1315
+ // so its paired occurrences now also live inside the merged group's occurrences:
1316
+ // mark them so duplicate_block_count counts each token span once.
1317
+ if left_consumed && right_consumed {
1318
+ groups[left_index] = result.merged;
1319
+ groups.remove(right_index);
1320
+ } else if right_consumed {
1321
+ groups[right_index] = result.merged;
1322
+ for &occurrence_index in &result.paired_retained_indexes {
1323
+ groups[left_index][occurrence_index].shared_with_merged_group = true;
1324
+ }
1325
+ } else {
1326
+ groups[left_index] = result.merged;
1327
+ for &occurrence_index in &result.paired_retained_indexes {
1328
+ groups[right_index][occurrence_index].shared_with_merged_group = true;
1329
+ }
1330
+ }
1331
+ groups.sort_by_key(|group| group_sort_key(group));
1332
+ restart = true;
1333
+ break 'outer;
1334
+ }
1335
+ }
1336
+ }
1337
+ groups
1338
+ }
1339
+
1340
+ fn group_sort_key(group: &[CountedOccurrence]) -> (usize, usize) {
1341
+ group
1342
+ .first()
1343
+ .map(|first| (first.start_token_index, first.end_token_index))
1344
+ .unwrap_or((0, 0))
1345
+ }
1346
+
1347
+ struct MergeResult {
1348
+ merged: Vec<CountedOccurrence>,
1349
+ /// Whether every occurrence of the respective input group was paired into the merge.
1350
+ first_consumed: bool,
1351
+ second_consumed: bool,
1352
+ /// Indexes (into the retained, not fully consumed group) of the occurrences that were paired.
1353
+ paired_retained_indexes: Vec<usize>,
1354
+ }
1355
+
1356
+ /// Pairs `second` occurrences with gap-preceding `first` occurrences, greedily in source order;
1357
+ /// a faithful port of mergeGroups in duplication.ts (at least two pairs, at least one group fully
1358
+ /// consumed, merged spans never overlap).
1359
+ fn merge_groups(
1360
+ first: &[CountedOccurrence],
1361
+ second: &[CountedOccurrence],
1362
+ max_gap_tokens: usize,
1363
+ ) -> Option<MergeResult> {
1364
+ // Occurrences a previous partial merge already paired into a merged group must not pair
1365
+ // again: their spans already live inside that merged group, so re-pairing them would assemble
1366
+ // a second, competing merged group instead of letting the existing merged group extend (and
1367
+ // would count the same span twice). Consumption is still judged against the FULL group, so a
1368
+ // group holding shared occurrences is never subsumed away.
1369
+ let leadings: Vec<usize> = (0..first.len())
1370
+ .filter(|&index| !first[index].shared_with_merged_group)
1371
+ .collect();
1372
+ let trailings: Vec<usize> = (0..second.len())
1373
+ .filter(|&index| !second[index].shared_with_merged_group)
1374
+ .collect();
1375
+ let mut pairs: Vec<(usize, usize)> = Vec::new();
1376
+ let mut leading_position = 0usize;
1377
+ let mut previous_trailing_end: Option<usize> = None;
1378
+ for &trailing_index in &trailings {
1379
+ let trailing = &second[trailing_index];
1380
+ // Leadings ending too far before this trailing can never pair a later (even farther) one.
1381
+ while leading_position < leadings.len()
1382
+ && first[leadings[leading_position]].end_token_index + max_gap_tokens
1383
+ < trailing.start_token_index
1384
+ {
1385
+ leading_position += 1;
1386
+ }
1387
+ if let Some(&leading_index) = leadings.get(leading_position) {
1388
+ let leading = &first[leading_index];
1389
+ if leading.end_token_index <= trailing.start_token_index
1390
+ && previous_trailing_end.is_none_or(|end| leading.start_token_index >= end)
1391
+ {
1392
+ pairs.push((leading_index, trailing_index));
1393
+ previous_trailing_end = Some(trailing.end_token_index);
1394
+ leading_position += 1;
1395
+ }
1396
+ }
1397
+ }
1398
+ let first_consumed = pairs.len() == first.len();
1399
+ let second_consumed = pairs.len() == second.len();
1400
+ if pairs.len() < 2 || (!first_consumed && !second_consumed) {
1401
+ return None;
1402
+ }
1403
+ let paired_retained_indexes: Vec<usize> = if first_consumed == second_consumed {
1404
+ Vec::new()
1405
+ } else if first_consumed {
1406
+ pairs.iter().map(|&(_, trailing)| trailing).collect()
1407
+ } else {
1408
+ pairs.iter().map(|&(leading, _)| leading).collect()
1409
+ };
1410
+ let merged = pairs
1411
+ .iter()
1412
+ .map(|&(leading_index, trailing_index)| {
1413
+ let leading = &first[leading_index];
1414
+ let trailing = &second[trailing_index];
1415
+ CountedOccurrence {
1416
+ // A merged occurrence is a fresh span combination; it inherits no shared marks.
1417
+ shared_with_merged_group: false,
1418
+ segments: [leading.segments.clone(), trailing.segments.clone()].concat(),
1419
+ token_count: leading.token_count + trailing.token_count,
1420
+ start_token_index: leading.start_token_index,
1421
+ end_token_index: trailing.end_token_index,
1422
+ start_line: leading.start_line,
1423
+ end_line: trailing.end_line,
1424
+ }
1425
+ })
1426
+ .collect();
1427
+ Some(MergeResult {
1428
+ merged,
1429
+ first_consumed,
1430
+ second_consumed,
1431
+ paired_retained_indexes,
1432
+ })
1433
+ }
1434
+
1435
+ /// Detects near-miss (Type-3) clone groups among block candidates the exact pipeline left
1436
+ /// unreported; a faithful port of collectNearMissGroups in duplication.ts (NIL-style n-gram
1437
+ /// filtration, then token-level LCS with NiCad-style per-fragment similarity, then transitive
1438
+ /// clustering of verified pairs).
1439
+ fn collect_near_miss_groups(
1440
+ tokens: &[Token<'_>],
1441
+ literal_count_prefix: &[usize],
1442
+ block_ranges: &[TokenRange],
1443
+ settings: &DuplicationSettings,
1444
+ reported_groups: &mut [Vec<CountedOccurrence>],
1445
+ ) -> Vec<Vec<CountedOccurrence>> {
1446
+ if settings.min_similarity_percent >= 100 {
1447
+ return Vec::new();
1448
+ }
1449
+ let mut eligible: Vec<&TokenRange> = block_ranges
1450
+ .iter()
1451
+ .filter(|range| {
1452
+ let token_count = range.end_token_index - range.start_token_index;
1453
+ let literal_count = literal_count_prefix
1454
+ .get(range.end_token_index)
1455
+ .copied()
1456
+ .unwrap_or(0)
1457
+ - literal_count_prefix
1458
+ .get(range.start_token_index)
1459
+ .copied()
1460
+ .unwrap_or(0);
1461
+ token_count >= settings.min_tokens && !is_literal_dense(literal_count, token_count)
1462
+ })
1463
+ .collect();
1464
+ eligible.sort_by_key(|range| {
1465
+ (
1466
+ range.start_token_index,
1467
+ std::cmp::Reverse(range.end_token_index),
1468
+ )
1469
+ });
1470
+ let comparable = select_comparable_blocks(&eligible);
1471
+ if comparable.len() < 2 {
1472
+ return Vec::new();
1473
+ }
1474
+
1475
+ // Reported-group indices whose occurrences overlap each comparable block: such blocks anchor
1476
+ // near-miss comparisons but are never re-reported.
1477
+ let touched_groups_by_block: Vec<Vec<usize>> = comparable
1478
+ .iter()
1479
+ .map(|range| {
1480
+ reported_groups
1481
+ .iter()
1482
+ .enumerate()
1483
+ .filter(|(_, group)| {
1484
+ group.iter().any(|occurrence| {
1485
+ occurrence.start_token_index < range.end_token_index
1486
+ && range.start_token_index < occurrence.end_token_index
1487
+ })
1488
+ })
1489
+ .map(|(group_index, _)| group_index)
1490
+ .collect()
1491
+ })
1492
+ .collect();
1493
+
1494
+ // Interned per call so a file's symbol ids (and thus its n-gram hashes) never depend on which
1495
+ // other files the process measured before it.
1496
+ let mut symbol_id_by_token_hashes: HashMap<(i32, i32, i32, i32), i32> = HashMap::new();
1497
+ let sequences: Vec<NormalizedBlock> = comparable
1498
+ .iter()
1499
+ .map(|range| normalize_block_sequence(tokens, range, &mut symbol_id_by_token_hashes))
1500
+ .collect();
1501
+ let ngram_sets: Vec<HashSet<i32>> = sequences
1502
+ .iter()
1503
+ .map(|block| collect_ngram_set(&block.sequence))
1504
+ .collect();
1505
+ let shared_counts = count_shared_ngrams(&ngram_sets);
1506
+
1507
+ let mut parent: Vec<usize> = (0..comparable.len()).collect();
1508
+ fn find(parent: &mut [usize], mut index: usize) -> usize {
1509
+ let mut root = index;
1510
+ while parent[root] != root {
1511
+ root = parent[root];
1512
+ }
1513
+ while parent[index] != root {
1514
+ let next = parent[index];
1515
+ parent[index] = root;
1516
+ index = next;
1517
+ }
1518
+ root
1519
+ }
1520
+ for (&(left_index, right_index), &shared) in &shared_counts {
1521
+ let left = &sequences[left_index];
1522
+ let right = &sequences[right_index];
1523
+ // Two already-reported blocks have nothing new to contribute to each other.
1524
+ if !touched_groups_by_block[left_index].is_empty()
1525
+ && !touched_groups_by_block[right_index].is_empty()
1526
+ {
1527
+ continue;
1528
+ }
1529
+ let min_ngrams = ngram_sets[left_index]
1530
+ .len()
1531
+ .min(ngram_sets[right_index].len());
1532
+ if shared * 100 < NEAR_MISS_FILTRATION_PERCENT * min_ngrams {
1533
+ continue;
1534
+ }
1535
+ // A structural match must be backed by shared content (names and literal values); the
1536
+ // bound is exclusive, matching duplication.ts.
1537
+ if content_overlap(left, right) * 100
1538
+ <= MIN_CONTENT_SIMILARITY_PERCENT * left.content_total.max(right.content_total)
1539
+ {
1540
+ continue;
1541
+ }
1542
+ // Per-fragment similarity against the larger block (NiCad semantics).
1543
+ if lcs_length(&left.sequence, &right.sequence) * 100
1544
+ >= settings.min_similarity_percent * left.sequence.len().max(right.sequence.len())
1545
+ {
1546
+ let left_root = find(&mut parent, left_index);
1547
+ let right_root = find(&mut parent, right_index);
1548
+ parent[left_root.max(right_root)] = left_root.min(right_root);
1549
+ }
1550
+ }
1551
+
1552
+ let mut members_by_root: IndexMap<usize, Vec<usize>> = IndexMap::new();
1553
+ for index in 0..comparable.len() {
1554
+ let root = find(&mut parent, index);
1555
+ members_by_root.entry(root).or_default().push(index);
1556
+ }
1557
+ let to_occurrence = |range: &TokenRange| CountedOccurrence {
1558
+ shared_with_merged_group: false,
1559
+ segments: vec![(range.start_token_index, range.end_token_index)],
1560
+ token_count: range.end_token_index - range.start_token_index,
1561
+ start_token_index: range.start_token_index,
1562
+ end_token_index: range.end_token_index,
1563
+ start_line: range.start_line,
1564
+ end_line: range.end_line,
1565
+ };
1566
+ let mut groups: Vec<Vec<CountedOccurrence>> = Vec::new();
1567
+ for members in members_by_root.values() {
1568
+ if members.len() < 2 {
1569
+ continue;
1570
+ }
1571
+ let uncovered: Vec<usize> = members
1572
+ .iter()
1573
+ .copied()
1574
+ .filter(|&index| touched_groups_by_block[index].is_empty())
1575
+ .collect();
1576
+ let covered: Vec<usize> = members
1577
+ .iter()
1578
+ .copied()
1579
+ .filter(|&index| !touched_groups_by_block[index].is_empty())
1580
+ .collect();
1581
+ if covered.is_empty() {
1582
+ groups.push(
1583
+ members
1584
+ .iter()
1585
+ .map(|&index| to_occurrence(comparable[index]))
1586
+ .collect(),
1587
+ );
1588
+ continue;
1589
+ }
1590
+ if uncovered.is_empty() {
1591
+ continue;
1592
+ }
1593
+ // An anchored cluster extends a reported group only when that group lies entirely inside
1594
+ // the cluster; see collectNearMissGroups in duplication.ts.
1595
+ let overlaps_member = |occurrence: &CountedOccurrence| {
1596
+ members.iter().any(|&index| {
1597
+ let range = comparable[index];
1598
+ occurrence.start_token_index < range.end_token_index
1599
+ && range.start_token_index < occurrence.end_token_index
1600
+ })
1601
+ };
1602
+ // Ascending by construction: BTreeSet iteration is sorted and filter preserves order.
1603
+ let fully_clustered: Vec<usize> = covered
1604
+ .iter()
1605
+ .flat_map(|&index| touched_groups_by_block[index].iter().copied())
1606
+ .collect::<std::collections::BTreeSet<usize>>()
1607
+ .into_iter()
1608
+ .filter(|&group_index| {
1609
+ let group = &reported_groups[group_index];
1610
+ !group.is_empty() && group.iter().all(&overlaps_member)
1611
+ })
1612
+ .collect();
1613
+ if let Some((&target_index, source_indexes)) = fully_clustered.split_first() {
1614
+ // Rebuild the component as ONE group with one coalesced occurrence per member block;
1615
+ // see collectNearMissGroups in duplication.ts.
1616
+ let mut consumed: HashSet<(usize, usize)> = HashSet::new();
1617
+ let mut merged: Vec<CountedOccurrence> = Vec::new();
1618
+ for &member_index in members {
1619
+ let range = comparable[member_index];
1620
+ // Occurrences of ONE group are distinct copies; only fragments from DIFFERENT
1621
+ // groups belong to the same copy. Consecutive position-order slices keep the
1622
+ // coalesced spans disjoint; see collectNearMissGroups in duplication.ts.
1623
+ let mut fragments: Vec<(CountedOccurrence, usize)> = Vec::new();
1624
+ for &group_index in &fully_clustered {
1625
+ for (occurrence_index, occurrence) in
1626
+ reported_groups[group_index].iter().enumerate()
1627
+ {
1628
+ if !consumed.contains(&(group_index, occurrence_index))
1629
+ && occurrence.start_token_index < range.end_token_index
1630
+ && range.start_token_index < occurrence.end_token_index
1631
+ {
1632
+ consumed.insert((group_index, occurrence_index));
1633
+ fragments.push((occurrence.clone(), group_index));
1634
+ }
1635
+ }
1636
+ }
1637
+ fragments.sort_by_key(|(occurrence, _)| {
1638
+ (occurrence.start_token_index, occurrence.end_token_index)
1639
+ });
1640
+ let had_fragments = !fragments.is_empty();
1641
+ let mut copy_parts: Vec<CountedOccurrence> = Vec::new();
1642
+ let mut copy_groups: HashSet<usize> = HashSet::new();
1643
+ for (occurrence, group_index) in fragments {
1644
+ if copy_groups.contains(&group_index) {
1645
+ merged.push(coalesce_occurrences(std::mem::take(&mut copy_parts)));
1646
+ copy_groups.clear();
1647
+ }
1648
+ copy_parts.push(occurrence);
1649
+ copy_groups.insert(group_index);
1650
+ }
1651
+ if !copy_parts.is_empty() {
1652
+ merged.push(coalesce_occurrences(copy_parts));
1653
+ }
1654
+ if !had_fragments && touched_groups_by_block[member_index].is_empty() {
1655
+ merged.push(to_occurrence(comparable[member_index]));
1656
+ }
1657
+ }
1658
+ merged.sort_by_key(|occurrence| {
1659
+ (occurrence.start_token_index, occurrence.end_token_index)
1660
+ });
1661
+ // The rebuild consumed every fully-clustered group, so shared-span marks from earlier
1662
+ // partial merges no longer point at a separate merged group.
1663
+ for occurrence in &mut merged {
1664
+ occurrence.shared_with_merged_group = false;
1665
+ }
1666
+ reported_groups[target_index] = merged;
1667
+ for &source_index in source_indexes {
1668
+ reported_groups[source_index].clear();
1669
+ }
1670
+ } else if uncovered.len() >= 2 {
1671
+ groups.push(
1672
+ uncovered
1673
+ .iter()
1674
+ .map(|&index| to_occurrence(comparable[index]))
1675
+ .collect(),
1676
+ );
1677
+ }
1678
+ }
1679
+ groups.sort_by_key(|group| group_sort_key(group));
1680
+ groups
1681
+ }
1682
+
1683
+ /// Keeps the block ranges the near-miss phase compares; a faithful port of selectComparableBlocks
1684
+ /// in duplication.ts (wrappers whose subtree branches into two or more disjoint eligible
1685
+ /// sub-blocks are descended through; linear chains keep their top).
1686
+ fn select_comparable_blocks<'a>(eligible: &[&'a TokenRange]) -> Vec<&'a TokenRange> {
1687
+ struct ForestNode<'a> {
1688
+ range: &'a TokenRange,
1689
+ children: Vec<usize>,
1690
+ }
1691
+ // Index arena: nodes never move, so ancestor references on the stack stay valid.
1692
+ let mut nodes: Vec<ForestNode<'a>> = Vec::new();
1693
+ let mut roots: Vec<usize> = Vec::new();
1694
+ let mut stack: Vec<usize> = Vec::new();
1695
+ for &range in eligible {
1696
+ while let Some(&top) = stack.last() {
1697
+ if nodes[top].range.end_token_index <= range.start_token_index {
1698
+ stack.pop();
1699
+ } else {
1700
+ break;
1701
+ }
1702
+ }
1703
+ if let Some(&top) = stack.last() {
1704
+ // Equal spans (two node types covering the same tokens) collapse into the first.
1705
+ if nodes[top].range.start_token_index == range.start_token_index
1706
+ && nodes[top].range.end_token_index == range.end_token_index
1707
+ {
1708
+ continue;
1709
+ }
1710
+ }
1711
+ let id = nodes.len();
1712
+ nodes.push(ForestNode {
1713
+ range,
1714
+ children: Vec::new(),
1715
+ });
1716
+ match stack.last() {
1717
+ Some(&top) => nodes[top].children.push(id),
1718
+ None => roots.push(id),
1719
+ }
1720
+ stack.push(id);
1721
+ }
1722
+
1723
+ fn branches(nodes: &[ForestNode<'_>], id: usize) -> bool {
1724
+ let children = &nodes[id].children;
1725
+ children.len() >= 2 || (children.len() == 1 && branches(nodes, children[0]))
1726
+ }
1727
+ fn visit<'a>(nodes: &[ForestNode<'a>], id: usize, kept: &mut Vec<&'a TokenRange>) {
1728
+ if branches(nodes, id) {
1729
+ for &child in &nodes[id].children {
1730
+ visit(nodes, child, kept);
1731
+ }
1732
+ } else {
1733
+ kept.push(nodes[id].range);
1734
+ }
1735
+ }
1736
+ let mut kept = Vec::new();
1737
+ for &root in &roots {
1738
+ visit(&nodes, root, &mut kept);
1739
+ }
1740
+ kept
1741
+ }
1742
+
1743
+ /// One copy's fragments (an exact prefix and suffix split by a large edit) as one occurrence;
1744
+ /// mirrors coalesceOccurrences in duplication.ts.
1745
+ fn coalesce_occurrences(occurrences: Vec<CountedOccurrence>) -> CountedOccurrence {
1746
+ if occurrences.len() == 1 {
1747
+ return occurrences.into_iter().next().expect("non-empty");
1748
+ }
1749
+ // A partial gapped merge retains the leftover group with ALL its occurrences, so a copy's
1750
+ // fragments can arrive both standalone and embedded in a merged occurrence: union overlapping
1751
+ // segments so no token span is reported or counted twice.
1752
+ let mut sorted: Vec<(usize, usize)> = occurrences
1753
+ .iter()
1754
+ .flat_map(|occurrence| occurrence.segments.iter().copied())
1755
+ .collect();
1756
+ sorted.sort_by_key(|segment| *segment);
1757
+ let mut segments: Vec<(usize, usize)> = Vec::with_capacity(sorted.len());
1758
+ for segment in sorted {
1759
+ match segments.last_mut() {
1760
+ Some(last) if segment.0 < last.1 => last.1 = last.1.max(segment.1),
1761
+ _ => segments.push(segment),
1762
+ }
1763
+ }
1764
+ CountedOccurrence {
1765
+ shared_with_merged_group: false,
1766
+ token_count: segments.iter().map(|segment| segment.1 - segment.0).sum(),
1767
+ start_token_index: occurrences
1768
+ .iter()
1769
+ .map(|o| o.start_token_index)
1770
+ .min()
1771
+ .unwrap_or(0),
1772
+ end_token_index: occurrences
1773
+ .iter()
1774
+ .map(|o| o.end_token_index)
1775
+ .max()
1776
+ .unwrap_or(0),
1777
+ start_line: occurrences.iter().map(|o| o.start_line).min().unwrap_or(0),
1778
+ end_line: occurrences.iter().map(|o| o.end_line).max().unwrap_or(0),
1779
+ segments,
1780
+ }
1781
+ }
1782
+
1783
+ struct NormalizedBlock {
1784
+ sequence: Vec<i32>,
1785
+ /// Occurrences per content-bearing symbol (names and literal values), for the content gate.
1786
+ content_count_by_symbol: HashMap<i32, usize>,
1787
+ content_total: usize,
1788
+ }
1789
+
1790
+ /// A block's tokens as comparable integers; see normalizeBlockSequence in duplication.ts
1791
+ /// (literal VALUES are folded into the symbol, unlike the exact fingerprint's kind tags).
1792
+ fn normalize_block_sequence(
1793
+ tokens: &[Token<'_>],
1794
+ range: &TokenRange,
1795
+ symbol_id_by_token_hashes: &mut HashMap<(i32, i32, i32, i32), i32>,
1796
+ ) -> NormalizedBlock {
1797
+ let mut sequence = Vec::with_capacity(range.end_token_index - range.start_token_index);
1798
+ let mut index_by_identifier: HashMap<&str, i32> = HashMap::new();
1799
+ let mut content_count_by_symbol: HashMap<i32, usize> = HashMap::new();
1800
+ let mut content_total = 0usize;
1801
+ for token in &tokens[range.start_token_index..range.end_token_index.min(tokens.len())] {
1802
+ let value = if token.is_id {
1803
+ let next_index = index_by_identifier.len() as i32;
1804
+ let identifier_index = *index_by_identifier
1805
+ .entry(token.text.as_ref())
1806
+ .or_insert(next_index);
1807
+ -(identifier_index + 1)
1808
+ } else {
1809
+ let next_id = symbol_id_by_token_hashes.len() as i32;
1810
+ let id = *symbol_id_by_token_hashes
1811
+ .entry((
1812
+ token.text_hash,
1813
+ token.text_hash2,
1814
+ token.literal_hash.unwrap_or(0),
1815
+ token.literal_hash2.unwrap_or(0),
1816
+ ))
1817
+ .or_insert(next_id);
1818
+ if token.is_name || token.literal_hash.is_some() {
1819
+ *content_count_by_symbol.entry(id).or_insert(0) += 1;
1820
+ content_total += 1;
1821
+ }
1822
+ id
1823
+ };
1824
+ sequence.push(value);
1825
+ }
1826
+ NormalizedBlock {
1827
+ sequence,
1828
+ content_count_by_symbol,
1829
+ content_total,
1830
+ }
1831
+ }
1832
+
1833
+ /// Multiset overlap of two blocks' content-bearing symbols, for the content gate.
1834
+ fn content_overlap(left: &NormalizedBlock, right: &NormalizedBlock) -> usize {
1835
+ let (smaller, larger) =
1836
+ if left.content_count_by_symbol.len() <= right.content_count_by_symbol.len() {
1837
+ (left, right)
1838
+ } else {
1839
+ (right, left)
1840
+ };
1841
+ smaller
1842
+ .content_count_by_symbol
1843
+ .iter()
1844
+ .map(|(symbol, count)| {
1845
+ (*count).min(
1846
+ larger
1847
+ .content_count_by_symbol
1848
+ .get(symbol)
1849
+ .copied()
1850
+ .unwrap_or(0),
1851
+ )
1852
+ })
1853
+ .sum()
1854
+ }
1855
+
1856
+ /// The distinct 5-gram hashes of a normalized block sequence, matching collectNgramSet exactly.
1857
+ fn collect_ngram_set(sequence: &[i32]) -> HashSet<i32> {
1858
+ if sequence.len() < NEAR_MISS_NGRAM_SIZE {
1859
+ return HashSet::new();
1860
+ }
1861
+ // Exact upper bound: one n-gram per window, and most windows hash distinctly.
1862
+ let mut ngrams = HashSet::with_capacity(sequence.len() - NEAR_MISS_NGRAM_SIZE + 1);
1863
+ for window in sequence.windows(NEAR_MISS_NGRAM_SIZE) {
1864
+ let mut hash: i32 = 5381;
1865
+ for &value in window {
1866
+ hash = hash.wrapping_mul(31).wrapping_add(value);
1867
+ }
1868
+ ngrams.insert(hash);
1869
+ }
1870
+ ngrams
1871
+ }
1872
+
1873
+ /// Shared distinct-n-gram counts per block pair (left < right).
1874
+ fn count_shared_ngrams(ngram_sets: &[HashSet<i32>]) -> HashMap<(usize, usize), usize> {
1875
+ let mut blocks_by_ngram: HashMap<i32, Vec<usize>> = HashMap::new();
1876
+ for (block_index, ngrams) in ngram_sets.iter().enumerate() {
1877
+ for &ngram in ngrams {
1878
+ blocks_by_ngram.entry(ngram).or_default().push(block_index);
1879
+ }
1880
+ }
1881
+ let mut shared_counts: HashMap<(usize, usize), usize> = HashMap::new();
1882
+ for blocks in blocks_by_ngram.values() {
1883
+ for (position, &left_index) in blocks.iter().enumerate() {
1884
+ // Bucket indices are appended in ascending block order, so left < right already.
1885
+ for &right_index in &blocks[position + 1..] {
1886
+ *shared_counts.entry((left_index, right_index)).or_insert(0) += 1;
1887
+ }
1888
+ }
1889
+ }
1890
+ shared_counts
1891
+ }
1892
+
1893
+ /// Longest-common-subsequence LENGTH via the Allison–Dix bit-parallel recurrence. Only the length
1894
+ /// is needed and LCS length is algorithm-independent, so u64 words are safe even though the
1895
+ /// TypeScript port in src/duplication.ts uses 32-bit words.
1896
+ fn lcs_length(a: &[i32], b: &[i32]) -> usize {
1897
+ if a.is_empty() || b.is_empty() {
1898
+ return 0;
1899
+ }
1900
+ let word_count = a.len().div_ceil(64);
1901
+ let mut position_masks: HashMap<i32, Vec<u64>> = HashMap::new();
1902
+ for (index, &symbol) in a.iter().enumerate() {
1903
+ position_masks
1904
+ .entry(symbol)
1905
+ .or_insert_with(|| vec![0; word_count])[index / 64] |= 1u64 << (index % 64);
1906
+ }
1907
+
1908
+ let mut v = vec![0u64; word_count];
1909
+ for symbol in b {
1910
+ let match_mask = position_masks.get(symbol);
1911
+ // `(v << 1) | 1` shifts a carry bit across words; subtraction borrows across words.
1912
+ let mut shift_carry = 1u64;
1913
+ let mut borrow = 0u64;
1914
+ for (word, slot) in v.iter_mut().enumerate() {
1915
+ let previous = *slot;
1916
+ let x = match_mask.map_or(0, |mask| mask[word]) | previous;
1917
+ let shifted = (previous << 1) | shift_carry;
1918
+ shift_carry = previous >> 63;
1919
+ let (partial, underflow1) = x.overflowing_sub(shifted);
1920
+ let (difference, underflow2) = partial.overflowing_sub(borrow);
1921
+ borrow = u64::from(underflow1 || underflow2);
1922
+ *slot = x & !difference;
1923
+ }
1924
+ }
1925
+ v.iter().map(|word| word.count_ones() as usize).sum()
1926
+ }
1927
+
1928
+ /// Redundant copies one group adds to duplicate_block_count; a faithful port of
1929
+ /// countRedundantFragments in duplication.ts. Fragment-weighted (merging must not halve
1930
+ /// duplicate_block_count) with the largest occurrence deducted as the representative; occurrences a
1931
+ /// partial gapped merge shared into a merged group are skipped — their spans are counted there,
1932
+ /// and the merged group's representative already stands for the shared content — so no token span
1933
+ /// contributes to the count twice.
1934
+ fn count_redundant_fragments(group: &[CountedOccurrence]) -> usize {
1935
+ let mut fragment_count = 0;
1936
+ let mut max_fragment_count = 0;
1937
+ let mut has_shared_occurrence = false;
1938
+ for occurrence in group {
1939
+ if occurrence.shared_with_merged_group {
1940
+ has_shared_occurrence = true;
1941
+ continue;
1942
+ }
1943
+ fragment_count += occurrence.segments.len();
1944
+ max_fragment_count = max_fragment_count.max(occurrence.segments.len());
1945
+ }
1946
+ if has_shared_occurrence {
1947
+ fragment_count
1948
+ } else {
1949
+ fragment_count - max_fragment_count
1950
+ }
1951
+ }
1952
+
1953
+ fn summarize_duplicates(
1954
+ groups: &[Vec<CountedOccurrence>],
1955
+ code_line_numbers: &HashSet<usize>,
1956
+ tokens: &[Token<'_>],
1957
+ ) -> DuplicationMetrics {
1958
+ let mut duplicate_block_count = 0;
1959
+ let mut max_duplicate_block_size = 0;
1960
+ let mut duplicate_block_groups: Vec<Vec<DuplicateBlockOccurrence>> = Vec::new();
1961
+ let mut duplicated_lines: HashSet<usize> = HashSet::new();
1962
+ for group in groups {
1963
+ duplicate_block_count += count_redundant_fragments(group);
1964
+ for occurrence in group {
1965
+ max_duplicate_block_size = max_duplicate_block_size.max(occurrence.token_count);
1966
+ // Only CODE lines carrying matched tokens count; the unmatched gap of a merged clone
1967
+ // stays out of line coverage.
1968
+ for &(segment_start, segment_end) in &occurrence.segments {
1969
+ for token in &tokens[segment_start..segment_end.min(tokens.len())] {
1970
+ for row in token.start_row..=token.end_row {
1971
+ if code_line_numbers.contains(&(row + 1)) {
1972
+ duplicated_lines.insert(row + 1);
1973
+ }
1974
+ }
1975
+ }
1976
+ }
1977
+ }
1978
+ let mut occurrences: Vec<DuplicateBlockOccurrence> = group
1979
+ .iter()
1980
+ .map(|occurrence| DuplicateBlockOccurrence {
1981
+ start_line: occurrence.start_line,
1982
+ end_line: occurrence.end_line,
1983
+ })
1984
+ .collect();
1985
+ occurrences.sort_by_key(|occurrence| occurrence.start_line);
1986
+ duplicate_block_groups.push(occurrences);
1987
+ }
1988
+ duplicate_block_groups
1989
+ .sort_by_key(|group| group.first().map(|first| first.start_line).unwrap_or(0));
1990
+
1991
+ let mut duplicate_line_numbers: Vec<usize> = duplicated_lines.iter().copied().collect();
1992
+ duplicate_line_numbers.sort_unstable();
1993
+
1994
+ DuplicationMetrics {
1995
+ duplicate_block_count,
1996
+ duplicate_block_group_count: groups.len(),
1997
+ duplicate_block_groups,
1998
+ duplicate_line_count: duplicated_lines.len(),
1999
+ duplicate_line_numbers,
2000
+ duplication_ratio: if code_line_numbers.is_empty() {
2001
+ 0.0
2002
+ } else {
2003
+ duplicated_lines.len() as f64 / code_line_numbers.len() as f64
2004
+ },
2005
+ max_duplicate_block_size,
2006
+ }
2007
+ }