/home/runner/work/jscpd/jscpd/rust/crates/cpd-tokenizer/src/tokenizer.rs
Line | Count | Source |
1 | | use std::str::FromStr; |
2 | | |
3 | | use cpd_core::hash::hash_token; |
4 | | use cpd_core::models::{DetectionToken, MEMBER_NAME, Token, TokenKind}; |
5 | | |
6 | | use crate::markdown::tokens_to_detection; |
7 | | |
8 | | /// A sub-format detection map produced by multi-format tokenizers. |
9 | | /// |
10 | | /// For single-format files, `tokenize_to_detection_maps()` returns exactly one |
11 | | /// TokenMap with the same format as the file. |
12 | | /// |
13 | | /// For multi-format files (markdown, SFC), one TokenMap is returned per |
14 | | /// detected sub-language, each carrying tokens that should enter that |
15 | | /// format's detection pool. |
16 | | #[derive(Debug, Clone)] |
17 | | pub struct TokenMap { |
18 | | pub format: String, |
19 | | pub tokens: Vec<DetectionToken>, |
20 | | } |
21 | | |
22 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
23 | | pub enum Mode { |
24 | | #[default] |
25 | | Mild, |
26 | | Weak, |
27 | | Strict, |
28 | | } |
29 | | |
30 | | impl FromStr for Mode { |
31 | | type Err = (); |
32 | | |
33 | 667 | fn from_str(s: &str) -> Result<Self, Self::Err> { |
34 | 667 | match s { |
35 | 667 | "weak" => Ok(Self::Weak)7 , |
36 | 660 | "strict" => Ok(Self::Strict)6 , |
37 | 654 | _ => Ok(Self::Mild), |
38 | | } |
39 | 667 | } |
40 | | } |
41 | | |
42 | | /// Options for the detection-path tokenizer. |
43 | | /// |
44 | | /// Carries mode, case-folding flag, pre-parsed ignore-region byte ranges, |
45 | | /// and pre-compiled code-level regex patterns that skip matching tokens during detection. |
46 | | /// |
47 | | /// Code-level ignore patterns (v4 `ignorePattern`) work by matching regex patterns |
48 | | /// against source text, collecting byte ranges of matches, and then filtering |
49 | | /// any token whose byte range overlaps a match — identical in effect to v4's |
50 | | /// `setupIgnorePatterns` which injected Prism grammar tokens. |
51 | | #[derive(Debug, Clone)] |
52 | | pub struct TokenizeOptions { |
53 | | pub mode: Mode, |
54 | | /// When true, token values are lowercased before hashing. |
55 | | pub ignore_case: bool, |
56 | | /// Ignored byte ranges from `jscpd:ignore-start` / `jscpd:ignore-end` |
57 | | /// and code-level regex matches from `ignorePattern`. |
58 | | /// Each entry is `[start_byte, end_byte)`. |
59 | | pub ignore_ranges: Vec<[usize; 2]>, |
60 | | /// Hash every identifier as `$id` so clones that differ only in variable, |
61 | | /// function or type names match (issue #998). Keywords are kept: the |
62 | | /// JavaScript tokenizer classifies them, other languages fall back to |
63 | | /// [`is_common_keyword`]. |
64 | | pub ignore_identifiers: bool, |
65 | | /// Hash string literals as `$str` and numeric literals as `$num`. |
66 | | pub ignore_literals: bool, |
67 | | /// Drop `@Name`, `@a.b.Name` and `@Name(...)` annotation/decorator |
68 | | /// sequences before hashing, in formats listed by [`strips_annotations`]. |
69 | | pub ignore_annotations: bool, |
70 | | /// Pre-compiled code-level regex patterns inherited from v4 `ignorePattern`. |
71 | | /// Before tokenization, these are matched against the source text and |
72 | | /// overlapping byte ranges are added to `ignore_ranges`. |
73 | | pub code_ignore_regexes: Vec<regex::Regex>, |
74 | | /// Formats whose TypeScript-only syntax is stripped from the detection |
75 | | /// token stream (`--cross-formats` groups mixing TS with JS). Only |
76 | | /// `typescript` and `tsx` are meaningful here; empty by default so the |
77 | | /// standard detection path is untouched. |
78 | | pub strip_types_formats: std::collections::HashSet<String>, |
79 | | } |
80 | | |
81 | | impl TokenizeOptions { |
82 | 1.19k | pub fn new(mode: Mode) -> Self { |
83 | 1.19k | Self { |
84 | 1.19k | mode, |
85 | 1.19k | ignore_case: false, |
86 | 1.19k | ignore_identifiers: false, |
87 | 1.19k | ignore_literals: false, |
88 | 1.19k | ignore_annotations: false, |
89 | 1.19k | ignore_ranges: Vec::new(), |
90 | 1.19k | code_ignore_regexes: Vec::new(), |
91 | 1.19k | strip_types_formats: std::collections::HashSet::new(), |
92 | 1.19k | } |
93 | 1.19k | } |
94 | | } |
95 | | |
96 | | /// Tokenize a single-format source snippet into detection tokens. |
97 | | /// |
98 | | /// Used by markdown and SFC tokenizers to dispatch embedded code blocks to the |
99 | | /// appropriate language tokenizer. |
100 | 349 | pub fn tokenize_format_to_detection( |
101 | 349 | format: &str, |
102 | 349 | source: &str, |
103 | 349 | options: &TokenizeOptions, |
104 | 349 | ) -> Vec<DetectionToken> { |
105 | 349 | let raw = match format { |
106 | 349 | "javascript" | "typescript"286 | "jsx"189 | "tsx"188 => { |
107 | 166 | if should_strip_types(format, options) { |
108 | 2 | crate::javascript::tokenize_js_stripped(source, format) |
109 | | } else { |
110 | 164 | crate::javascript::tokenize_js(source, format) |
111 | | } |
112 | | } |
113 | 183 | "vue" | "svelte"178 | "astro"177 => crate::sfc::tokenize_sfc6 (source6 , format6 , options.mode6 ), |
114 | 177 | "markdown" | "md"173 => crate::generic::tokenize_generic4 (source4 , format4 ), |
115 | 173 | _ => crate::generic::tokenize_generic(source, format), |
116 | | }; |
117 | 349 | tokens_to_detection(raw, options, NameRules::of(format)) |
118 | 349 | } |
119 | | |
120 | | /// [`tokenize_format_to_detection`] for the block of a host file that |
121 | | /// starts at byte `offset`, such as a Markdown fence or a component's |
122 | | /// script: the host's `ignore_ranges` apply at the block's own offsets. |
123 | 349 | pub fn tokenize_block_to_detection( |
124 | 349 | format: &str, |
125 | 349 | block: &str, |
126 | 349 | offset: usize, |
127 | 349 | options: &TokenizeOptions, |
128 | 349 | ) -> Vec<DetectionToken> { |
129 | 349 | if options.ignore_ranges.is_empty() { |
130 | 333 | return tokenize_format_to_detection(format, block, options); |
131 | 16 | } |
132 | | // The expressions are spent: their ranges are all a block needs. |
133 | 16 | let local = TokenizeOptions { |
134 | 16 | mode: options.mode, |
135 | 16 | ignore_case: options.ignore_case, |
136 | 16 | ignore_ranges: ranges_in(&options.ignore_ranges, offset..offset + block.len()), |
137 | 16 | ignore_identifiers: options.ignore_identifiers, |
138 | 16 | ignore_literals: options.ignore_literals, |
139 | 16 | ignore_annotations: options.ignore_annotations, |
140 | 16 | code_ignore_regexes: Vec::new(), |
141 | 16 | strip_types_formats: options.strip_types_formats.clone(), |
142 | 16 | }; |
143 | 16 | tokenize_format_to_detection(format, block, &local) |
144 | 349 | } |
145 | | |
146 | | /// The parts of a host file's byte ranges `ranges` that fall in its block |
147 | | /// `block`, by the block's own offsets. |
148 | 34 | pub fn ranges_in(ranges: &[[usize; 2]], block: std::ops::Range<usize>) -> Vec<[usize; 2]> { |
149 | 34 | ranges |
150 | 34 | .iter() |
151 | 46 | .filter34 (|[start, end]| *start < block.end && *end > block.start16 ) |
152 | 34 | .map(|[start, end]| {4 |
153 | 4 | [ |
154 | 4 | (*start).max(block.start) - block.start, |
155 | 4 | (*end).min(block.end) - block.start, |
156 | 4 | ] |
157 | 4 | }) |
158 | 34 | .collect() |
159 | 34 | } |
160 | | |
161 | | /// True when this format's TypeScript-only syntax must be stripped for |
162 | | /// cross-format detection (see `TokenizeOptions::strip_types_formats`). |
163 | 2.83k | fn should_strip_types(format: &str, options: &TokenizeOptions) -> bool { |
164 | 2.83k | matches!538 (format, "typescript" | "tsx"2.37k ) && options.strip_types_formats538 .contains538 (format538 ) |
165 | 2.83k | } |
166 | | |
167 | | /// Compute byte ranges of all regex matches against source text. |
168 | | /// Used to populate `ignore_ranges` from `ignorePattern` regexes before |
169 | | /// tokenization, matching v4 semantics where regex patterns match against |
170 | | /// source text regions (not individual token values). |
171 | 301 | pub fn code_ignore_ranges(source: &str, regexes: &[regex::Regex]) -> Vec<[usize; 2]> { |
172 | 301 | let mut ranges = Vec::new(); |
173 | 332 | for re in regexes301 { |
174 | 1.33k | for m in re332 .find_iter332 (source332 ) { |
175 | 1.33k | ranges.push([m.start(), m.end()]); |
176 | 1.33k | } |
177 | | } |
178 | 301 | ranges |
179 | 301 | } |
180 | | |
181 | | /// Push a token into the detection output if it passes all filters. |
182 | | /// |
183 | | /// Filtering happens here — at tokenize time — so the resulting |
184 | | /// `Vec<DetectionToken>` passed to detection is already minimal. |
185 | | /// Token values are not stored; only the pre-computed hash is kept. |
186 | | /// |
187 | | /// The argument count is intentional: this function is a hot-path helper |
188 | | /// called from every tokenizer branch; grouping parameters into a struct |
189 | | /// would add an extra dereference per call. |
190 | | #[allow(clippy::too_many_arguments)] |
191 | | #[inline] |
192 | 3.72M | pub fn push_token( |
193 | 3.72M | tokens: &mut Vec<DetectionToken>, |
194 | 3.72M | kind: TokenKind, |
195 | 3.72M | value: &str, |
196 | 3.72M | byte_start: usize, |
197 | 3.72M | byte_end: usize, |
198 | 3.72M | start: cpd_core::models::Location, |
199 | 3.72M | end: cpd_core::models::Location, |
200 | 3.72M | options: &TokenizeOptions, |
201 | 3.72M | fold_names: bool, |
202 | 3.72M | member: bool, |
203 | 3.72M | ) -> bool { |
204 | | // Drop Ignore-marked tokens in all modes. |
205 | 3.72M | if kind == TokenKind::Ignore { |
206 | 138k | return false; |
207 | 3.58M | } |
208 | | // Drop tokens in Ignore byte ranges. |
209 | | // This covers both jscpd:ignore-start/end markers and code-level ignorePattern |
210 | | // regex ranges (which are computed from source text before tokenization). |
211 | 3.58M | if options |
212 | 3.58M | .ignore_ranges |
213 | 3.58M | .iter() |
214 | 23.5M | .any3.58M (|[rs, re]| byte_start < *re && byte_end > *rs11.5M ) |
215 | | { |
216 | 1.36k | return false; |
217 | 3.58M | } |
218 | | // Mode-based filtering: |
219 | 3.58M | match options.mode { |
220 | | Mode::Mild => { |
221 | 3.57M | if kind == TokenKind::Whitespace { |
222 | 760k | return false; |
223 | 2.81M | } |
224 | | } |
225 | | Mode::Weak => { |
226 | 2.46k | if matches!( |
227 | 3.50k | kind, |
228 | | TokenKind::Whitespace | TokenKind::Comment | TokenKind::BlockComment |
229 | | ) { |
230 | 1.03k | return false; |
231 | 2.46k | } |
232 | | } |
233 | 3.56k | Mode::Strict => {} // keep everything (except Ignore, handled above) |
234 | | } |
235 | 2.81M | let raw_hash = hash_token(kind.discriminant(), value, options.ignore_case); |
236 | 2.81M | let (hash, name) = match normalized_value(&kind, value, options) { |
237 | 4.64k | Some(placeholder) => { |
238 | | // The language decides whether a name keeps its case, whatever |
239 | | // --ignore-case says, and a member is another name than a local |
240 | | // of its text; 0 marks the tokens that are not renamed |
241 | | // identifiers. |
242 | 4.64k | let name = match placeholder { |
243 | 4.64k | RENAMED_IDENTIFIER => { |
244 | 4.59k | let name = match fold_names == options.ignore_case { |
245 | 4.58k | true => raw_hash, |
246 | 14 | false => hash_token(kind.discriminant(), value, fold_names), |
247 | | }; |
248 | 4.59k | match member { |
249 | 285 | true => name | MEMBER_NAME, |
250 | 4.31k | false => (name & !MEMBER_NAME).max(1), |
251 | | } |
252 | | } |
253 | 50 | _ => 0, |
254 | | }; |
255 | 4.64k | (hash_token(kind.discriminant(), placeholder, false), name) |
256 | | } |
257 | 2.81M | None => (raw_hash, 0), |
258 | | }; |
259 | 2.81M | tokens.push(DetectionToken { |
260 | 2.81M | hash, |
261 | 2.81M | raw_hash, |
262 | 2.81M | name, |
263 | 2.81M | start, |
264 | 2.81M | end, |
265 | 2.81M | range: [byte_start, byte_end], |
266 | 2.81M | }); |
267 | 2.81M | true |
268 | 3.72M | } |
269 | | |
270 | | /// Whether the names of `format` ignore case, so that `Customer` and |
271 | | /// `customer` are one name to consistent matching. |
272 | 3.02k | pub fn names_ignore_case(format: &str) -> bool { |
273 | 69 | matches!( |
274 | 3.02k | format, |
275 | 3.02k | "abap" |
276 | 3.02k | | "ada" |
277 | 3.02k | | "apex" |
278 | 3.02k | | "autohotkey" |
279 | 3.02k | | "autoit" |
280 | 3.02k | | "basic" |
281 | 3.02k | | "batch" |
282 | 3.02k | | "cfml" |
283 | 3.02k | | "cfscript" |
284 | 3.02k | | "cobol" |
285 | 3.02k | | "excel-formula" |
286 | 3.02k | | "fortran" |
287 | 3.02k | | "ichigojam" |
288 | 3.02k | | "pascal" |
289 | 3.02k | | "plsql" |
290 | 3.02k | | "powershell" |
291 | 3.02k | | "sas" |
292 | 3.02k | | "sql" |
293 | 2.95k | | "vbnet" |
294 | 2.95k | | "vhdl" |
295 | 2.95k | | "visual-basic" |
296 | 2.95k | | "xojo" |
297 | | ) |
298 | 3.02k | } |
299 | | |
300 | | /// How the names of a format compare when `--ignore-identifiers` matches |
301 | | /// them consistently (see [`DetectionToken::name`]). |
302 | | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] |
303 | | pub struct NameRules { |
304 | | /// `Customer` and `customer` are one name ([`names_ignore_case`]). |
305 | | pub fold_case: bool, |
306 | | /// A name after `.` is a member, another name than a local of the same |
307 | | /// text: `this.url = url` copied as `this.url = link` pairs each name |
308 | | /// with one name. |
309 | | pub members: bool, |
310 | | /// A name after `->` is a member, as in C, C++ and PHP. |
311 | | pub arrows: bool, |
312 | | } |
313 | | |
314 | | impl NameRules { |
315 | | /// The rules of `format`: `.` reaches a member in the languages of |
316 | | /// [`DOT_MEMBERS`], `->` in those of [`ARROW_MEMBERS`]. In other formats |
317 | | /// a name after them is a name like any other: PHP and Perl join |
318 | | /// strings with `.`, Fortran compares with `.gt.`, Haskell composes |
319 | | /// functions, and prose ends a sentence. |
320 | 3.02k | pub fn of(format: &str) -> Self { |
321 | 3.02k | Self { |
322 | 3.02k | fold_case: names_ignore_case(format), |
323 | 3.02k | members: DOT_MEMBERS.contains(&format), |
324 | 3.02k | arrows: ARROW_MEMBERS.contains(&format), |
325 | 3.02k | } |
326 | 3.02k | } |
327 | | } |
328 | | |
329 | | /// The formats where `.` reaches a member of what comes before it. |
330 | | const DOT_MEMBERS: &[&str] = &[ |
331 | | "actionscript", |
332 | | "apex", |
333 | | "arduino", |
334 | | "bison", |
335 | | "c", |
336 | | "c-header", |
337 | | "cfscript", |
338 | | "clike", |
339 | | "coffeescript", |
340 | | "cpp", |
341 | | "cpp-header", |
342 | | "crystal", |
343 | | "csharp", |
344 | | "d", |
345 | | "dart", |
346 | | "flow", |
347 | | "fsharp", |
348 | | "gdscript", |
349 | | "glsl", |
350 | | "go", |
351 | | "groovy", |
352 | | "haxe", |
353 | | "hlsl", |
354 | | "java", |
355 | | "javascript", |
356 | | "jsx", |
357 | | "julia", |
358 | | "kotlin", |
359 | | "livescript", |
360 | | "lua", |
361 | | "n4js", |
362 | | "nim", |
363 | | "objectivec", |
364 | | "ocaml", |
365 | | "odin", |
366 | | "opencl", |
367 | | "powershell", |
368 | | "processing", |
369 | | "python", |
370 | | "reason", |
371 | | "rescript", |
372 | | "ruby", |
373 | | "rust", |
374 | | "scala", |
375 | | "solidity", |
376 | | "swift", |
377 | | "tsx", |
378 | | "typescript", |
379 | | "unrealscript", |
380 | | "vala", |
381 | | "vbnet", |
382 | | "visual-basic", |
383 | | "wgsl", |
384 | | "xojo", |
385 | | "zig", |
386 | | ]; |
387 | | |
388 | | /// The formats where `->` reaches a member of what comes before it. |
389 | | const ARROW_MEMBERS: &[&str] = &[ |
390 | | "arduino", |
391 | | "bison", |
392 | | "c", |
393 | | "c-header", |
394 | | "clike", |
395 | | "cpp", |
396 | | "cpp-header", |
397 | | "objectivec", |
398 | | "opencl", |
399 | | "perl", |
400 | | "php", |
401 | | ]; |
402 | | |
403 | | /// Whether each name of a token stream is reached as a member, by the |
404 | | /// [`NameRules`] of its format: after a single `.` or `?.` (most tokenizers |
405 | | /// give `..` and `...`, ranges, spreads, varargs and Dart's cascades, as one |
406 | | /// dot after another), after the `.` that ends a number such as Rust's |
407 | | /// `x.0.url`, or after `->` given as `-` and `>`, but not `-->`. The tokens |
408 | | /// it is shown are the ones detection keeps, without comments. |
409 | | #[derive(Debug, Clone, Copy)] |
410 | | pub(crate) struct MemberAccess { |
411 | | rules: NameRules, |
412 | | /// The last three tokens shown, oldest first: what each is and where it |
413 | | /// starts and ends. |
414 | | last: [(Access, usize, usize); 3], |
415 | | } |
416 | | |
417 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
418 | | enum Access { |
419 | | Dot, |
420 | | /// A number that a single dot ends, as `0.` in `x.0.url`. |
421 | | NumberDot, |
422 | | Minus, |
423 | | Greater, |
424 | | Other, |
425 | | } |
426 | | |
427 | | impl MemberAccess { |
428 | 3.54k | pub(crate) fn new(rules: NameRules) -> Self { |
429 | 3.54k | Self { |
430 | 3.54k | rules, |
431 | 3.54k | last: [(Access::Other, 0, 0); 3], |
432 | 3.54k | } |
433 | 3.54k | } |
434 | | |
435 | | /// Whether the rules can make any name a member's. |
436 | 184 | pub(crate) fn tracks(&self) -> bool { |
437 | 184 | self.rules.members || self.rules.arrows22 |
438 | 184 | } |
439 | | |
440 | | /// Whether a name that comes next is a member's. |
441 | 12.4k | pub(crate) fn next_is_member(&self) -> bool { |
442 | | let [ |
443 | 12.4k | (oldest, _, oldest_end), |
444 | 12.4k | (older, older_start, older_end), |
445 | 12.4k | (newer, newer_start, _), |
446 | 12.4k | ] = self.last; |
447 | 12.4k | match newer { |
448 | | Access::Dot => { |
449 | 294 | self.rules.members && !(older == Access::Dot290 && older_end == newer_start6 ) |
450 | | } |
451 | 1 | Access::NumberDot => self.rules.members, |
452 | | Access::Greater => { |
453 | 25 | self.rules.arrows |
454 | 5 | && older == Access::Minus |
455 | 5 | && older_end == newer_start |
456 | 5 | && !(oldest == Access::Minus && oldest_end == older_start1 ) |
457 | | } |
458 | 12.1k | Access::Minus | Access::Other => false, |
459 | | } |
460 | 12.4k | } |
461 | | |
462 | | /// Takes in the token `value` at `start..end`. |
463 | 9.33k | pub(crate) fn see(&mut self, value: &str, start: usize, end: usize) { |
464 | 9.33k | let access = match value8.79k { |
465 | 9.33k | "." | "?."9.04k => Access::Dot291 , |
466 | 9.04k | "-" => Access::Minus232 , |
467 | 8.81k | ">" => Access::Greater15 , |
468 | 8.79k | _ if value.ends_with('.') |
469 | 2 | && !value.ends_with("..") |
470 | 1 | && value.starts_with(|c: char| c.is_ascii_digit()) => |
471 | | { |
472 | 1 | Access::NumberDot |
473 | | } |
474 | 8.79k | _ => Access::Other, |
475 | | }; |
476 | 9.33k | self.last = [self.last[1], self.last[2], (access, start, end)]; |
477 | 9.33k | } |
478 | | } |
479 | | |
480 | | /// The detection tokens of `raw`, the tokens of a source whose names compare |
481 | | /// by `rules`. A token that `annotation` marks is a dropped annotation: its |
482 | | /// text goes into the raw hash of the token before it. A clone whose |
483 | | /// matched run contains that token then classifies as `renamed` when the |
484 | | /// annotations differ, while a run that starts after the annotation is |
485 | | /// unaffected and stays `exact`: the reported fragment text really is |
486 | | /// identical there. |
487 | 3.54k | pub(crate) fn detection_tokens( |
488 | 3.54k | raw: Vec<Token>, |
489 | 3.54k | options: &TokenizeOptions, |
490 | 3.54k | rules: NameRules, |
491 | 3.54k | annotation: &[bool], |
492 | 3.54k | ) -> Vec<DetectionToken> { |
493 | 3.54k | let mut members = MemberAccess::new(rules); |
494 | 3.54k | let track = options.ignore_identifiers && members184 .tracks184 (); |
495 | 3.54k | let mut detection: Vec<DetectionToken> = Vec::with_capacity(raw.len()); |
496 | 3.72M | for (i, t) in raw3.54k .into_iter3.54k ().enumerate3.54k () { |
497 | 3.72M | if annotation.get(i).copied().unwrap_or(false) { |
498 | 19 | if let Some(prev10 ) = detection.last_mut() { |
499 | 10 | let h = hash_token(t.kind.discriminant(), &t.value, false); |
500 | 10 | prev.raw_hash = prev.raw_hash.rotate_left(7) ^ h; |
501 | 10 | }9 |
502 | 19 | continue; |
503 | 3.72M | } |
504 | 3.72M | let byte_start = t.start.offset as usize; |
505 | 3.72M | let byte_end = t.end.offset as usize; |
506 | 3.72M | let comment = matches!3.39M (t.kind, TokenKind::Comment | TokenKind::BlockComment); |
507 | 3.72M | let member = track && members12.4k .next_is_member12.4k (); |
508 | 3.72M | let kept = push_token( |
509 | 3.72M | &mut detection, |
510 | 3.72M | t.kind, |
511 | 3.72M | &t.value, |
512 | 3.72M | byte_start, |
513 | 3.72M | byte_end, |
514 | 3.72M | t.start, |
515 | 3.72M | t.end, |
516 | 3.72M | options, |
517 | 3.72M | rules.fold_case, |
518 | 3.72M | member, |
519 | | ); |
520 | 3.72M | if track && kept12.4k && !comment9.33k { |
521 | 9.33k | members.see(&t.value, byte_start, byte_end); |
522 | 3.71M | } |
523 | | } |
524 | 3.54k | detection |
525 | 3.54k | } |
526 | | |
527 | | /// The placeholder every identifier hashes as under `--ignore-identifiers`. |
528 | | const RENAMED_IDENTIFIER: &str = "$id"; |
529 | | |
530 | | /// Placeholder that replaces `value` under the active normalization options, |
531 | | /// or `None` when the token hashes as-is (issue #998). |
532 | | #[inline] |
533 | 2.81M | fn normalized_value( |
534 | 2.81M | kind: &TokenKind, |
535 | 2.81M | value: &str, |
536 | 2.81M | options: &TokenizeOptions, |
537 | 2.81M | ) -> Option<&'static str> { |
538 | 807k | match kind { |
539 | 5.03k | TokenKind::Identifier if options.ignore_identifiers => { |
540 | 5.03k | if is_common_keyword(value) { |
541 | 433 | None |
542 | | } else { |
543 | 4.59k | Some(RENAMED_IDENTIFIER) |
544 | | } |
545 | | } |
546 | 50 | TokenKind::Literal if options.ignore_literals => literal_placeholder(value), |
547 | 2.81M | _ => None, |
548 | | } |
549 | 2.81M | } |
550 | | |
551 | | /// `$str` for quoted literals (with an optional short alphabetic prefix such |
552 | | /// as Python's `r"..."` or C#'s `@"..."`), `$num` for numbers, `None` for |
553 | | /// anything else (`true`, `null`, regex literals) which keeps its own hash. |
554 | 50 | fn literal_placeholder(value: &str) -> Option<&'static str> { |
555 | 50 | let bytes = value.as_bytes(); |
556 | 50 | let first = *bytes.first()?0 ; |
557 | 50 | if matches!25 (first, b'"' | b'\'' | b'`') { |
558 | 25 | return Some("$str"); |
559 | 25 | } |
560 | 25 | if first.is_ascii_digit() { |
561 | 24 | return Some("$num"); |
562 | 1 | } |
563 | 1 | if first == b'.' && bytes.get(1).is_some_and(u8::is_ascii_digit) { |
564 | 1 | return Some("$num"); |
565 | 0 | } |
566 | 0 | if first.is_ascii_alphabetic() || first == b'@' { |
567 | 0 | let quote_at = bytes |
568 | 0 | .iter() |
569 | 0 | .take(4) |
570 | 0 | .position(|b| matches!(b, b'"' | b'\'' | b'`')); |
571 | 0 | if quote_at.is_some() { |
572 | 0 | return Some("$str"); |
573 | 0 | } |
574 | 0 | } |
575 | 0 | None |
576 | 50 | } |
577 | | |
578 | | /// Keywords the generic tokenizer reports as identifiers. Kept verbatim under |
579 | | /// `--ignore-identifiers` so control flow still has to match; the list is the |
580 | | /// union of common keywords across C-like, Python-like and ML-like languages. |
581 | | /// Must stay sorted: looked up by binary search. |
582 | | static COMMON_KEYWORDS: &[&str] = &[ |
583 | | "abstract", |
584 | | "and", |
585 | | "as", |
586 | | "assert", |
587 | | "async", |
588 | | "await", |
589 | | "begin", |
590 | | "break", |
591 | | "case", |
592 | | "catch", |
593 | | "class", |
594 | | "const", |
595 | | "continue", |
596 | | "def", |
597 | | "default", |
598 | | "defer", |
599 | | "del", |
600 | | "do", |
601 | | "elif", |
602 | | "else", |
603 | | "elsif", |
604 | | "end", |
605 | | "enum", |
606 | | "except", |
607 | | "export", |
608 | | "extends", |
609 | | "extern", |
610 | | "false", |
611 | | "final", |
612 | | "finally", |
613 | | "fn", |
614 | | "for", |
615 | | "foreach", |
616 | | "from", |
617 | | "func", |
618 | | "function", |
619 | | "global", |
620 | | "go", |
621 | | "goto", |
622 | | "if", |
623 | | "impl", |
624 | | "implements", |
625 | | "import", |
626 | | "in", |
627 | | "inline", |
628 | | "instanceof", |
629 | | "interface", |
630 | | "is", |
631 | | "lambda", |
632 | | "let", |
633 | | "loop", |
634 | | "match", |
635 | | "mod", |
636 | | "module", |
637 | | "mut", |
638 | | "namespace", |
639 | | "new", |
640 | | "nil", |
641 | | "none", |
642 | | "not", |
643 | | "null", |
644 | | "or", |
645 | | "override", |
646 | | "package", |
647 | | "pass", |
648 | | "private", |
649 | | "protected", |
650 | | "pub", |
651 | | "public", |
652 | | "raise", |
653 | | "record", |
654 | | "ref", |
655 | | "require", |
656 | | "rescue", |
657 | | "return", |
658 | | "sealed", |
659 | | "select", |
660 | | "self", |
661 | | "sizeof", |
662 | | "static", |
663 | | "struct", |
664 | | "super", |
665 | | "switch", |
666 | | "then", |
667 | | "this", |
668 | | "throw", |
669 | | "throws", |
670 | | "trait", |
671 | | "true", |
672 | | "try", |
673 | | "type", |
674 | | "typedef", |
675 | | "typeof", |
676 | | "undefined", |
677 | | "union", |
678 | | "unless", |
679 | | "unsafe", |
680 | | "until", |
681 | | "use", |
682 | | "using", |
683 | | "var", |
684 | | "virtual", |
685 | | "void", |
686 | | "volatile", |
687 | | "when", |
688 | | "where", |
689 | | "while", |
690 | | "with", |
691 | | "yield", |
692 | | ]; |
693 | | |
694 | | /// True for words in [`COMMON_KEYWORDS`] (case-sensitive). |
695 | 5.03k | pub fn is_common_keyword(word: &str) -> bool { |
696 | 5.03k | COMMON_KEYWORDS.binary_search(&word).is_ok() |
697 | 5.03k | } |
698 | | |
699 | | /// Formats where `@Name` / `@Name(...)` means an annotation or decorator. |
700 | | /// Elsewhere (`Ruby`, `Perl`, `T-SQL`, `Razor`, `CSS`) `@` prefixes variables |
701 | | /// or directives and must not be dropped. |
702 | 9 | pub fn strips_annotations(format: &str) -> bool { |
703 | 7 | matches!( |
704 | 9 | format, |
705 | 9 | "javascript" |
706 | 9 | | "typescript" |
707 | 7 | | "jsx" |
708 | 7 | | "tsx" |
709 | 7 | | "java" |
710 | 4 | | "kotlin" |
711 | 3 | | "scala" |
712 | 3 | | "groovy" |
713 | 3 | | "python" |
714 | 2 | | "dart" |
715 | 2 | | "swift" |
716 | | ) |
717 | 9 | } |
718 | | |
719 | | /// Flag `@Name`, `@a.b.Name` and `@Name(...)` token runs so the detection |
720 | | /// path drops them (issue #998). Whitespace tokens inside a run are tolerated |
721 | | /// only between the name and its argument list. Returns one flag per token, |
722 | | /// or an empty vector when nothing matched. |
723 | 6 | fn mark_annotations(tokens: &[Token]) -> Vec<bool> { |
724 | 6 | let n = tokens.len(); |
725 | 6 | let mut i = 0; |
726 | 6 | let mut flags: Vec<bool> = Vec::new(); |
727 | 120 | while i < n { |
728 | | // `@` followed by an identifier — but never by a keyword: Java's |
729 | | // `@interface Name { … }` declares an annotation type rather than |
730 | | // applying one, and the generic tokenizer reports `interface` as an |
731 | | // identifier. |
732 | 114 | let starts_annotation = tokens[i].value == "@" |
733 | 6 | && tokens[i].kind != TokenKind::Ignore |
734 | 6 | && tokens |
735 | 6 | .get(i + 1) |
736 | 6 | .is_some_and(|t| t.kind == TokenKind::Identifier && !is_common_keyword(&t.value)); |
737 | 114 | if !starts_annotation { |
738 | 109 | i += 1; |
739 | 109 | continue; |
740 | 5 | } |
741 | 5 | let start = i; |
742 | 5 | let mut j = i + 2; |
743 | 5 | while j + 1 < n && tokens[j].value == "." && tokens[j + 1].kind == TokenKind::Identifier0 { |
744 | 0 | j += 2; |
745 | 0 | } |
746 | 5 | let mut k = j; |
747 | 7 | while k < n && tokens[k].kind == TokenKind::Whitespace { |
748 | 2 | k += 1; |
749 | 2 | } |
750 | 5 | if k < n && tokens[k].value == "(" { |
751 | 2 | let mut depth = 0usize; |
752 | 2 | j = k; |
753 | 9 | while j < n { |
754 | 9 | match tokens[j].value.as_str() { |
755 | 9 | "(" => depth += 12 , |
756 | 7 | ")" => { |
757 | 2 | depth -= 1; |
758 | 2 | if depth == 0 { |
759 | 2 | j += 1; |
760 | 2 | break; |
761 | 0 | } |
762 | | } |
763 | 5 | _ => {} |
764 | | } |
765 | 7 | j += 1; |
766 | | } |
767 | 3 | } |
768 | 5 | if flags.is_empty() { |
769 | 4 | flags = vec![false; n]; |
770 | 4 | }1 |
771 | 19 | for flag in &mut 5 flags5 [start..j] { |
772 | 19 | *flag = true; |
773 | 19 | } |
774 | 5 | i = j; |
775 | | } |
776 | 6 | flags |
777 | 6 | } |
778 | | |
779 | | /// Tokenize source code in the given format with the given mode. |
780 | | /// Returns a Vec<Token>. Never panics on empty input — returns empty Vec. |
781 | | /// |
782 | | /// This is the display/reporter path. For the detection path, use |
783 | | /// `tokenize_to_detection`. |
784 | 3.73k | pub fn tokenize(format: &str, source: &str, mode: Mode) -> Vec<Token> { |
785 | 3.73k | let raw = dispatch_tokenizer(format, source, mode); |
786 | | // Apply mode filter inline — keeps Ignore tokens removed, drops Whitespace in |
787 | | // Mild, drops Whitespace+Comment+BlockComment in Weak, keeps all in Strict. |
788 | 3.69M | raw3.73k .into_iter3.73k ().filter3.73k (|t| keep_token(t, mode)).collect3.73k () |
789 | 3.73k | } |
790 | | |
791 | 3.69M | fn keep_token(token: &Token, mode: Mode) -> bool { |
792 | 3.69M | if token.kind == TokenKind::Ignore { |
793 | 138k | return false; |
794 | 3.56M | } |
795 | 3.56M | match mode { |
796 | 3.50M | Mode::Mild => !matches!2.74M (token.kind, TokenKind::Whitespace), |
797 | 41.0k | Mode::Weak => !matches!( |
798 | 51.3k | token.kind, |
799 | | TokenKind::Whitespace | TokenKind::Comment | TokenKind::BlockComment |
800 | | ), |
801 | 3.15k | Mode::Strict => true, |
802 | | } |
803 | 3.69M | } |
804 | | |
805 | | /// Tokenize source code for the detection hot path. |
806 | | /// |
807 | | /// Returns `Vec<DetectionToken>` — tokens filtered and hashed inline at |
808 | | /// tokenize time. No per-token heap allocation survives in the output: |
809 | | /// the value string is consumed; only the hash, locations, and byte range |
810 | | /// are stored. |
811 | | /// |
812 | | /// This replaces the `tokenize` → `apply_mode` → convert-to-hashes pipeline |
813 | | /// that existed in `detect.rs`. |
814 | 2.67k | pub fn tokenize_to_detection( |
815 | 2.67k | format: &str, |
816 | 2.67k | source: &str, |
817 | 2.67k | options: &TokenizeOptions, |
818 | 2.67k | ) -> Vec<DetectionToken> { |
819 | | // Produce the display tokens first (reuse existing tokenizer code), |
820 | | // then convert to DetectionToken in one pass applying options filters. |
821 | | // |
822 | | // This approach is conservative: it reuses all existing tokenizer logic |
823 | | // without risk of introducing per-tokenizer bugs. The conversion is O(n) |
824 | | // and eliminates the separate filter pass and hash computation that |
825 | | // previously happened inside detect.rs. |
826 | 2.67k | let raw = if should_strip_types(format, options) { |
827 | 43 | crate::javascript::tokenize_js_stripped(source, format) |
828 | | } else { |
829 | 2.62k | dispatch_tokenizer(format, source, options.mode) |
830 | | }; |
831 | 2.67k | let annotation = if options.ignore_annotations && strips_annotations7 (format7 ) { |
832 | 6 | mark_annotations(&raw) |
833 | | } else { |
834 | 2.66k | Vec::new() |
835 | | }; |
836 | 2.67k | detection_tokens(raw, options, NameRules::of(format), &annotation) |
837 | 2.67k | } |
838 | | |
839 | | /// The byte ranges of `source` whose tokens the tokenizer of `format` drops |
840 | | /// for `jscpd:ignore-start` and `jscpd:ignore-end` markers, sorted and |
841 | | /// disjoint, so the code inside a range is left out of detection. Each |
842 | | /// language reads the markers as its tokenizer does: the container formats |
843 | | /// run their tokenizer, the others scan the text. Empty without a marker. |
844 | 247 | pub fn ignored_ranges(format: &str, source: &str) -> Vec<[usize; 2]> { |
845 | 247 | if !source.contains("jscpd:ignore") { |
846 | 223 | return Vec::new(); |
847 | 24 | } |
848 | 24 | let ranges = match format { |
849 | 24 | "javascript" | "typescript"14 | "jsx"12 | "tsx"10 => { |
850 | 15 | crate::javascript::find_ignore_ranges(source) |
851 | | } |
852 | 9 | "markdown" | "md" => crate::markdown::collect_ignore_byte_ranges0 (source0 ), |
853 | 9 | "vue" | "svelte" | "astro" | "razor" => ignored_tokens1 (format1 , source1 ), |
854 | 8 | _ => crate::generic::ignore_line_ranges(source), |
855 | | }; |
856 | 24 | merge_ranges(ranges) |
857 | 247 | } |
858 | | |
859 | | /// The byte ranges of the tokens the tokenizer of a container format marks |
860 | | /// as ignored, one per run of them: blanks and comments between ignored |
861 | | /// tokens keep a run whole. |
862 | 1 | fn ignored_tokens(format: &str, source: &str) -> Vec<[usize; 2]> { |
863 | 1 | let mut ranges = Vec::new(); |
864 | 1 | let mut open: Option<[usize; 2]> = None; |
865 | 38 | for token in dispatch_tokenizer1 (format1 , source1 , Mode::Mild1 ) { |
866 | 38 | match token.kind { |
867 | | TokenKind::Ignore => { |
868 | 22 | let (start, end) = (token.start.offset as usize, token.end.offset as usize); |
869 | 22 | match &mut open { |
870 | 21 | Some(range) => range[1] = end, |
871 | 1 | None => open = Some([start, end]), |
872 | | } |
873 | | } |
874 | 0 | TokenKind::Whitespace | TokenKind::Comment | TokenKind::BlockComment => {} |
875 | 16 | _ => ranges.extend(open.take()), |
876 | | } |
877 | | } |
878 | 1 | ranges.extend(open); |
879 | 1 | ranges |
880 | 1 | } |
881 | | |
882 | | /// `ranges` sorted and merged where they overlap or touch, without the |
883 | | /// empty ones. |
884 | 248 | pub fn merge_ranges(mut ranges: Vec<[usize; 2]>) -> Vec<[usize; 2]> { |
885 | 248 | ranges.retain(|[start, end]| start31 < end31 ); |
886 | 248 | ranges.sort_unstable(); |
887 | 248 | let mut merged: Vec<[usize; 2]> = Vec::with_capacity(ranges.len()); |
888 | 248 | for [start31 , end31 ] in ranges { |
889 | 31 | match merged.last_mut() { |
890 | 4 | Some(last0 ) if start <= last[1]0 => last[1]0 = last[1]0 .max(end), |
891 | 31 | _ => merged.push([start, end]), |
892 | | } |
893 | | } |
894 | 248 | merged |
895 | 248 | } |
896 | | |
897 | | /// The `--ignore-pattern` expressions that compile; the others are |
898 | | /// skipped, as a warning says when the run starts. |
899 | 789 | pub fn compile_ignore_patterns(patterns: &[String]) -> Vec<regex::Regex> { |
900 | 789 | patterns |
901 | 789 | .iter() |
902 | 789 | .filter_map(|pattern| regex::Regex::new26 (pattern26 ).ok26 ()) |
903 | 789 | .collect() |
904 | 789 | } |
905 | | |
906 | 6.36k | fn dispatch_tokenizer(format: &str, source: &str, mode: Mode) -> Vec<Token> { |
907 | 6.36k | match format { |
908 | 6.36k | "javascript" | "typescript"4.39k | "jsx"3.51k | "tsx"3.37k => { |
909 | 3.13k | crate::javascript::tokenize_js(source, format) |
910 | | } |
911 | 3.23k | "vue" | "svelte"3.14k | "astro"3.02k => crate::sfc::tokenize_sfc272 (source272 , format272 , mode272 ), |
912 | 2.96k | "razor" => crate::razor::tokenize_razor83 (source83 , mode83 ), |
913 | 2.88k | "markdown" | "md"2.74k => crate::markdown::tokenize_markdown135 (source135 , mode135 ), |
914 | 2.74k | _ => crate::generic::tokenize_generic(source, format), |
915 | | } |
916 | 6.36k | } |
917 | | |
918 | | /// Tokenize source code into one or more format-specific detection maps. |
919 | | /// |
920 | | /// For single-format files, returns exactly one `TokenMap` with the same format. |
921 | | /// For multi-format files (markdown, SFCs), returns one `TokenMap` per detected |
922 | | /// sub-language — e.g. markdown prose + embedded JavaScript + embedded Python. |
923 | | /// |
924 | | /// Each map's tokens carry byte offsets relative to the original source, so |
925 | | /// they can be used directly for clone detection within their format group. |
926 | 1.19k | pub fn tokenize_to_detection_maps( |
927 | 1.19k | format: &str, |
928 | 1.19k | source: &str, |
929 | 1.19k | options: &TokenizeOptions, |
930 | 1.19k | ) -> Vec<TokenMap> { |
931 | 1.19k | match format { |
932 | 1.19k | "markdown" | "md"1.04k => crate::markdown::tokenize_markdown_maps154 (source154 , options154 ), |
933 | 1.04k | "vue" | "svelte"943 | "astro"829 => crate::sfc::tokenize_sfc_maps278 (source278 , format278 , options278 ), |
934 | 764 | "razor" => crate::razor::tokenize_razor_maps64 (source64 , options64 ), |
935 | | _ => { |
936 | 700 | let tokens = tokenize_to_detection(format, source, options); |
937 | 700 | vec![TokenMap { |
938 | 700 | format: format.to_string(), |
939 | 700 | tokens, |
940 | 700 | }] |
941 | | } |
942 | | } |
943 | 1.19k | } |
944 | | |
945 | | #[cfg(test)] |
946 | | mod tests { |
947 | | use super::*; |
948 | | |
949 | | #[test] |
950 | 1 | fn ignore_patterns_drop_the_code_they_match_inside_a_markdown_fence() { |
951 | 1 | let md = "# Guide\n\nSome prose first.\n\n```js\nfunction total(items) {\n let sum = 0;\n console.log(\"start\");\n for (const item of items) sum += item.price;\n return sum;\n}\n```\n"; |
952 | 1 | let re = regex::Regex::new(r"console\.log\(.*\);").unwrap(); |
953 | 1 | let options = TokenizeOptions { |
954 | 1 | ignore_ranges: code_ignore_ranges(md, &[re]), |
955 | 1 | ..TokenizeOptions::new(Mode::Mild) |
956 | 1 | }; |
957 | 1 | let maps = tokenize_to_detection_maps("markdown", md, &options); |
958 | 2 | let js1 = maps.iter()1 .find1 (|map| map.format == "javascript").unwrap1 (); |
959 | 1 | let texts: Vec<&str> = js |
960 | 1 | .tokens |
961 | 1 | .iter() |
962 | 28 | .map1 (|t| &md[t.range[0]..t.range[1]]) |
963 | 1 | .collect(); |
964 | 1 | assert!(!texts.contains(&"console"), "{texts:?}"); |
965 | 1 | assert!(texts.contains(&"return"), "{texts:?}"); |
966 | 1 | assert_eq!( |
967 | 1 | ranges_in(&[[2, 8], [20, 30]], 5..25), |
968 | 1 | vec![[0, 3], [15, 20]] |
969 | | ); |
970 | 1 | } |
971 | | |
972 | | #[test] |
973 | 1 | fn ignored_ranges_hold_what_the_tokenizers_drop_and_nothing_else() { |
974 | 1 | let sources = [ |
975 | 1 | ( |
976 | 1 | "python", |
977 | 1 | "a = 1\n# jscpd:ignore-start\nb = 2\n# jscpd:ignore-end\nc = 3\n", |
978 | 1 | ), |
979 | 1 | ("python", "a = 1\nb = close(a) # jscpd:ignore-end\nc = 3\n"), |
980 | 1 | ("python", "x = 1 # jscpd:ignore-start\ny = 2\n"), |
981 | 1 | ( |
982 | 1 | "python", |
983 | 1 | "a = 1\n# jscpd:ignore-start jscpd:ignore-end\nb = 2\n", |
984 | 1 | ), |
985 | 1 | ( |
986 | 1 | "python", |
987 | 1 | "a = 1\r\n# jscpd:ignore-start\r\nb = 2\r\n# jscpd:ignore-end\r\nc = 3\r\n", |
988 | 1 | ), |
989 | 1 | ( |
990 | 1 | "javascript", |
991 | 1 | "let a = 1;\n// jscpd:ignore-start\nlet b = 2;\n/* jscpd:ignore-end */ let c = 3;\n", |
992 | 1 | ), |
993 | 1 | ( |
994 | 1 | "javascript", |
995 | 1 | "let a = 1;\n// jscpd:ignore-start\nlet b = 2;\n", |
996 | 1 | ), |
997 | 1 | ( |
998 | 1 | "typescript", |
999 | 1 | "const a = 1; /* jscpd:ignore-start */ const b = 2; /* jscpd:ignore-end */ const c = 3;\n", |
1000 | 1 | ), |
1001 | 1 | ]; |
1002 | 8 | for (format, source) in sources1 { |
1003 | 8 | let ranges = ignored_ranges(format, source); |
1004 | 8 | let all = tokenize(format, source, Mode::Strict); |
1005 | 8 | let kept = dispatch_tokenizer(format, source, Mode::Strict); |
1006 | 104 | for token in &kept8 { |
1007 | 104 | let (start, end) = (token.start.offset as usize, token.end.offset as usize); |
1008 | 104 | let inside = ranges.iter().any(|[s, e]| *s <= start94 && end <= *e64 ); |
1009 | 104 | let overlaps = ranges.iter().any(|[s, e]| start < *e94 && end > *s64 ); |
1010 | 104 | match token.kind { |
1011 | | TokenKind::Ignore => { |
1012 | 34 | assert!(inside, "{format} {source:?}: {token:?} in {ranges:?}") |
1013 | | } |
1014 | 16 | TokenKind::Whitespace | TokenKind::Comment | TokenKind::BlockComment => {} |
1015 | 54 | _ => assert!(!overlaps, "{format} {source:?}: {token:?} in {ranges:?}"), |
1016 | | } |
1017 | | } |
1018 | | // A token the tokenizer drops without a trace lies in a range too. |
1019 | 8 | for (at1 , _) in source.match_indices("close") { |
1020 | 10 | let dropped1 = !all.iter()1 .any1 (|t| t.start.offset as usize == at); |
1021 | 1 | if dropped { |
1022 | 1 | assert!( |
1023 | 1 | ranges.iter().any(|[s, e]| *s <= at && at < *e), |
1024 | | "{source:?}" |
1025 | | ); |
1026 | 0 | } |
1027 | | } |
1028 | | } |
1029 | 1 | } |
1030 | | |
1031 | | #[test] |
1032 | 1 | fn ignore_patterns_drop_the_code_they_match_in_razor_and_vue_blocks() { |
1033 | 1 | let pattern = regex::Regex::new(r"Log\.Debug\([^)]*\);").unwrap(); |
1034 | 1 | let razor = "<h1>@Model.Title</h1>\n<p>Totals</p>\n@{\n var total = Model.Lines.Sum(l => l.Price);\n Log.Debug(\"total computed\");\n ViewData[\"Total\"] = total;\n}\n"; |
1035 | 1 | let options = TokenizeOptions { |
1036 | 1 | ignore_ranges: code_ignore_ranges(razor, std::slice::from_ref(&pattern)), |
1037 | 1 | ..TokenizeOptions::new(Mode::Mild) |
1038 | 1 | }; |
1039 | 1 | let texts: Vec<&str> = tokenize_to_detection("razor", razor, &options) |
1040 | 1 | .iter() |
1041 | 46 | .map1 (|t| &razor[t.range[0]..t.range[1]]) |
1042 | 1 | .collect(); |
1043 | 1 | assert!(!texts.contains(&"Log"), "{texts:?}"); |
1044 | 1 | assert!(texts.contains(&"ViewData"), "{texts:?}"); |
1045 | | |
1046 | 1 | let md = "# Guide\n\nA component:\n\n```vue\n<template><p>{{ total }}</p></template>\n<script>\nexport default {\n computed: {\n total() {\n Log.Debug(\"total\");\n return this.lines.length;\n },\n },\n};\n</script>\n```\n"; |
1047 | 1 | let options = TokenizeOptions { |
1048 | 1 | ignore_ranges: code_ignore_ranges(md, std::slice::from_ref(&pattern)), |
1049 | 1 | ..TokenizeOptions::new(Mode::Mild) |
1050 | 1 | }; |
1051 | 1 | let maps = tokenize_to_detection_maps("markdown", md, &options); |
1052 | 1 | let texts: Vec<&str> = maps |
1053 | 1 | .iter() |
1054 | 2 | .filter1 (|map| map.format != "markdown") |
1055 | 1 | .flat_map(|map| map.tokens.iter()) |
1056 | 35 | .map1 (|t| &md[t.range[0]..t.range[1]]) |
1057 | 1 | .collect(); |
1058 | 1 | assert!(!texts.contains(&"Log"), "{texts:?}"); |
1059 | 1 | assert!(texts.contains(&"return"), "{texts:?}"); |
1060 | 1 | } |
1061 | | |
1062 | | #[test] |
1063 | 1 | fn ignored_ranges_cover_the_code_between_the_markers() { |
1064 | 1 | let python = "def f(x):\n y = x\n # jscpd:ignore-start\n for i in x:\n # retry\n y += i\n # jscpd:ignore-end\n return y\n"; |
1065 | 1 | let ranges = ignored_ranges("python", python); |
1066 | 1 | assert_eq!(ranges.len(), 1, "{ranges:?}"); |
1067 | 1 | let [start, end] = ranges[0]; |
1068 | 1 | let ignored = &python[start..end]; |
1069 | 1 | assert!(ignored.contains("for i in x:"), "{ignored:?}"); |
1070 | 1 | assert!(ignored.contains("y += i"), "{ignored:?}"); |
1071 | 1 | assert!(!ignored.contains("return"), "{ignored:?}"); |
1072 | | |
1073 | 1 | let js = "function f(x) {\n let y = x;\n // jscpd:ignore-start\n for (const i of x) { y += i; }\n // jscpd:ignore-end\n return y;\n}\n"; |
1074 | 1 | let ranges = ignored_ranges("javascript", js); |
1075 | 1 | assert_eq!(ranges.len(), 1, "{ranges:?}"); |
1076 | 1 | let ignored = &js[ranges[0][0]..ranges[0][1]]; |
1077 | 1 | assert!( |
1078 | 1 | ignored.contains("for (const i of x) { y += i; }"), |
1079 | | "{ignored:?}" |
1080 | | ); |
1081 | 1 | assert!(!ignored.contains("return"), "{ignored:?}"); |
1082 | | |
1083 | 1 | assert!(ignored_ranges("python", "x = 1\n").is_empty()); |
1084 | 1 | } |
1085 | | |
1086 | | #[test] |
1087 | 1 | fn mode_from_str_defaults_to_mild() { |
1088 | 1 | assert_eq!("unknown".parse::<Mode>().unwrap(), Mode::Mild); |
1089 | 1 | assert_eq!("mild".parse::<Mode>().unwrap(), Mode::Mild); |
1090 | 1 | } |
1091 | | |
1092 | | #[test] |
1093 | 1 | fn mode_from_str_weak() { |
1094 | 1 | assert_eq!("weak".parse::<Mode>().unwrap(), Mode::Weak); |
1095 | 1 | } |
1096 | | |
1097 | | #[test] |
1098 | 1 | fn mode_from_str_strict() { |
1099 | 1 | assert_eq!("strict".parse::<Mode>().unwrap(), Mode::Strict); |
1100 | 1 | } |
1101 | | |
1102 | 49 | fn det(source: &str, format: &str, opts: &TokenizeOptions) -> Vec<DetectionToken> { |
1103 | 49 | tokenize_to_detection(format, source, opts) |
1104 | 49 | } |
1105 | | |
1106 | 24 | fn hashes(tokens: &[DetectionToken]) -> Vec<u64> { |
1107 | 24 | tokens.iter().map(|t| t.hash).collect() |
1108 | 24 | } |
1109 | | |
1110 | | #[test] |
1111 | 1 | fn default_options_keep_raw_hash_equal_to_hash() { |
1112 | 1 | let opts = TokenizeOptions::new(Mode::Mild); |
1113 | 1 | let tokens = det("function a(x) { return x + 1; }", "javascript", &opts); |
1114 | 1 | assert!(!tokens.is_empty()); |
1115 | 12 | assert!1 (tokens.iter()1 .all1 (|t| t.raw_hash == t.hash)); |
1116 | 1 | } |
1117 | | |
1118 | | #[test] |
1119 | 1 | fn ignore_identifiers_matches_renamed_code_and_keeps_keywords() { |
1120 | 1 | let mut opts = TokenizeOptions::new(Mode::Mild); |
1121 | 1 | opts.ignore_identifiers = true; |
1122 | 1 | let a = det("function a(x) { return x + 1; }", "javascript", &opts); |
1123 | 1 | let b = det("function b(y) { return y + 1; }", "javascript", &opts); |
1124 | 1 | assert_eq!( |
1125 | 1 | hashes(&a), |
1126 | 1 | hashes(&b), |
1127 | | "renamed identifiers must hash alike" |
1128 | | ); |
1129 | | // raw hashes still differ where the names differ |
1130 | 1 | assert_ne!( |
1131 | 1 | a.iter().map(|t| t.raw_hash).collect::<Vec<_>>(), |
1132 | 1 | b.iter().map(|t| t.raw_hash).collect::<Vec<_>>() |
1133 | | ); |
1134 | | // keywords are not folded: `return` vs `throw` must stay distinct |
1135 | 1 | let c = det("function a(x) { throw x + 1; }", "javascript", &opts); |
1136 | 1 | assert_ne!(hashes(&a), hashes(&c)); |
1137 | 1 | } |
1138 | | |
1139 | | /// The text and the name of every token of `code` that has a name. |
1140 | 25 | fn names<'a>(code: &'a str, format: &str, opts: &TokenizeOptions) -> Vec<(&'a str, u64)> { |
1141 | 25 | det(code, format, opts) |
1142 | 25 | .iter() |
1143 | 260 | .filter25 (|t| t.name != 0) |
1144 | 102 | .map25 (|t| (&code[t.range[0]..t.range[1]], t.name)) |
1145 | 25 | .collect() |
1146 | 25 | } |
1147 | | |
1148 | | #[test] |
1149 | 1 | fn renamed_identifiers_keep_their_names() { |
1150 | | // Consistent matching (issue #1217) pairs the names of two clones. |
1151 | 1 | let mut opts = TokenizeOptions::new(Mode::Mild); |
1152 | 1 | opts.ignore_identifiers = true; |
1153 | 2 | for (code, format) in [1 ("a = b + a", "python")1 , ("let a = b + a;", "javascript")] { |
1154 | 2 | let names = names(code, format, &opts); |
1155 | 2 | let texts: Vec<&str> = names.iter().map(|(text, _)| *text).collect(); |
1156 | 2 | assert_eq!(texts, ["a", "b", "a"], "{format}: only identifiers"); |
1157 | 2 | assert_eq!(names[0].1, names[2].1, "{format}"); |
1158 | 2 | assert_ne!(names[0].1, names[1].1, "{format}"); |
1159 | | } |
1160 | 1 | opts.ignore_identifiers = false; |
1161 | 1 | assert!(names("a = b + a", "python", &opts).is_empty()); |
1162 | 1 | } |
1163 | | |
1164 | | #[test] |
1165 | 1 | fn a_dropped_annotation_leaves_the_name_before_it_alone() { |
1166 | 1 | let mut opts = TokenizeOptions::new(Mode::Mild); |
1167 | 1 | opts.ignore_identifiers = true; |
1168 | 1 | opts.ignore_annotations = true; |
1169 | 1 | let code = "def f(self):\n return result\n@staticmethod\ndef g():\n return result\n"; |
1170 | 1 | let tokens = det(code, "python", &opts); |
1171 | 1 | let results: Vec<_> = tokens |
1172 | 1 | .iter() |
1173 | 15 | .filter1 (|t| &code[t.range[0]..t.range[1]] == "result") |
1174 | 1 | .collect(); |
1175 | 1 | assert_eq!(results.len(), 2); |
1176 | | // The decorator salts the raw hash of the `result` before it. |
1177 | 1 | assert_ne!(results[0].raw_hash, results[1].raw_hash); |
1178 | 1 | assert_eq!(results[0].name, results[1].name); |
1179 | 1 | } |
1180 | | |
1181 | | #[test] |
1182 | 1 | fn names_ignore_case_where_the_language_does() { |
1183 | 1 | let mut opts = TokenizeOptions::new(Mode::Mild); |
1184 | 1 | opts.ignore_identifiers = true; |
1185 | 1 | let sql = "SELECT Customer.Name FROM Customer WHERE customer.City = 1"; |
1186 | 1 | let customer: Vec<u64> = names(sql, "sql", &opts) |
1187 | 1 | .into_iter() |
1188 | 8 | .filter1 (|(text, _)| text.eq_ignore_ascii_case("customer")) |
1189 | 1 | .map(|(_, name)| name) |
1190 | 1 | .collect(); |
1191 | 1 | assert_eq!(customer.len(), 3, "{customer:?}"); |
1192 | 3 | assert!1 (customer.iter()1 .all1 (|&name| name == customer[0])); |
1193 | 1 | let js = names("Customer = customer;", "javascript", &opts); |
1194 | 1 | assert_ne!(js[0].1, js[1].1, "JavaScript names keep their case"); |
1195 | | // --ignore-case compares tokens; it does not make two names one. |
1196 | 1 | opts.ignore_case = true; |
1197 | 1 | let js = names("Customer = customer;", "javascript", &opts); |
1198 | 1 | assert_ne!(js[0].1, js[1].1, "with --ignore-case too"); |
1199 | 1 | } |
1200 | | |
1201 | | #[test] |
1202 | 1 | fn a_member_is_another_name_than_the_local_it_spells() { |
1203 | 1 | let mut opts = TokenizeOptions::new(Mode::Mild); |
1204 | 1 | opts.ignore_identifiers = true; |
1205 | 19 | let urls1 = |code: &str, format: &str| -> Vec<u64> { |
1206 | 19 | names(code, format, &opts) |
1207 | 19 | .into_iter() |
1208 | 84 | .filter19 (|(text, _)| *text == "url") |
1209 | 19 | .map(|(_, name)| name) |
1210 | 19 | .collect() |
1211 | 19 | }; |
1212 | | // A member, the local, and a member again. |
1213 | 9 | for (format, code) in [ |
1214 | 1 | ("javascript", "this.url = url; a?.url;"), |
1215 | 1 | ("python", "self.url = url\nother.url\n"), |
1216 | 1 | ("java", "this.url = url; that.url = 1;"), |
1217 | 1 | ("kotlin", "this.url = url; a?.url"), |
1218 | 1 | ("go", "s.url = url; t.url = 1"), |
1219 | 1 | ("c", "p->url = url; q.url = 1;"), |
1220 | 1 | ("arduino", "p->url = url; q.url = 1;"), |
1221 | 1 | ("php", "<?php $this->url = $url; $o->url;"), |
1222 | 1 | ("rust", "s.0.url = url; t.url = 1;"), |
1223 | 1 | ] { |
1224 | 9 | let urls = urls(code, format); |
1225 | 9 | assert_eq!(urls.len(), 3, "{format}"); |
1226 | 9 | assert_ne!(urls[0], urls[1], "{format}"); |
1227 | 9 | assert_eq!(urls[0], urls[2], "{format}"); |
1228 | | } |
1229 | | // Ranges, spreads, varargs, a Rust return type and prose. |
1230 | 10 | for (format, code) in [ |
1231 | 1 | ("rust", "let r = url..url;"), |
1232 | 1 | ("ruby", "r = (url...url)"), |
1233 | 1 | ("javascript", "f(...url, url);"), |
1234 | 1 | ("java", "void f(String... url) { g(url); }"), |
1235 | 1 | ("rust", "fn f(url: u8) -> url { url }"), |
1236 | 1 | ("markdown", "Read it. url\nurl"), |
1237 | 1 | ("html", "<p>Read it. url url</p>"), |
1238 | 1 | // `.` joins strings in PHP and compares in Fortran. |
1239 | 1 | ("php", "<?php $a = $b . url($c) . url($d);"), |
1240 | 1 | ("fortran", "if (a .gt. url) url = 1"), |
1241 | 1 | // `i-- > url` written without spaces. |
1242 | 1 | ("c", "while (i-->url) url++;"), |
1243 | 1 | ] { |
1244 | 10 | let urls = urls(code, format); |
1245 | 10 | assert!(urls.len() >= 2, "{format}: {urls:?}"); |
1246 | 21 | assert!10 (urls.iter()10 .all10 (|&name| name == urls[0]), "{format}: {code}"); |
1247 | | } |
1248 | 1 | } |
1249 | | |
1250 | | #[test] |
1251 | 1 | fn ignore_identifiers_keeps_common_keywords_in_generic_languages() { |
1252 | 1 | let mut opts = TokenizeOptions::new(Mode::Mild); |
1253 | 1 | opts.ignore_identifiers = true; |
1254 | 1 | let a = det("if x:\n return y\n", "python", &opts); |
1255 | 1 | let b = det("if p:\n return q\n", "python", &opts); |
1256 | 1 | let c = det("while x:\n return y\n", "python", &opts); |
1257 | 1 | assert_eq!(hashes(&a), hashes(&b)); |
1258 | 1 | assert_ne!( |
1259 | 1 | hashes(&a), |
1260 | 1 | hashes(&c), |
1261 | | "`if` and `while` are keywords, not identifiers" |
1262 | | ); |
1263 | 1 | assert!(is_common_keyword("return")); |
1264 | 1 | assert!(!is_common_keyword("total")); |
1265 | 1 | } |
1266 | | |
1267 | | #[test] |
1268 | 1 | fn ignore_literals_folds_strings_and_numbers_separately() { |
1269 | 1 | let mut opts = TokenizeOptions::new(Mode::Mild); |
1270 | 1 | opts.ignore_literals = true; |
1271 | 1 | let a = det("const a = 10; const b = 'x';", "javascript", &opts); |
1272 | 1 | let b = det("const a = 25; const b = \"yy\";", "javascript", &opts); |
1273 | 1 | assert_eq!(hashes(&a), hashes(&b)); |
1274 | 1 | let c = det("const a = 'ten'; const b = 'x';", "javascript", &opts); |
1275 | 1 | assert_ne!(hashes(&a), hashes(&c), "a string is not a number"); |
1276 | | // A leading-dot number, a prefixed string and a keyword literal. |
1277 | 1 | let d = det("const a = .5; const b = 'x';", "javascript", &opts); |
1278 | 1 | assert_eq!(hashes(&a), hashes(&d), ".5 is a number too"); |
1279 | 1 | let raw_a = det("p = r'one'\n", "python", &opts); |
1280 | 1 | let raw_b = det("p = r\"two\"\n", "python", &opts); |
1281 | 1 | assert_eq!(hashes(&raw_a), hashes(&raw_b), "a prefixed string folds"); |
1282 | 1 | let t = det("const a = true;", "javascript", &opts); |
1283 | 1 | let f = det("const a = false;", "javascript", &opts); |
1284 | 1 | assert_ne!(hashes(&t), hashes(&f), "true and false are not folded"); |
1285 | 1 | } |
1286 | | |
1287 | | #[test] |
1288 | 1 | fn ignore_annotations_drops_decorators_in_listed_formats_only() { |
1289 | 1 | let mut opts = TokenizeOptions::new(Mode::Mild); |
1290 | 1 | opts.ignore_annotations = true; |
1291 | 1 | let plain = det("class A { m() { return 1; } }", "typescript", &opts); |
1292 | 1 | let decorated = det( |
1293 | 1 | "@Component({ selector: 'a' })\nclass A { @Input() m() { return 1; } }", |
1294 | 1 | "typescript", |
1295 | 1 | &opts, |
1296 | | ); |
1297 | 1 | assert_eq!(hashes(&plain), hashes(&decorated)); |
1298 | | // the token before an inner annotation carries a salted raw hash |
1299 | 3 | assert!1 (decorated.iter()1 .any1 (|t| t.raw_hash != t.hash)); |
1300 | | |
1301 | 1 | let java_a = det( |
1302 | 1 | "class A {\n @Override\n int f() { return 1; }\n}", |
1303 | 1 | "java", |
1304 | 1 | &opts, |
1305 | | ); |
1306 | 1 | let java_b = det( |
1307 | 1 | "class A {\n @Deprecated\n int f() { return 1; }\n}", |
1308 | 1 | "java", |
1309 | 1 | &opts, |
1310 | | ); |
1311 | 1 | assert_eq!(hashes(&java_a), hashes(&java_b)); |
1312 | 1 | assert_ne!( |
1313 | 1 | java_a.iter().map(|t| t.raw_hash).collect::<Vec<_>>(), |
1314 | 1 | java_b.iter().map(|t| t.raw_hash).collect::<Vec<_>>(), |
1315 | | "different annotations must leave different raw hashes" |
1316 | | ); |
1317 | | |
1318 | | // `@interface` declares an annotation type; it is not an annotation use |
1319 | 1 | let decl = "public @interface Marker {\n String value() default \"\";\n}"; |
1320 | 1 | let stripped = det(decl, "java", &opts); |
1321 | 1 | let untouched = det(decl, "java", &TokenizeOptions::new(Mode::Mild)); |
1322 | 1 | assert_eq!(hashes(&stripped), hashes(&untouched)); |
1323 | | |
1324 | | // Ruby instance variables use `@` and must survive |
1325 | 1 | let ruby = det("@count = 1", "ruby", &opts); |
1326 | 1 | assert!(strips_annotations("kotlin")); |
1327 | 1 | assert!(!strips_annotations("ruby")); |
1328 | 1 | assert_eq!( |
1329 | 1 | ruby.len(), |
1330 | 1 | det("@count = 1", "ruby", &TokenizeOptions::new(Mode::Mild)).len() |
1331 | | ); |
1332 | 1 | } |
1333 | | |
1334 | | #[test] |
1335 | 1 | fn code_ignore_ranges_computes_from_source_text() { |
1336 | 1 | let source = "import foo from 'bar';\nconst x = 1;"; |
1337 | 1 | let re = regex::Regex::new(r"import\s+\w+\s+from").unwrap(); |
1338 | 1 | let ranges = code_ignore_ranges(source, &[re]); |
1339 | 1 | assert_eq!(ranges.len(), 1, "should find one regex match"); |
1340 | | // "import foo from" starts at byte 0, ends at byte 15 |
1341 | 1 | assert_eq!(ranges[0], [0, 15]); |
1342 | 1 | } |
1343 | | |
1344 | | #[test] |
1345 | 1 | fn code_ignore_ranges_multiple_patterns() { |
1346 | 1 | let source = "// MIT License\nfunction foo() {}\n// Copyright"; |
1347 | 1 | let re1 = regex::Regex::new(r"//\s*MIT\s+License").unwrap(); |
1348 | 1 | let re2 = regex::Regex::new(r"//\s*Copyright").unwrap(); |
1349 | 1 | let ranges = code_ignore_ranges(source, &[re1, re2]); |
1350 | 1 | assert_eq!(ranges.len(), 2, "should find two regex matches"); |
1351 | 1 | } |
1352 | | |
1353 | | #[test] |
1354 | 1 | fn code_ignore_ranges_empty_regexes() { |
1355 | 1 | let source = "function foo() {}"; |
1356 | 1 | let ranges = code_ignore_ranges(source, &[]); |
1357 | 1 | assert!(ranges.is_empty(), "no regexes means no ranges"); |
1358 | 1 | } |
1359 | | |
1360 | | #[test] |
1361 | 1 | fn code_ignore_ranges_multi_token_match() { |
1362 | | // The key test: regex "import.*from" matches multi-token source text |
1363 | | // like "import * from 'module-name'" — not just a single token value. |
1364 | 1 | let source = "import * from 'lodash';\nconst result = 42;"; |
1365 | 1 | let re = regex::Regex::new(r"import\s+.*?\s+from").unwrap(); |
1366 | 1 | let ranges = code_ignore_ranges(source, &[re]); |
1367 | 1 | assert_eq!( |
1368 | 1 | ranges.len(), |
1369 | | 1, |
1370 | | "should find one regex match spanning import statement" |
1371 | | ); |
1372 | 1 | assert!(ranges[0][0] == 0, "match should start at beginning"); |
1373 | 1 | assert!(ranges[0][1] > 0, "match should have non-zero end"); |
1374 | 1 | } |
1375 | | } |