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