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