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.
- package/README.md +83 -21
- package/dist/cli.cjs +3 -3
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +3 -3
- package/dist/cli.js.map +1 -1
- package/dist/cliConfig.cjs +1 -1
- package/dist/cliConfig.cjs.map +1 -1
- package/dist/cliConfig.d.ts +11 -0
- package/dist/cliConfig.js +1 -1
- package/dist/cliConfig.js.map +1 -1
- package/dist/crossFileDuplication.cjs +1 -1
- package/dist/crossFileDuplication.cjs.map +1 -1
- package/dist/crossFileDuplication.js +1 -1
- package/dist/crossFileDuplication.js.map +1 -1
- package/dist/diffCommand.cjs +5 -0
- package/dist/diffCommand.cjs.map +1 -0
- package/dist/diffCommand.d.ts +17 -0
- package/dist/diffCommand.js +5 -0
- package/dist/diffCommand.js.map +1 -0
- package/dist/duplication.cjs +1 -1
- package/dist/duplication.cjs.map +1 -1
- package/dist/duplication.d.ts +20 -24
- package/dist/duplication.js +1 -1
- package/dist/duplication.js.map +1 -1
- package/dist/git.cjs +2 -0
- package/dist/git.cjs.map +1 -0
- package/dist/git.d.ts +27 -0
- package/dist/git.js +2 -0
- package/dist/git.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/languages.cjs +1 -1
- package/dist/languages.cjs.map +1 -1
- package/dist/languages.d.ts +5 -0
- package/dist/languages.js +1 -1
- package/dist/languages.js.map +1 -1
- package/dist/metrics.cjs +1 -1
- package/dist/metrics.cjs.map +1 -1
- package/dist/metrics.d.ts +18 -5
- package/dist/metrics.js +1 -1
- package/dist/metrics.js.map +1 -1
- package/dist/nativeMetrics.cjs +3 -1
- package/dist/nativeMetrics.cjs.map +1 -1
- package/dist/nativeMetrics.d.ts +29 -10
- package/dist/nativeMetrics.js +3 -1
- package/dist/nativeMetrics.js.map +1 -1
- package/dist/regressionGate.cjs +2 -0
- package/dist/regressionGate.cjs.map +1 -0
- package/dist/regressionGate.d.ts +106 -0
- package/dist/regressionGate.js +2 -0
- package/dist/regressionGate.js.map +1 -0
- package/dist/scan.cjs +2 -0
- package/dist/scan.cjs.map +1 -0
- package/dist/scan.d.ts +55 -0
- package/dist/scan.js +2 -0
- package/dist/scan.js.map +1 -0
- package/dist/types.d.ts +18 -13
- package/native/Cargo.lock +523 -0
- package/native/Cargo.toml +45 -0
- package/native/build.rs +3 -0
- package/native/src/complexity.rs +627 -0
- package/native/src/dep_degree.rs +253 -0
- package/native/src/duplication.rs +2007 -0
- package/native/src/functions.rs +345 -0
- package/native/src/languages.rs +647 -0
- package/native/src/lib.rs +101 -0
- package/native/src/measure.rs +590 -0
- package/native/src/ncss.rs +263 -0
- package/native/src/types.rs +135 -0
- package/native/src/util.rs +139 -0
- package/package.json +16 -19
- package/scripts/buildNative.mjs +25 -0
- package/scripts/installNative.mjs +96 -0
- package/dist/ncss.cjs +0 -2
- package/dist/ncss.cjs.map +0 -1
- package/dist/ncss.d.ts +0 -17
- package/dist/ncss.js +0 -2
- 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
|
+
}
|