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