Skip to main content

cpd_core/
detect.rs

1// detect.rs
2
3use rayon::prelude::*;
4use rustc_hash::{FxHashMap, FxHashSet};
5use std::path::{Path, PathBuf};
6
7use crate::{
8    hash::{base_pow, hash_window, roll, token_hash},
9    models::{
10        CloneKind, CpdClone, DetectionToken, Fragment, Location, SimilarityMethod, SourceFile,
11        TokenKind, covered_lines,
12    },
13};
14
15// ---------------------------------------------------------------------------
16// Internal store type — replaces the Store trait + MemoryStore
17// ---------------------------------------------------------------------------
18
19/// Window store: maps a window hash to the last seen occurrence.
20/// Type alias — no trait indirection, no vtable, no dyn dispatch.
21type WindowStore = FxHashMap<u64, Occurrence>;
22
23/// Lightweight reference to a window position within a format-group detection call.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25struct Occurrence {
26    /// Index into the `prepared` array for this `detect_in_group` call.
27    source_id: usize,
28    token_start: usize,
29}
30
31// ---------------------------------------------------------------------------
32// Deduplication key
33// ---------------------------------------------------------------------------
34
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36struct CloneDedupKey {
37    a_id: String,
38    a_start_line: u32,
39    b_id: String,
40    b_start_line: u32,
41}
42
43impl CloneDedupKey {
44    fn from_clone(c: &CpdClone) -> Self {
45        // Normalize: smaller (id, line) first so (A,B) and (B,A) map to the same key.
46        let a_key = (&c.fragment_a.source_id, c.fragment_a.start.line);
47        let b_key = (&c.fragment_b.source_id, c.fragment_b.start.line);
48        if a_key <= b_key {
49            Self {
50                a_id: c.fragment_a.source_id.clone(),
51                a_start_line: c.fragment_a.start.line,
52                b_id: c.fragment_b.source_id.clone(),
53                b_start_line: c.fragment_b.start.line,
54            }
55        } else {
56            Self {
57                a_id: c.fragment_b.source_id.clone(),
58                a_start_line: c.fragment_b.start.line,
59                b_id: c.fragment_a.source_id.clone(),
60                b_start_line: c.fragment_a.start.line,
61            }
62        }
63    }
64}
65
66// ---------------------------------------------------------------------------
67// Public API — SourceFile path (for backward compat with tests)
68// ---------------------------------------------------------------------------
69
70/// Detect duplicate code clones across `files` using a rolling-hash sliding window.
71///
72/// Files are grouped by format; each format group is processed independently.
73/// Rayon is used for outer parallelism (one task per format group).
74pub fn detect(files: &[SourceFile], min_tokens: usize) -> Vec<CpdClone> {
75    detect_with_options(files, min_tokens, 0, &PathFilters::default())
76}
77
78/// Detect clones with extended options.
79///
80/// - `min_lines`: reject clones whose fragment line span is shorter than this.
81///   The line span is `end.line - start.line`; a clone is kept only if this value
82///   is >= `min_lines`. This mirrors jscpd's `LinesLengthCloneValidator`.
83/// - `filters`: path-based clone pair filters ([`PathFilters`]).
84pub fn detect_with_options(
85    files: &[SourceFile],
86    min_tokens: usize,
87    min_lines: usize,
88    filters: &PathFilters,
89) -> Vec<CpdClone> {
90    if files.is_empty() || min_tokens == 0 {
91        return vec![];
92    }
93
94    // Group files by format. Sort for deterministic order.
95    let mut by_format: FxHashMap<&str, Vec<&SourceFile>> = FxHashMap::default();
96    for file in files {
97        by_format
98            .entry(file.format.as_str())
99            .or_default()
100            .push(file);
101    }
102    let mut format_groups: Vec<(&str, Vec<&SourceFile>)> = by_format.into_iter().collect();
103    format_groups.sort_unstable_by_key(|(fmt, _)| *fmt);
104    for (_, group) in &mut format_groups {
105        group.sort_unstable_by_key(|&file| file.id.as_str());
106    }
107
108    let mut clones: Vec<CpdClone> = format_groups
109        .into_par_iter()
110        .flat_map(|(_format, files)| {
111            // Build per-group prepared data from SourceFile.tokens.
112            // This is the backward-compat path; orchestrate.rs uses
113            // detect_prepared() directly to avoid re-hashing.
114            let prepared: Vec<PreparedSource> = files
115                .into_iter()
116                .map(|file| {
117                    let mut hashes = Vec::with_capacity(file.tokens.len());
118                    let mut spans: Vec<(Location, Location)> =
119                        Vec::with_capacity(file.tokens.len());
120                    for t in &file.tokens {
121                        if t.kind == TokenKind::Ignore {
122                            continue;
123                        }
124                        hashes.push(token_hash(t.kind.discriminant(), &t.value));
125                        spans.push((t.start.clone(), t.end.clone()));
126                    }
127                    PreparedSource {
128                        id: file.id.clone(),
129                        format: file.format.clone(),
130                        hashes,
131                        spans,
132                        raw_hashes: Vec::new(),
133                        functions: Vec::new(),
134                        real_path: String::new(),
135                        embedded: false,
136                    }
137                })
138                .collect();
139            detect_in_group(&prepared, min_tokens, min_lines, filters)
140        })
141        .collect();
142
143    finalize_clones(&mut clones);
144    clones
145}
146
147fn finalize_clones(clones: &mut Vec<CpdClone>) {
148    dedup_exact_clones(clones);
149    clones.sort_by(|a, b| a.position_key().cmp(&b.position_key()));
150}
151
152// ---------------------------------------------------------------------------
153// Direct DetectionToken path (called by orchestrate.rs)
154// ---------------------------------------------------------------------------
155
156/// A file ready for detection: pre-hashed, pre-filtered.
157///
158/// Produced either from `SourceFile.tokens` (backward compat) or directly from
159/// `tokenize_to_detection` output (fast path used by orchestrate.rs).
160#[derive(Debug, Clone)]
161pub struct PreparedSource {
162    pub id: String,
163    pub format: String,
164    pub hashes: Vec<u64>,
165    pub spans: Vec<(Location, Location)>,
166    /// Un-normalized token hashes, parallel to `hashes`. Empty unless a
167    /// normalization option rewrote at least one token of this source; then
168    /// it is used to classify clones as exact or renamed (issue #998).
169    pub raw_hashes: Vec<u64>,
170    /// Function signatures for similarity scoring (issue #999). Empty unless
171    /// `--similarity` is set and the format is JavaScript/TypeScript.
172    pub functions: Vec<crate::similarity::FunctionSig>,
173    /// Canonical on-disk path of the file; empty when it equals `id`. The two
174    /// differ behind a symlink: `id` keeps the path the walker found the file
175    /// at, which is what reports, `--ignore` and the path filters use (issue
176    /// #1059). `--skip-isolated` falls back to this path so a group folder
177    /// that is itself a symlink still matches the files found through it.
178    pub real_path: String,
179    /// True when this source is one language embedded in another — a fenced
180    /// block in markdown, a `<script>` in a single-file component. Its tokens
181    /// keep the host file's line numbers, so a clone's line span may run
182    /// across host text that belongs to no block (issue #1090); statistics
183    /// discount those lines.
184    pub embedded: bool,
185}
186
187impl PreparedSource {
188    /// The canonical path when it differs from `id`, otherwise `id` itself.
189    pub fn filter_path(&self) -> &str {
190        if self.real_path.is_empty() {
191            &self.id
192        } else {
193            &self.real_path
194        }
195    }
196
197    /// Build from a `DetectionToken` slice — the fast path.
198    pub fn from_detection_tokens(id: String, format: String, tokens: &[DetectionToken]) -> Self {
199        let mut hashes = Vec::with_capacity(tokens.len());
200        let mut spans = Vec::with_capacity(tokens.len());
201        // Only materialize raw hashes once a token proves normalization was
202        // applied; the default path allocates nothing extra.
203        let mut raw_hashes: Vec<u64> = Vec::new();
204        for (i, t) in tokens.iter().enumerate() {
205            hashes.push(t.hash);
206            spans.push((t.start.clone(), t.end.clone()));
207            if raw_hashes.is_empty() && t.raw_hash != t.hash {
208                raw_hashes.reserve(tokens.len());
209                raw_hashes.extend(hashes[..i].iter().copied());
210            }
211            if !raw_hashes.is_empty() {
212                raw_hashes.push(t.raw_hash);
213            }
214        }
215        Self {
216            id,
217            format,
218            hashes,
219            spans,
220            raw_hashes,
221            functions: Vec::new(),
222            real_path: String::new(),
223            embedded: false,
224        }
225    }
226}
227
228/// Detect clones from pre-prepared sources grouped by format.
229///
230/// Called by orchestrate.rs after `tokenize_to_detection` — skips re-hashing.
231/// - `filters`: path-based clone pair filters ([`PathFilters`]).
232pub fn detect_prepared(
233    format_groups: Vec<Vec<PreparedSource>>,
234    min_tokens: usize,
235    min_lines: usize,
236    filters: &PathFilters,
237) -> Vec<CpdClone> {
238    if format_groups.is_empty() || min_tokens == 0 {
239        return vec![];
240    }
241
242    let mut clones: Vec<CpdClone> = format_groups
243        .into_par_iter()
244        .flat_map(|group| detect_in_group(&group, min_tokens, min_lines, filters))
245        .collect();
246
247    finalize_clones(&mut clones);
248    clones
249}
250
251// ---------------------------------------------------------------------------
252// Core detection — per format group
253// ---------------------------------------------------------------------------
254
255fn detect_in_group(
256    prepared: &[PreparedSource],
257    min_tokens: usize,
258    min_lines: usize,
259    filters: &PathFilters,
260) -> Vec<CpdClone> {
261    // Precompute window_power once for this format group.
262    // If per-language min_tokens is introduced, recompute per group (it is already scoped here).
263    let window_power = base_pow(min_tokens.saturating_sub(1));
264
265    // Pre-allocate store capacity to avoid FxHashMap rehashing.
266    let total_windows: usize = prepared
267        .iter()
268        .map(|p| p.hashes.len().saturating_sub(min_tokens))
269        .sum();
270    let mut store: WindowStore =
271        FxHashMap::with_capacity_and_hasher(total_windows, Default::default());
272
273    let mut clones: Vec<CpdClone> = Vec::new();
274    // Cap repeated-window occurrences per hash. Higher values find more clone pairs
275    // among 3+ similar files (e.g., file_1.js, file_1.mjs, file_1.cjs) but use more memory.
276    // The TypeScript jscpd compares all file pairs, so we raise this to match its coverage.
277    const SECONDARY_OCCURRENCE_CAP: usize = 2;
278    let mut repeated_windows: FxHashMap<u64, Vec<Occurrence>> = FxHashMap::default();
279
280    for (file_idx, source) in prepared.iter().enumerate() {
281        let hashes = &source.hashes;
282        if hashes.len() < min_tokens {
283            continue;
284        }
285        let windows_len = hashes.len() - min_tokens + 1;
286
287        // open_clone state machine: replaces emit-every-window + suppress_subclones.
288        // A clone is opened when a matching window is found and enlarged as long as
289        // subsequent windows also match. flush_clone is called when the match breaks
290        // or the file scan ends — only one clone per contiguous matching region.
291        let mut open_clone: Option<OpenClone> = None;
292
293        let mut window_hash = hash_window(&hashes[..min_tokens]);
294
295        for token_start in 0..windows_len {
296            if token_start > 0 {
297                window_hash = roll(
298                    window_hash,
299                    hashes[token_start - 1],
300                    hashes[token_start + min_tokens - 1],
301                    window_power,
302                );
303            }
304
305            let current = Occurrence {
306                source_id: file_idx,
307                token_start,
308            };
309
310            let stored = store
311                .get(&window_hash)
312                .copied()
313                .filter(|stored| windows_match(*stored, current, prepared, min_tokens));
314
315            // An open clone grows only while its own anchor keeps matching
316            // (issue #1033). The store may hold a *different* occurrence for
317            // this window — one stored by a third file whose text continues
318            // the same way — and extending on that would stretch the anchored
319            // fragment past what the anchor file contains: the clone is then
320            // dropped by `flush_clone` when the anchor runs out of tokens, or
321            // reported longer than the real common region when it does not.
322            let anchor_continues = open_clone.as_ref().is_some_and(|oc| {
323                let anchor = Occurrence {
324                    source_id: oc.stored_occurrence.source_id,
325                    token_start: oc.stored_occurrence.token_start
326                        + (token_start - oc.current_start),
327                };
328                windows_match(anchor, current, prepared, min_tokens)
329            });
330
331            if anchor_continues {
332                if let Some(oc) = open_clone.as_mut() {
333                    oc.match_len += 1;
334                }
335            } else {
336                // The anchor stopped (or nothing was open): flush, then start a
337                // new clone on whatever the store matched, if anything.
338                flush_clone(
339                    open_clone.take(),
340                    file_idx,
341                    prepared,
342                    min_lines,
343                    filters,
344                    &mut clones,
345                );
346                match stored {
347                    Some(stored) => {
348                        open_clone = Some(OpenClone {
349                            stored_occurrence: stored,
350                            current_start: token_start,
351                            match_len: min_tokens,
352                        });
353                    }
354                    None => {
355                        store.insert(window_hash, current);
356                    }
357                }
358            }
359            if let Some(stored) = stored {
360                remember_repeated_window(
361                    &mut repeated_windows,
362                    window_hash,
363                    stored,
364                    SECONDARY_OCCURRENCE_CAP,
365                );
366                remember_repeated_window(
367                    &mut repeated_windows,
368                    window_hash,
369                    current,
370                    SECONDARY_OCCURRENCE_CAP,
371                );
372                // The store keeps the first occurrence so the enlargement stays
373                // consistent across the contiguous match region.
374            }
375        }
376
377        // Flush any open clone at the end of the file scan.
378        flush_clone(
379            open_clone.take(),
380            file_idx,
381            prepared,
382            min_lines,
383            filters,
384            &mut clones,
385        );
386    }
387
388    add_secondary_clones(
389        repeated_windows,
390        prepared,
391        min_tokens,
392        min_lines,
393        filters,
394        &mut clones,
395    );
396
397    clones
398}
399
400// ---------------------------------------------------------------------------
401// Open clone state machine helpers
402// ---------------------------------------------------------------------------
403
404/// Path-based clone pair filters, applied when a clone is flushed.
405///
406/// Bundles the options that decide whether a clone pair is dropped based on
407/// where its two fragments live on disk.
408#[derive(Debug, Default, Clone, Copy)]
409pub struct PathFilters<'a> {
410    /// Skip clone pairs where both fragments are under the same scan root.
411    /// Mirrors jscpd's `SkipLocalValidator`.
412    pub skip_local: bool,
413    /// Scan roots used by `skip_local` to determine same-directory pairs.
414    pub scan_roots: &'a [PathBuf],
415    /// Isolation groups (`--skip-isolated`): skip clone pairs whose fragments
416    /// are under two *different* folders of the same group. Mirrors the
417    /// `SkipIsolatedValidator` proposed in jscpd PR #628.
418    pub isolated_groups: &'a [Vec<PathBuf>],
419}
420
421impl PathFilters<'_> {
422    /// Returns true if the clone pair (`file_a`, `file_b`) must be dropped.
423    pub fn should_skip(&self, file_a: &str, file_b: &str) -> bool {
424        (self.skip_local && should_skip_local(file_a, file_b, self.scan_roots))
425            || should_skip_isolated(file_a, file_b, self.isolated_groups)
426    }
427
428    /// `should_skip` for two prepared sources. Filters see the path a file
429    /// was found at, like jscpd v4: `--skip-local` drops a pair found under
430    /// the same scan root even when one side is a symlink into it. Isolation
431    /// groups are canonicalized, so a group folder that is itself a symlink
432    /// only matches the canonical path of the files found through it; that
433    /// case is covered by a second check on the real paths.
434    fn should_skip_pair(&self, a: &PreparedSource, b: &PreparedSource) -> bool {
435        self.should_skip(&a.id, &b.id)
436            || ((!a.real_path.is_empty() || !b.real_path.is_empty())
437                && should_skip_isolated(a.filter_path(), b.filter_path(), self.isolated_groups))
438    }
439}
440
441/// Where one file stands for the path filters, computed once so that
442/// deciding a pair costs a few integer comparisons: `a.skips(&b)` agrees
443/// with [`PathFilters::should_skip`] on the two paths.
444#[derive(Debug, Clone, Default, PartialEq, Eq)]
445pub struct PathLabel {
446    /// Indexes of the scan roots holding the file (only with `skip_local`).
447    roots: Vec<u16>,
448    /// `(group, folder)` for every isolation group with a folder holding
449    /// the file, the first such folder as in `should_skip_isolated`.
450    folders: Vec<(u16, u16)>,
451}
452
453impl PathLabel {
454    /// True when a clone between files labelled `self` and `other` is
455    /// dropped by the path filters the labels were made with.
456    pub fn skips(&self, other: &PathLabel) -> bool {
457        self.roots.iter().any(|r| other.roots.contains(r))
458            || self
459                .folders
460                .iter()
461                .any(|(g, f)| other.folders.iter().any(|(g2, f2)| g == g2 && f != f2))
462    }
463}
464
465impl PathFilters<'_> {
466    /// Whether any path filter is on.
467    pub fn is_active(&self) -> bool {
468        self.skip_local || !self.isolated_groups.is_empty()
469    }
470
471    /// The [`PathLabel`] of `file`.
472    pub fn label(&self, file: &str) -> PathLabel {
473        let roots = match self.skip_local {
474            true => self
475                .scan_roots
476                .iter()
477                .enumerate()
478                .filter(|(_, root)| is_relative_to(file, root))
479                .map(|(k, _)| k as u16)
480                .collect(),
481            false => Vec::new(),
482        };
483        let folders = self
484            .isolated_groups
485            .iter()
486            .enumerate()
487            .filter_map(|(g, group)| {
488                let f = group.iter().position(|dir| is_relative_to(file, dir))?;
489                Some((g as u16, f as u16))
490            })
491            .collect();
492        PathLabel { roots, folders }
493    }
494}
495
496/// Returns true if both files share a common scan root directory.
497/// Mirrors jscpd's `SkipLocalValidator.shouldSkipClone`:
498///   `path.some(dir => isRelative(fileA, dir) && isRelative(fileB, dir))`
499fn should_skip_local(file_a: &str, file_b: &str, scan_roots: &[PathBuf]) -> bool {
500    scan_roots
501        .iter()
502        .any(|root| is_relative_to(file_a, root) && is_relative_to(file_b, root))
503}
504
505/// Returns true if the two files fall under two different folders of the same
506/// isolation group. Mirrors `SkipIsolatedValidator.shouldSkipClone` from jscpd
507/// PR #628: for each group, take the first folder containing each file; the
508/// clone is skipped when both files match and their folders differ.
509fn should_skip_isolated(file_a: &str, file_b: &str, isolated_groups: &[Vec<PathBuf>]) -> bool {
510    isolated_groups.iter().any(|group| {
511        let Some(dir_a) = group.iter().find(|dir| is_relative_to(file_a, dir)) else {
512            return false;
513        };
514        group
515            .iter()
516            .find(|dir| is_relative_to(file_b, dir))
517            .is_some_and(|dir_b| dir_a != dir_b)
518    })
519}
520
521/// Returns true if `file_path` is contained within `dir`.
522/// Mirrors the TypeScript `SkipLocalValidator.isRelative`:
523///   `const rel = relative(dir, file); return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);`
524fn is_relative_to(file_path: &str, dir: &PathBuf) -> bool {
525    let file = Path::new(file_path);
526    // Fast path: file path starts with the dir prefix
527    if let Ok(rel) = file.strip_prefix(dir) {
528        return !rel.as_os_str().is_empty();
529    }
530    // Mixed absolute/relative can never match via simple prefix
531    if file.is_absolute() != dir.is_absolute() {
532        return false;
533    }
534    // Walk up from the file path checking if any ancestor starts with dir
535    let mut ancestor = file;
536    loop {
537        if ancestor == dir.as_path() {
538            return false;
539        }
540        if ancestor.starts_with(dir) {
541            let rel = ancestor.strip_prefix(dir).unwrap_or(ancestor);
542            return !rel.as_os_str().is_empty();
543        }
544        ancestor = match ancestor.parent() {
545            Some(p) => p,
546            None => return false,
547        };
548    }
549}
550
551struct OpenClone {
552    stored_occurrence: Occurrence,
553    current_start: usize,
554    match_len: usize,
555}
556
557/// Returns true if the window at `current` actually matches the window at `stored`
558/// (hash match is necessary but not sufficient — verify token equality).
559fn windows_match(
560    stored: Occurrence,
561    current: Occurrence,
562    prepared: &[PreparedSource],
563    min_tokens: usize,
564) -> bool {
565    if stored.source_id == current.source_id && stored.token_start == current.token_start {
566        return false;
567    }
568    let stored_hashes = &prepared[stored.source_id].hashes;
569    let current_hashes = &prepared[current.source_id].hashes;
570    if stored.token_start + min_tokens > stored_hashes.len()
571        || current.token_start + min_tokens > current_hashes.len()
572    {
573        return false;
574    }
575    stored_hashes[stored.token_start..stored.token_start + min_tokens]
576        == current_hashes[current.token_start..current.token_start + min_tokens]
577}
578
579/// Flush an open clone to the clones list.
580///
581/// A clone is rejected if its line span is shorter than `min_lines`.
582/// The line span is measured as `end.line - start.line` (which equals
583/// `number_of_lines - 1`). Mirrors jscpd's `LinesLengthCloneValidator`.
584fn flush_clone(
585    open: Option<OpenClone>,
586    current_file_idx: usize,
587    prepared: &[PreparedSource],
588    min_lines: usize,
589    filters: &PathFilters,
590    clones: &mut Vec<CpdClone>,
591) {
592    let oc = match open {
593        Some(o) => o,
594        None => return,
595    };
596
597    let existing = &oc.stored_occurrence;
598    let cur_start = oc.current_start;
599    let match_len = oc.match_len;
600
601    let existing_file = &prepared[existing.source_id];
602    let current_file = &prepared[current_file_idx];
603
604    let ex_start = existing.token_start;
605    let ex_end = ex_start + match_len - 1;
606    let cur_end = cur_start + match_len - 1;
607
608    // Path filters: drop clone pairs by fragment location (skip_local,
609    // skip_isolated). Mirrors jscpd's SkipLocalValidator / SkipIsolatedValidator.
610    if filters.should_skip_pair(existing_file, current_file) {
611        return;
612    }
613
614    let fragment_a = match make_fragment(&existing_file.id, &existing_file.spans, ex_start, ex_end)
615    {
616        Some(f) => f,
617        None => return,
618    };
619    let fragment_b = match make_fragment(&current_file.id, &current_file.spans, cur_start, cur_end)
620    {
621        Some(f) => f,
622        None => return,
623    };
624    let kind = clone_kind(
625        existing_file,
626        fragment_a.range,
627        current_file,
628        fragment_b.range,
629    );
630
631    // min_lines filter: reject clones whose fragment A line span is shorter than min_lines.
632    // Mirrors jscpd's LinesLengthCloneValidator which checks only duplicationA:
633    //   duplicationA.end.line - duplicationA.start.line >= minLines
634    if min_lines > 0 {
635        let lines = fragment_a.end.line as usize - fragment_a.start.line as usize;
636        if lines < min_lines {
637            return;
638        }
639    }
640
641    let unmatched_lines = [
642        host_lines_in(existing_file, &fragment_a),
643        host_lines_in(current_file, &fragment_b),
644    ];
645
646    clones.push(CpdClone {
647        format: current_file.format.clone(),
648        fragment_a,
649        fragment_b,
650        token_count: match_len as u32,
651        is_new: false,
652        kind,
653        similarity: None,
654        similarity_method: None,
655        unmatched_lines,
656    });
657}
658
659/// Lines of `fragment` that hold no token of `source`.
660///
661/// Zero for an ordinary file, where the fragment is a contiguous run of code.
662/// For an embedded block it is the host language's text sitting between two
663/// blocks the fragment happens to span — markdown prose, the template half of
664/// a single-file component. That text is not code of this format and must not
665/// count as duplicated (issue #1090).
666fn host_lines_in(source: &PreparedSource, fragment: &Fragment) -> u32 {
667    if !source.embedded {
668        return 0;
669    }
670    let (first, last) = (fragment.range[0] as usize, fragment.range[1] as usize);
671    let Some(spans) = source.spans.get(first..=last) else {
672        return 0;
673    };
674    let span = fragment.end.line.saturating_sub(fragment.start.line) + 1;
675    let covered = covered_lines(spans.iter().map(|(s, e)| (s.line, e.line)));
676    span.saturating_sub(covered)
677}
678
679/// Classify a clone pair as exact or renamed (issue #998).
680///
681/// Sources scanned without a normalization option carry no raw hashes and
682/// always yield `Exact`. Otherwise the raw (un-normalized) hashes of both
683/// fragments are compared over the run of tokens whose normalized hashes
684/// match — the fragment ranges may overshoot the matched window by one
685/// token on the secondary path, so the comparison is bounded by the
686/// normalized match rather than by the stored range.
687fn clone_kind(
688    a: &PreparedSource,
689    a_range: [u32; 2],
690    b: &PreparedSource,
691    b_range: [u32; 2],
692) -> CloneKind {
693    if a.raw_hashes.is_empty() && b.raw_hashes.is_empty() {
694        return CloneKind::Exact;
695    }
696    let (na, ra) = range_slices(a, a_range);
697    let (nb, rb) = range_slices(b, b_range);
698    let matched = na.iter().zip(nb).take_while(|(x, y)| x == y).count();
699    if ra[..matched.min(ra.len())] == rb[..matched.min(rb.len())] {
700        CloneKind::Exact
701    } else {
702        CloneKind::Renamed
703    }
704}
705
706/// `(normalized, raw)` hash slices for an inclusive token index range.
707fn range_slices(p: &PreparedSource, range: [u32; 2]) -> (&[u64], &[u64]) {
708    let start = (range[0] as usize).min(p.hashes.len());
709    let end = (range[1] as usize + 1).min(p.hashes.len());
710    let raw = if p.raw_hashes.is_empty() {
711        &p.hashes
712    } else {
713        &p.raw_hashes
714    };
715    (&p.hashes[start..end], &raw[start..end])
716}
717
718fn make_fragment(
719    source_id: &str,
720    spans: &[(Location, Location)],
721    start_idx: usize,
722    end_idx: usize,
723) -> Option<Fragment> {
724    let (first_start, _) = spans.get(start_idx)?;
725    let (_, last_end) = spans.get(end_idx)?;
726    Some(Fragment {
727        source_id: source_id.to_string(),
728        source_root: None,
729        start: first_start.clone(),
730        end: last_end.clone(),
731        range: [start_idx as u32, end_idx as u32],
732        blame: None,
733    })
734}
735
736// ---------------------------------------------------------------------------
737// Deduplication — O(n) FxHashSet + sub-clone suppression
738// ---------------------------------------------------------------------------
739
740/// Lowest similarity a gap merge may produce. Below it the merged span
741/// would hold more unmatched than matched tokens (one very long inserted
742/// line, say), which is not a near-miss copy: the halves stay separate.
743pub const MIN_GAP_SIMILARITY: f32 = 0.5;
744
745/// Gap-tolerant merging (issue #999, stage 1).
746///
747/// Two clones of the same file pair whose fragments follow each other in
748/// *both* files with at most `max_gap_lines` unmatched lines in between are
749/// merged into one `similar` clone spanning both. `token_count` becomes the
750/// number of matched tokens and `similarity` the matched tokens divided by
751/// the tokens of the longer merged span; a merge whose similarity would fall
752/// below [`MIN_GAP_SIMILARITY`] is refused. Chains merge transitively. The
753/// merged clone is `similar` even when its halves were `renamed`, and its
754/// `unmatched_lines` hold the gap lines of each fragment so statistics can
755/// leave them out. With `max_gap_lines == 0` the input is returned as-is, so
756/// default runs never enter this pass.
757pub fn merge_gapped_clones(mut clones: Vec<CpdClone>, max_gap_lines: usize) -> Vec<CpdClone> {
758    if max_gap_lines == 0 || clones.len() < 2 {
759        return clones;
760    }
761    clones.sort_by(|x, y| {
762        pair_key(x)
763            .cmp(&pair_key(y))
764            .then(x.fragment_a.range[0].cmp(&y.fragment_a.range[0]))
765            .then(x.fragment_b.range[0].cmp(&y.fragment_b.range[0]))
766    });
767    let mut merged: Vec<CpdClone> = Vec::with_capacity(clones.len());
768    for clone in clones {
769        let extended = match merged.last_mut() {
770            Some(last) if pair_key(last) == pair_key(&clone) => {
771                merge_into(last, &clone, max_gap_lines)
772            }
773            _ => false,
774        };
775        if !extended {
776            merged.push(clone);
777        }
778    }
779    merged
780}
781
782/// Extend `last` with `next` when both fragments continue within the gap
783/// limit and the result clears [`MIN_GAP_SIMILARITY`]. `last.token_count`
784/// is the matched-token total of its chain, which is what the exact pass
785/// stores for an unmerged clone as well.
786fn merge_into(last: &mut CpdClone, next: &CpdClone, max_gap_lines: usize) -> bool {
787    let Some(step_a) = continuation(&last.fragment_a, &next.fragment_a, max_gap_lines) else {
788        return false;
789    };
790    let Some(step_b) = continuation(&last.fragment_b, &next.fragment_b, max_gap_lines) else {
791        return false;
792    };
793    // Adjacent exact windows may share their boundary tokens; that overlap is
794    // matched once.
795    let matched = last.token_count
796        + next
797            .token_count
798            .saturating_sub(step_a.overlap.max(step_b.overlap));
799    let span_a = next.fragment_a.range[1] - last.fragment_a.range[0] + 1;
800    let span_b = next.fragment_b.range[1] - last.fragment_b.range[0] + 1;
801    let similarity = matched as f32 / span_a.max(span_b) as f32;
802    if similarity < MIN_GAP_SIMILARITY {
803        return false;
804    }
805    last.fragment_a.end = next.fragment_a.end.clone();
806    last.fragment_a.range[1] = next.fragment_a.range[1];
807    last.fragment_b.end = next.fragment_b.end.clone();
808    last.fragment_b.range[1] = next.fragment_b.range[1];
809    last.token_count = matched;
810    last.similarity = Some(similarity);
811    last.similarity_method = Some(SimilarityMethod::Gap);
812    last.kind = CloneKind::Similar;
813    // The merged span covers both halves and the gap between them, so it
814    // inherits what neither half matched as well as the gap itself.
815    last.unmatched_lines[0] += next.unmatched_lines[0] + step_a.gap_lines;
816    last.unmatched_lines[1] += next.unmatched_lines[1] + step_b.gap_lines;
817    true
818}
819
820fn pair_key(c: &CpdClone) -> (&str, &str, &str) {
821    (
822        c.format.as_str(),
823        c.fragment_a.source_id.as_str(),
824        c.fragment_b.source_id.as_str(),
825    )
826}
827
828/// How one fragment continues another: the tokens the two windows share at
829/// the boundary and the whole lines between them that neither covers.
830#[derive(Debug, Clone, Copy, PartialEq, Eq)]
831struct Continuation {
832    overlap: u32,
833    gap_lines: u32,
834}
835
836/// `next` extends `prev` when it starts after `prev` starts, ends after
837/// `prev` ends, and at most `max_gap_lines` lines lie between them.
838fn continuation(prev: &Fragment, next: &Fragment, max_gap_lines: usize) -> Option<Continuation> {
839    if next.range[0] <= prev.range[0] || next.range[1] <= prev.range[1] {
840        return None;
841    }
842    let gap_lines = next.start.line.saturating_sub(prev.end.line + 1);
843    if gap_lines as usize > max_gap_lines {
844        return None;
845    }
846    Some(Continuation {
847        overlap: (prev.range[1] + 1).saturating_sub(next.range[0]),
848        gap_lines,
849    })
850}
851
852fn dedup_exact_clones(clones: &mut Vec<CpdClone>) {
853    // Normalize each clone so fragment_a <= fragment_b (by id then start line).
854    for clone in clones.iter_mut() {
855        let a_key = (&clone.fragment_a.source_id, clone.fragment_a.start.line);
856        let b_key = (&clone.fragment_b.source_id, clone.fragment_b.start.line);
857        if a_key > b_key {
858            std::mem::swap(&mut clone.fragment_a, &mut clone.fragment_b);
859        }
860    }
861
862    let mut seen: FxHashSet<CloneDedupKey> = FxHashSet::default();
863    clones.retain(|c| seen.insert(CloneDedupKey::from_clone(c)));
864}
865
866// ---------------------------------------------------------------------------
867// Secondary clone pass
868// ---------------------------------------------------------------------------
869
870fn remember_repeated_window(
871    repeated_windows: &mut FxHashMap<u64, Vec<Occurrence>>,
872    hash: u64,
873    occurrence: Occurrence,
874    cap: usize,
875) {
876    let bucket = repeated_windows.entry(hash).or_default();
877    if bucket
878        .iter()
879        .any(|s| s.source_id == occurrence.source_id && s.token_start == occurrence.token_start)
880    {
881        return;
882    }
883    if bucket.len() < cap {
884        bucket.push(occurrence);
885    }
886}
887
888struct SecondaryOpen {
889    clone: CpdClone,
890    source_a: usize,
891    source_b: usize,
892    last_token_start_a: usize,
893    last_token_start_b: usize,
894}
895
896#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
897struct Candidate {
898    source_a: usize,
899    source_b: usize,
900    token_a: usize,
901    token_b: usize,
902}
903
904impl SecondaryOpen {
905    /// True when `candidate` extends this open clone by exactly one token on
906    /// both sides.
907    fn is_continuation(&self, candidate: &Candidate) -> bool {
908        self.source_a == candidate.source_a
909            && self.source_b == candidate.source_b
910            && self.last_token_start_a + 1 == candidate.token_a
911            && self.last_token_start_b + 1 == candidate.token_b
912    }
913
914    /// Grow the open clone by one token on each side, using `prepared` to
915    /// resolve the new endpoint spans.
916    fn grow(&mut self, candidate: &Candidate, prepared: &[PreparedSource], min_tokens: usize) {
917        self.clone.token_count += 1;
918        let end_a = candidate.token_a + min_tokens;
919        let end_b = candidate.token_b + min_tokens;
920        if let Some(span) = prepared[self.source_a].spans.get(end_a) {
921            self.clone.fragment_a.end = span.1.clone();
922            self.clone.fragment_a.range[1] = end_a as u32;
923        }
924        if let Some(span) = prepared[self.source_b].spans.get(end_b) {
925            self.clone.fragment_b.end = span.1.clone();
926            self.clone.fragment_b.range[1] = end_b as u32;
927        }
928        self.last_token_start_a = candidate.token_a;
929        self.last_token_start_b = candidate.token_b;
930    }
931}
932
933fn add_secondary_clones(
934    repeated_windows: FxHashMap<u64, Vec<Occurrence>>,
935    prepared: &[PreparedSource],
936    min_tokens: usize,
937    min_lines: usize,
938    filters: &PathFilters,
939    clones: &mut Vec<CpdClone>,
940) {
941    if repeated_windows.is_empty() {
942        return;
943    }
944
945    let mut candidates: Vec<Candidate> = Vec::new();
946    for occurrences in repeated_windows.values() {
947        if occurrences.len() < 2 {
948            continue;
949        }
950        for li in 0..occurrences.len() {
951            for ri in li + 1..occurrences.len() {
952                let left = &occurrences[li];
953                let right = &occurrences[ri];
954                if left.source_id == right.source_id && left.token_start == right.token_start {
955                    continue;
956                }
957                let lh = &prepared[left.source_id].hashes;
958                let rh = &prepared[right.source_id].hashes;
959                let la = left.token_start;
960                let ra = right.token_start;
961                if la + min_tokens > lh.len() || ra + min_tokens > rh.len() {
962                    continue;
963                }
964                if lh[la..la + min_tokens] != rh[ra..ra + min_tokens] {
965                    continue;
966                }
967                let (sa, ta, sb, tb) =
968                    if (left.source_id, left.token_start) <= (right.source_id, right.token_start) {
969                        (
970                            left.source_id,
971                            left.token_start,
972                            right.source_id,
973                            right.token_start,
974                        )
975                    } else {
976                        (
977                            right.source_id,
978                            right.token_start,
979                            left.source_id,
980                            left.token_start,
981                        )
982                    };
983                candidates.push(Candidate {
984                    source_a: sa,
985                    source_b: sb,
986                    token_a: ta,
987                    token_b: tb,
988                });
989            }
990        }
991    }
992    if candidates.is_empty() {
993        return;
994    }
995    candidates.sort_unstable();
996    candidates.dedup();
997
998    // Build line-coverage from already-found primary clones.
999    let mut coverage = LineCoverage::from_clones(prepared, clones);
1000    let mut open: Option<SecondaryOpen> = None;
1001
1002    for candidate in candidates {
1003        if let Some(current) = open.as_mut()
1004            && current.is_continuation(&candidate)
1005        {
1006            current.grow(&candidate, prepared, min_tokens);
1007            continue;
1008        }
1009
1010        flush_secondary_clone(
1011            open.take(),
1012            prepared,
1013            min_lines,
1014            filters,
1015            clones,
1016            &mut coverage,
1017        );
1018
1019        // Create a new secondary clone candidate.
1020        let start_a = candidate.token_a;
1021        let end_a = start_a + min_tokens - 1;
1022        let start_b = candidate.token_b;
1023        let end_b = start_b + min_tokens - 1;
1024
1025        let frag_a = match make_fragment(
1026            &prepared[candidate.source_a].id,
1027            &prepared[candidate.source_a].spans,
1028            start_a,
1029            end_a,
1030        ) {
1031            Some(f) => f,
1032            None => continue,
1033        };
1034        let frag_b = match make_fragment(
1035            &prepared[candidate.source_b].id,
1036            &prepared[candidate.source_b].spans,
1037            start_b,
1038            end_b,
1039        ) {
1040            Some(f) => f,
1041            None => continue,
1042        };
1043
1044        open = Some(SecondaryOpen {
1045            clone: CpdClone {
1046                format: prepared[candidate.source_a].format.clone(),
1047                fragment_a: frag_a,
1048                fragment_b: frag_b,
1049                token_count: min_tokens as u32,
1050                is_new: false,
1051                kind: Default::default(),
1052                similarity: None,
1053                similarity_method: None,
1054                unmatched_lines: [0, 0],
1055            },
1056            source_a: candidate.source_a,
1057            source_b: candidate.source_b,
1058            last_token_start_a: candidate.token_a,
1059            last_token_start_b: candidate.token_b,
1060        });
1061    }
1062
1063    flush_secondary_clone(
1064        open.take(),
1065        prepared,
1066        min_lines,
1067        filters,
1068        clones,
1069        &mut coverage,
1070    );
1071}
1072
1073fn flush_secondary_clone(
1074    open: Option<SecondaryOpen>,
1075    prepared: &[PreparedSource],
1076    min_lines: usize,
1077    filters: &PathFilters,
1078    clones: &mut Vec<CpdClone>,
1079    coverage: &mut LineCoverage,
1080) {
1081    let Some(oc) = open else {
1082        return;
1083    };
1084
1085    let range_a = fragment_line_range(&oc.clone.fragment_a);
1086    let range_b = fragment_line_range(&oc.clone.fragment_b);
1087
1088    // Path filters: drop clone pairs by fragment location (skip_local, skip_isolated).
1089    if filters.should_skip_pair(&prepared[oc.source_a], &prepared[oc.source_b]) {
1090        return;
1091    }
1092
1093    // min_lines filter: only check fragment A, mirroring jscpd's LinesLengthCloneValidator.
1094    if min_lines > 0 {
1095        let lines = oc.clone.fragment_a.end.line as usize - oc.clone.fragment_a.start.line as usize;
1096        if lines < min_lines {
1097            return;
1098        }
1099    }
1100
1101    // Line-coverage filter: skip secondary clones that don't extend existing coverage
1102    // on either side.  This prevents the report from filling up with dozens of
1103    // overlapping sub-clones of the same region.
1104    if !coverage.extends(oc.source_a, range_a) || !coverage.extends(oc.source_b, range_b) {
1105        return;
1106    }
1107
1108    let before = clones.len();
1109    let mut clone = oc.clone;
1110    clone.kind = clone_kind(
1111        &prepared[oc.source_a],
1112        clone.fragment_a.range,
1113        &prepared[oc.source_b],
1114        clone.fragment_b.range,
1115    );
1116    // The fragments grew while the clone stayed open, so the host lines they
1117    // now span are only known here.
1118    clone.unmatched_lines = [
1119        host_lines_in(&prepared[oc.source_a], &clone.fragment_a),
1120        host_lines_in(&prepared[oc.source_b], &clone.fragment_b),
1121    ];
1122    clones.push(clone);
1123
1124    // Insert coverage for newly added clone.
1125    if clones.len() > before {
1126        coverage.insert(oc.source_a, range_a);
1127        coverage.insert(oc.source_b, range_b);
1128    }
1129}
1130
1131fn fragment_line_range(fragment: &Fragment) -> (usize, usize) {
1132    let start = fragment.start.line as usize;
1133    let end = fragment.end.line as usize;
1134    (start.min(end), start.max(end))
1135}
1136
1137// ---------------------------------------------------------------------------
1138// Line coverage tracking for secondary clones
1139// ---------------------------------------------------------------------------
1140
1141struct LineCoverage {
1142    ranges_by_source: Vec<Vec<(usize, usize)>>,
1143}
1144
1145impl LineCoverage {
1146    fn from_clones(prepared: &[PreparedSource], clones: &[CpdClone]) -> Self {
1147        let mut source_lookup: FxHashMap<&str, usize> = FxHashMap::default();
1148        for (idx, source) in prepared.iter().enumerate() {
1149            source_lookup.insert(source.id.as_str(), idx);
1150        }
1151        let mut coverage = Self {
1152            ranges_by_source: vec![Vec::new(); prepared.len()],
1153        };
1154        for clone in clones {
1155            if let Some(idx) = source_lookup.get(clone.fragment_a.source_id.as_str()) {
1156                coverage.insert(*idx, fragment_line_range(&clone.fragment_a));
1157            }
1158            if let Some(idx) = source_lookup.get(clone.fragment_b.source_id.as_str()) {
1159                coverage.insert(*idx, fragment_line_range(&clone.fragment_b));
1160            }
1161        }
1162        coverage
1163    }
1164
1165    fn extends(&self, source_idx: usize, range: (usize, usize)) -> bool {
1166        // ponytail: walk existing intervals in order, advancing the low watermark
1167        // past anything already covered. We extend unless the candidate is
1168        // already fully covered by an existing interval chain.
1169        let Some(intervals) = self.ranges_by_source.get(source_idx) else {
1170            return true;
1171        };
1172        let mut cursor = range.0;
1173        for &(start, end) in intervals {
1174            if end < cursor {
1175                continue;
1176            }
1177            if start > cursor {
1178                return true;
1179            }
1180            cursor = cursor.max(end.saturating_add(1));
1181            if cursor > range.1 {
1182                return false;
1183            }
1184        }
1185        cursor <= range.1
1186    }
1187
1188    fn insert(&mut self, source_idx: usize, range: (usize, usize)) {
1189        // ponytail: merge-into-sorted approach. Keep the per-source vector
1190        // sorted and merged so `extends` can scan it in one pass; we rebuild
1191        // it by folding the new range into the existing merged intervals
1192        // rather than re-sorting the whole list every insert.
1193        let Some(intervals) = self.ranges_by_source.get_mut(source_idx) else {
1194            return;
1195        };
1196        let mut folded = Vec::with_capacity(intervals.len() + 1);
1197        let mut pending = Some(range);
1198        for &(start, end) in intervals.iter() {
1199            let p = match pending.take() {
1200                None => {
1201                    folded.push((start, end));
1202                    continue;
1203                }
1204                Some(p) => p,
1205            };
1206            // p is fully before this interval — emit p, then this interval.
1207            if p.1.saturating_add(1) < start {
1208                folded.push(p);
1209                folded.push((start, end));
1210            }
1211            // this interval is fully before p — emit it, keep p pending.
1212            else if end.saturating_add(1) < p.0 {
1213                folded.push((start, end));
1214                pending = Some(p);
1215            }
1216            // overlapping or adjacent — merge into p, keep pending.
1217            else {
1218                pending = Some((p.0.min(start), p.1.max(end)));
1219            }
1220        }
1221        if let Some(p) = pending {
1222            folded.push(p);
1223        }
1224        *intervals = folded;
1225    }
1226}
1227
1228// ---------------------------------------------------------------------------
1229// Tests
1230// ---------------------------------------------------------------------------
1231
1232#[cfg(test)]
1233mod tests {
1234    use super::*;
1235
1236    fn tok(hash: u64, raw_hash: u64, line: u32) -> DetectionToken {
1237        let loc = Location {
1238            line,
1239            column: 0,
1240            offset: line,
1241        };
1242        DetectionToken {
1243            hash,
1244            raw_hash,
1245            start: loc.clone(),
1246            end: loc,
1247            range: [line as usize, line as usize + 1],
1248        }
1249    }
1250
1251    /// A clone of `format` between `a` and `b` covering the given token index
1252    /// ranges (inclusive) and line ranges.
1253    fn gap_clone(
1254        a: &str,
1255        a_tok: [u32; 2],
1256        a_lines: [u32; 2],
1257        b: &str,
1258        b_tok: [u32; 2],
1259        b_lines: [u32; 2],
1260    ) -> CpdClone {
1261        let frag = |id: &str, tok: [u32; 2], lines: [u32; 2]| Fragment {
1262            source_id: id.to_string(),
1263            source_root: None,
1264            start: Location {
1265                line: lines[0],
1266                column: 1,
1267                offset: tok[0],
1268            },
1269            end: Location {
1270                line: lines[1],
1271                column: 1,
1272                offset: tok[1],
1273            },
1274            range: tok,
1275            blame: None,
1276        };
1277        CpdClone {
1278            format: "javascript".to_string(),
1279            fragment_a: frag(a, a_tok, a_lines),
1280            fragment_b: frag(b, b_tok, b_lines),
1281            token_count: a_tok[1] - a_tok[0] + 1,
1282            is_new: false,
1283            kind: CloneKind::Exact,
1284            similarity: None,
1285            similarity_method: None,
1286            unmatched_lines: [0, 0],
1287        }
1288    }
1289
1290    #[test]
1291    fn merge_gapped_is_a_no_op_at_zero() {
1292        let clones = vec![
1293            gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1294            gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1295        ];
1296        let out = merge_gapped_clones(clones.clone(), 0);
1297        assert_eq!(out, clones);
1298    }
1299
1300    #[test]
1301    fn merge_gapped_joins_adjacent_fragments_within_gap() {
1302        // a: lines 1-4 then 5-8 (no gap); b: lines 1-4 then 6-9 (one inserted line)
1303        let clones = vec![
1304            gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1305            gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1306        ];
1307        let out = merge_gapped_clones(clones, 1);
1308        assert_eq!(out.len(), 1);
1309        let c = &out[0];
1310        assert_eq!(c.kind, CloneKind::Similar);
1311        assert_eq!(c.token_count, 20, "matched tokens");
1312        assert_eq!(c.fragment_a.range, [0, 19]);
1313        assert_eq!(c.fragment_b.range, [0, 21]);
1314        assert_eq!(c.fragment_a.end.line, 8);
1315        assert_eq!(c.fragment_b.end.line, 9);
1316        // 20 matched over the longer span of 22 tokens
1317        assert!((c.similarity.unwrap() - 20.0 / 22.0).abs() < 1e-6);
1318    }
1319
1320    #[test]
1321    fn merge_gapped_respects_the_line_limit_and_file_pair() {
1322        let far = vec![
1323            gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1324            gap_clone("a", [10, 19], [5, 8], "b", [20, 29], [8, 11]), // 3-line gap in b
1325        ];
1326        assert_eq!(merge_gapped_clones(far.clone(), 2).len(), 2);
1327        assert_eq!(merge_gapped_clones(far, 3).len(), 1);
1328
1329        let other_pair = vec![
1330            gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1331            gap_clone("a", [10, 19], [5, 8], "c", [10, 19], [5, 8]),
1332        ];
1333        assert_eq!(merge_gapped_clones(other_pair, 5).len(), 2);
1334    }
1335
1336    #[test]
1337    fn merge_gapped_counts_a_shared_boundary_token_once_and_chains() {
1338        // second window starts on the last token of the first in `a`
1339        let clones = vec![
1340            gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1341            gap_clone("a", [9, 18], [4, 8], "b", [11, 20], [6, 9]),
1342            gap_clone("a", [19, 28], [9, 12], "b", [22, 31], [10, 13]),
1343        ];
1344        let out = merge_gapped_clones(clones, 1);
1345        assert_eq!(out.len(), 1);
1346        assert_eq!(out[0].token_count, 29, "10 + (10 - 1 overlap) + 10");
1347        assert_eq!(out[0].fragment_a.range, [0, 28]);
1348        assert_eq!(out[0].fragment_b.range, [0, 31]);
1349    }
1350
1351    #[test]
1352    fn merge_gapped_never_merges_overlapping_or_reordered_fragments() {
1353        let nested = vec![
1354            gap_clone("a", [0, 19], [1, 8], "b", [0, 19], [1, 8]),
1355            gap_clone("a", [5, 9], [3, 4], "b", [5, 9], [3, 4]),
1356        ];
1357        assert_eq!(merge_gapped_clones(nested, 5).len(), 2);
1358        let crossed = vec![
1359            gap_clone("a", [0, 9], [1, 4], "b", [20, 29], [10, 13]),
1360            gap_clone("a", [10, 19], [5, 8], "b", [0, 9], [1, 4]),
1361        ];
1362        assert_eq!(merge_gapped_clones(crossed, 5).len(), 2);
1363    }
1364
1365    #[test]
1366    fn merge_gapped_refuses_a_gap_wider_than_the_match() {
1367        // b holds 30 unmatched tokens on one inserted line between two
1368        // 10-token halves: 20 matched over a 50-token span is 0.4.
1369        let wide = vec![
1370            gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1371            gap_clone("a", [10, 19], [5, 8], "b", [40, 49], [6, 9]),
1372        ];
1373        let out = merge_gapped_clones(wide, 1);
1374        assert_eq!(out.len(), 2);
1375        assert!(out.iter().all(|c| c.kind == CloneKind::Exact));
1376        assert!(out.iter().all(|c| c.similarity.is_none()));
1377        // 20 matched over a 40-token span is exactly the floor and merges.
1378        let at_floor = vec![
1379            gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1380            gap_clone("a", [10, 19], [5, 8], "b", [30, 39], [6, 9]),
1381        ];
1382        let out = merge_gapped_clones(at_floor, 1);
1383        assert_eq!(out.len(), 1);
1384        assert!((out[0].similarity.unwrap() - MIN_GAP_SIMILARITY).abs() < 1e-6);
1385    }
1386
1387    #[test]
1388    fn merge_gapped_records_unmatched_lines_per_fragment() {
1389        // a continues without a gap; b leaves lines 5 and 6 unmatched.
1390        let clones = vec![
1391            gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1392            gap_clone("a", [10, 19], [5, 8], "b", [14, 23], [7, 10]),
1393        ];
1394        let out = merge_gapped_clones(clones, 2);
1395        assert_eq!(out.len(), 1);
1396        assert_eq!(out[0].unmatched_lines, [0, 2]);
1397        assert_eq!(out[0].fragment_b.end.line, 10);
1398    }
1399
1400    #[test]
1401    fn merge_gapped_reports_renamed_halves_as_similar() {
1402        let mut clones = vec![
1403            gap_clone("a", [0, 9], [1, 4], "b", [0, 9], [1, 4]),
1404            gap_clone("a", [10, 19], [5, 8], "b", [12, 21], [6, 9]),
1405        ];
1406        for c in &mut clones {
1407            c.kind = CloneKind::Renamed;
1408        }
1409        let out = merge_gapped_clones(clones, 1);
1410        assert_eq!(out.len(), 1);
1411        assert_eq!(
1412            out[0].kind,
1413            CloneKind::Similar,
1414            "similar takes precedence over renamed"
1415        );
1416    }
1417
1418    #[test]
1419    fn prepared_source_skips_raw_hashes_when_nothing_was_normalized() {
1420        let tokens = vec![tok(1, 1, 1), tok(2, 2, 2)];
1421        let p = PreparedSource::from_detection_tokens("a".into(), "js".into(), &tokens);
1422        assert!(p.raw_hashes.is_empty());
1423    }
1424
1425    #[test]
1426    fn prepared_source_backfills_raw_hashes_from_first_normalized_token() {
1427        let tokens = vec![tok(1, 1, 1), tok(2, 2, 2), tok(3, 30, 3), tok(4, 4, 4)];
1428        let p = PreparedSource::from_detection_tokens("a".into(), "js".into(), &tokens);
1429        assert_eq!(p.raw_hashes, vec![1, 2, 30, 4]);
1430    }
1431
1432    #[test]
1433    fn clone_kind_is_exact_without_raw_hashes_and_renamed_when_raw_differs() {
1434        let a = PreparedSource::from_detection_tokens(
1435            "a".into(),
1436            "js".into(),
1437            &[tok(1, 1, 1), tok(2, 2, 2), tok(3, 3, 3)],
1438        );
1439        let b = PreparedSource::from_detection_tokens(
1440            "b".into(),
1441            "js".into(),
1442            &[tok(1, 1, 1), tok(2, 20, 2), tok(3, 3, 3)],
1443        );
1444        assert_eq!(clone_kind(&a, [0, 2], &a, [0, 2]), CloneKind::Exact);
1445        assert_eq!(clone_kind(&a, [0, 2], &b, [0, 2]), CloneKind::Renamed);
1446        // the differing token lies outside the compared range
1447        assert_eq!(clone_kind(&a, [2, 2], &b, [2, 2]), CloneKind::Exact);
1448        // an overshooting range is bounded by the normalized match
1449        let c = PreparedSource::from_detection_tokens(
1450            "c".into(),
1451            "js".into(),
1452            &[tok(1, 1, 1), tok(2, 2, 2), tok(9, 90, 3)],
1453        );
1454        assert_eq!(clone_kind(&a, [0, 2], &c, [0, 2]), CloneKind::Exact);
1455    }
1456    use crate::models::{Location, Token, TokenKind};
1457
1458    fn loc(line: u32, col: u32, offset: u32) -> Location {
1459        Location {
1460            line,
1461            column: col,
1462            offset,
1463        }
1464    }
1465
1466    fn make_token(kind: TokenKind, value: &str, line: u32, col: u32, offset: u32) -> Token {
1467        let end_col = col + value.len() as u32;
1468        let end_off = offset + value.len() as u32;
1469        Token {
1470            kind,
1471            value: value.to_string(),
1472            start: loc(line, col, offset),
1473            end: loc(line, end_col, end_off),
1474        }
1475    }
1476
1477    fn make_file(id: &str, format: &str, tokens: Vec<Token>) -> SourceFile {
1478        SourceFile {
1479            bytes: 0,
1480            id: id.to_string(),
1481            format: format.to_string(),
1482            tokens,
1483        }
1484    }
1485
1486    /// A prepared source built straight from hashes and spans, with every
1487    /// other field at rest — what the detection tests need.
1488    fn make_prepared(
1489        id: &str,
1490        format: &str,
1491        hashes: Vec<u64>,
1492        spans: Vec<(Location, Location)>,
1493    ) -> PreparedSource {
1494        PreparedSource {
1495            id: id.to_string(),
1496            format: format.to_string(),
1497            hashes,
1498            spans,
1499            raw_hashes: Vec::new(),
1500            functions: Vec::new(),
1501            real_path: String::new(),
1502            embedded: false,
1503        }
1504    }
1505
1506    fn js_tokens_ab() -> Vec<Token> {
1507        vec![
1508            make_token(TokenKind::Keyword, "function", 1, 0, 0),
1509            make_token(TokenKind::Other, "hello", 1, 9, 9),
1510            make_token(TokenKind::Operator, "(", 1, 14, 14),
1511            make_token(TokenKind::Operator, ")", 1, 15, 15),
1512            make_token(TokenKind::Operator, "{", 1, 16, 16),
1513            make_token(TokenKind::Keyword, "return", 2, 0, 18),
1514            make_token(TokenKind::Literal, "42", 2, 7, 25),
1515            make_token(TokenKind::Operator, ";", 2, 9, 27),
1516            make_token(TokenKind::Operator, "}", 3, 0, 29),
1517        ]
1518    }
1519
1520    #[test]
1521    fn empty_input_returns_empty() {
1522        let result = detect(&[], 10);
1523        assert!(result.is_empty());
1524    }
1525
1526    fn pair_with_js_tokens(min_tokens: usize) -> Vec<CpdClone> {
1527        let tokens = js_tokens_ab();
1528        let file_a = make_file("a.js", "javascript", tokens.clone());
1529        let file_b = make_file("b.js", "javascript", tokens);
1530        detect(&[file_a, file_b], min_tokens)
1531    }
1532
1533    #[test]
1534    fn identical_files_detected_as_clone() {
1535        assert!(
1536            !pair_with_js_tokens(5).is_empty(),
1537            "identical files must produce at least one clone"
1538        );
1539    }
1540
1541    #[test]
1542    fn min_tokens_threshold_respected() {
1543        assert!(
1544            pair_with_js_tokens(100).is_empty(),
1545            "no clones when min_tokens exceeds file length"
1546        );
1547    }
1548
1549    #[test]
1550    fn deduplication_ab_ba_collapse() {
1551        assert_eq!(
1552            pair_with_js_tokens(5).len(),
1553            1,
1554            "symmetric pairs must collapse to 1"
1555        );
1556    }
1557
1558    #[test]
1559    fn different_formats_not_cross_detected() {
1560        let tokens = js_tokens_ab();
1561        let file_js = make_file("a.js", "javascript", tokens.clone());
1562        let file_py = make_file("a.py", "python", tokens);
1563        let clones = detect(&[file_js, file_py], 5);
1564        assert!(
1565            clones.is_empty(),
1566            "tokens from different formats must not match"
1567        );
1568    }
1569
1570    #[test]
1571    fn cross_format_group_detected() {
1572        // Inverse of different_formats_not_cross_detected: when two formats
1573        // are pooled into ONE prepared group (--cross-formats), identical
1574        // token streams match across formats.
1575        let to_prepared = |id: &str, format: &str| {
1576            let tokens = js_tokens_ab();
1577            let mut hashes = Vec::new();
1578            let mut spans = Vec::new();
1579            for t in &tokens {
1580                hashes.push(token_hash(t.kind.discriminant(), &t.value));
1581                spans.push((t.start.clone(), t.end.clone()));
1582            }
1583            make_prepared(id, format, hashes, spans)
1584        };
1585        let group = vec![
1586            to_prepared("a.js", "javascript"),
1587            to_prepared("a.ts", "typescript"),
1588        ];
1589        let clones = detect_prepared(vec![group], 5, 0, &PathFilters::default());
1590        assert_eq!(
1591            clones.len(),
1592            1,
1593            "identical token streams in one pool must match across formats"
1594        );
1595    }
1596
1597    /// Issue #1033: three files, scanned in this order.
1598    ///   a: X
1599    ///   b: X' T X'' where X' and X'' are X with a different first token
1600    ///   c: X T Z
1601    /// While scanning b, the windows spanning "tail of X + T" match nothing
1602    /// and are stored. While scanning c, the clone anchored on a covers X;
1603    /// the next window (tail of X + T) matches b's stored occurrence, and a
1604    /// blind extension would stretch the fragment past a's last token and
1605    /// drop the clone. The anchor check keeps a↔c at exactly |X| tokens.
1606    #[test]
1607    fn open_clone_extends_only_while_its_anchor_continues() {
1608        let min_tokens = 5;
1609        let x: Vec<u64> = (100..112).collect(); // 12 tokens
1610        let t: Vec<u64> = vec![900, 901, 902, 903, 904, 905];
1611        let z: Vec<u64> = vec![700, 701, 702, 703, 704, 705, 706];
1612        let renamed = |first: u64| {
1613            let mut v = x.clone();
1614            v[0] = first;
1615            v
1616        };
1617        let stream = |parts: &[&[u64]]| -> Vec<u64> { parts.concat() };
1618        let a = stream(&[&x]);
1619        let b = stream(&[&renamed(1), &t, &renamed(2)]);
1620        let c = stream(&[&x, &t, &z]);
1621        let streams: Vec<(&str, Vec<u64>)> =
1622            vec![("a", a.clone()), ("b", b.clone()), ("c", c.clone())];
1623        let to_prepared = |id: &str, hashes: Vec<u64>| {
1624            let spans = (0..hashes.len())
1625                .map(|i| {
1626                    let loc = Location {
1627                        line: i as u32 + 1,
1628                        column: 1,
1629                        offset: i as u32,
1630                    };
1631                    (loc.clone(), loc)
1632                })
1633                .collect();
1634            make_prepared(id, "javascript", hashes, spans)
1635        };
1636        let group = vec![
1637            to_prepared("a", a),
1638            to_prepared("b", b),
1639            to_prepared("c", c),
1640        ];
1641        let clones = detect_prepared(vec![group], min_tokens, 0, &PathFilters::default());
1642        let a_c: Vec<&CpdClone> = clones
1643            .iter()
1644            .filter(|cl| cl.fragment_a.source_id == "a" && cl.fragment_b.source_id == "c")
1645            .collect();
1646        assert_eq!(
1647            a_c.len(),
1648            1,
1649            "a↔c must be reported once, got {:?}",
1650            clones
1651                .iter()
1652                .map(|cl| (
1653                    cl.fragment_a.source_id.as_str(),
1654                    cl.fragment_a.range,
1655                    cl.fragment_b.source_id.as_str(),
1656                    cl.fragment_b.range,
1657                    cl.token_count
1658                ))
1659                .collect::<Vec<_>>()
1660        );
1661        assert_eq!(
1662            a_c[0].token_count,
1663            x.len() as u32,
1664            "exactly X, not X plus T"
1665        );
1666        assert_eq!(a_c[0].fragment_a.range, [0, x.len() as u32 - 1]);
1667        assert_eq!(a_c[0].fragment_b.range, [0, x.len() as u32 - 1]);
1668        // Every reported pair covers identical token runs on both sides.
1669        let run = |frag: &Fragment| -> &[u64] {
1670            let hashes = &streams
1671                .iter()
1672                .find(|(id, _)| *id == frag.source_id)
1673                .unwrap()
1674                .1;
1675            &hashes[frag.range[0] as usize..=frag.range[1] as usize]
1676        };
1677        for cl in &clones {
1678            assert_eq!(
1679                run(&cl.fragment_a),
1680                run(&cl.fragment_b),
1681                "{}{:?} and {}{:?} must hold the same tokens",
1682                cl.fragment_a.source_id,
1683                cl.fragment_a.range,
1684                cl.fragment_b.source_id,
1685                cl.fragment_b.range
1686            );
1687        }
1688    }
1689
1690    #[test]
1691    fn identical_files_maximal_clone() {
1692        // With the open_clone state machine, a single maximal clone is emitted
1693        // instead of multiple sliding-window sub-clones.
1694        let tokens = js_tokens_ab();
1695        let file_a = make_file("a.js", "javascript", tokens.clone());
1696        let file_b = make_file("b.js", "javascript", tokens);
1697        let clones = detect(&[file_a, file_b], 5);
1698        assert_eq!(
1699            clones.len(),
1700            1,
1701            "open_clone SM must produce one maximal clone"
1702        );
1703        assert_eq!(
1704            clones[0].token_count, 9,
1705            "maximal clone must cover all 9 tokens"
1706        );
1707    }
1708
1709    #[test]
1710    fn three_identical_files_secondary_pass_adds_missing_pair() {
1711        let tokens = js_tokens_ab();
1712        let file_a = make_file("a.js", "javascript", tokens.clone());
1713        let file_b = make_file("b.js", "javascript", tokens.clone());
1714        let file_c = make_file("c.js", "javascript", tokens);
1715        let clones = detect(&[file_a, file_b, file_c], 5);
1716        assert!(
1717            clones.len() >= 2,
1718            "three identical files must yield at least 2 clone pairs, got {}",
1719            clones.len()
1720        );
1721    }
1722
1723    #[test]
1724    fn clones_sorted_by_source_and_line() {
1725        let tokens = js_tokens_ab();
1726        let file_a = make_file("a.js", "javascript", tokens.clone());
1727        let file_b = make_file("b.js", "javascript", tokens);
1728        let clones = detect(&[file_a, file_b], 5);
1729        for i in 1..clones.len() {
1730            let prev = &clones[i - 1];
1731            let curr = &clones[i];
1732            assert!(
1733                (
1734                    &prev.fragment_a.source_id,
1735                    prev.fragment_a.start.line,
1736                    &prev.fragment_b.source_id,
1737                    prev.fragment_b.start.line,
1738                ) <= (
1739                    &curr.fragment_a.source_id,
1740                    curr.fragment_a.start.line,
1741                    &curr.fragment_b.source_id,
1742                    curr.fragment_b.start.line,
1743                ),
1744                "clones must be sorted"
1745            );
1746        }
1747    }
1748
1749    #[test]
1750    fn filter_path_is_the_real_path_when_it_differs_from_the_id() {
1751        let mut source = PreparedSource::from_detection_tokens(
1752            "/repo/corpus/x.js".into(),
1753            "javascript".into(),
1754            &[],
1755        );
1756        assert_eq!(source.filter_path(), "/repo/corpus/x.js");
1757        source.real_path = "/elsewhere/x.js".into();
1758        assert_eq!(source.filter_path(), "/elsewhere/x.js");
1759    }
1760
1761    fn isolated(groups: &[&[&str]]) -> Vec<Vec<PathBuf>> {
1762        groups
1763            .iter()
1764            .map(|g| g.iter().map(PathBuf::from).collect())
1765            .collect()
1766    }
1767
1768    #[test]
1769    fn skip_isolated_drops_pairs_across_group_folders() {
1770        let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1771        assert!(should_skip_isolated(
1772            "/repo/packages/a/src/x.js",
1773            "/repo/packages/b/src/y.js",
1774            &groups
1775        ));
1776    }
1777
1778    #[test]
1779    fn skip_isolated_keeps_pairs_inside_one_folder() {
1780        let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1781        assert!(!should_skip_isolated(
1782            "/repo/packages/a/src/x.js",
1783            "/repo/packages/a/lib/y.js",
1784            &groups
1785        ));
1786    }
1787
1788    #[test]
1789    fn skip_isolated_keeps_pairs_with_one_file_outside_group() {
1790        let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1791        assert!(!should_skip_isolated(
1792            "/repo/packages/a/src/x.js",
1793            "/repo/globals/y.js",
1794            &groups
1795        ));
1796        assert!(!should_skip_isolated(
1797            "/repo/globals/x.js",
1798            "/repo/infra/y.js",
1799            &groups
1800        ));
1801    }
1802
1803    #[test]
1804    fn skip_isolated_folders_in_different_groups_do_not_isolate() {
1805        let groups = isolated(&[
1806            &["/repo/packages/a", "/repo/packages/b"],
1807            &["/repo/libs/a", "/repo/libs/b"],
1808        ]);
1809        assert!(!should_skip_isolated(
1810            "/repo/packages/a/x.js",
1811            "/repo/libs/b/y.js",
1812            &groups
1813        ));
1814        assert!(should_skip_isolated(
1815            "/repo/libs/a/x.js",
1816            "/repo/libs/b/y.js",
1817            &groups
1818        ));
1819    }
1820
1821    #[test]
1822    fn path_labels_agree_with_should_skip() {
1823        let roots = [PathBuf::from("/repo/app"), PathBuf::from("/repo/lib")];
1824        let groups = [vec![
1825            PathBuf::from("/repo/app/a"),
1826            PathBuf::from("/repo/app/b"),
1827        ]];
1828        for skip_local in [false, true] {
1829            let filters = PathFilters {
1830                skip_local,
1831                scan_roots: &roots,
1832                isolated_groups: &groups,
1833            };
1834            let files = [
1835                "/repo/app/a/x.ts",
1836                "/repo/app/b/y.ts",
1837                "/repo/app/z.ts",
1838                "/repo/lib/w.rs",
1839                "/repo/other.js",
1840            ];
1841            for a in files {
1842                for b in files {
1843                    assert_eq!(
1844                        filters.label(a).skips(&filters.label(b)),
1845                        filters.should_skip(a, b),
1846                        "{a} ~ {b}, skip_local {skip_local}"
1847                    );
1848                }
1849            }
1850        }
1851    }
1852
1853    #[test]
1854    fn path_filters_combine_skip_local_and_skip_isolated() {
1855        let scan_roots = vec![PathBuf::from("/repo/shared")];
1856        let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1857        let filters = PathFilters {
1858            skip_local: true,
1859            scan_roots: &scan_roots,
1860            isolated_groups: &groups,
1861        };
1862        assert!(filters.should_skip("/repo/shared/x.js", "/repo/shared/y.js"));
1863        assert!(filters.should_skip("/repo/packages/a/x.js", "/repo/packages/b/y.js"));
1864        assert!(!filters.should_skip("/repo/shared/x.js", "/repo/packages/a/y.js"));
1865    }
1866}