Coverage Report

Created: 2026-11-01 13:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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
}
pages.flisk.si@odykyiv3sourcereport