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::{CpdClone, DetectionToken, Fragment, Location, SourceFile, TokenKind},
10};
11
12// ---------------------------------------------------------------------------
13// Internal store type — replaces the Store trait + MemoryStore
14// ---------------------------------------------------------------------------
15
16/// Window store: maps a window hash to the last seen occurrence.
17/// Type alias — no trait indirection, no vtable, no dyn dispatch.
18type WindowStore = FxHashMap<u64, Occurrence>;
19
20/// Lightweight reference to a window position within a format-group detection call.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22struct Occurrence {
23    /// Index into the `prepared` array for this `detect_in_group` call.
24    source_id: usize,
25    token_start: usize,
26}
27
28// ---------------------------------------------------------------------------
29// Deduplication key
30// ---------------------------------------------------------------------------
31
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33struct CloneDedupKey {
34    a_id: String,
35    a_start_line: u32,
36    b_id: String,
37    b_start_line: u32,
38}
39
40impl CloneDedupKey {
41    fn from_clone(c: &CpdClone) -> Self {
42        // Normalize: smaller (id, line) first so (A,B) and (B,A) map to the same key.
43        let a_key = (&c.fragment_a.source_id, c.fragment_a.start.line);
44        let b_key = (&c.fragment_b.source_id, c.fragment_b.start.line);
45        if a_key <= b_key {
46            Self {
47                a_id: c.fragment_a.source_id.clone(),
48                a_start_line: c.fragment_a.start.line,
49                b_id: c.fragment_b.source_id.clone(),
50                b_start_line: c.fragment_b.start.line,
51            }
52        } else {
53            Self {
54                a_id: c.fragment_b.source_id.clone(),
55                a_start_line: c.fragment_b.start.line,
56                b_id: c.fragment_a.source_id.clone(),
57                b_start_line: c.fragment_a.start.line,
58            }
59        }
60    }
61}
62
63// ---------------------------------------------------------------------------
64// Public API — SourceFile path (for backward compat with tests)
65// ---------------------------------------------------------------------------
66
67/// Detect duplicate code clones across `files` using a rolling-hash sliding window.
68///
69/// Files are grouped by format; each format group is processed independently.
70/// Rayon is used for outer parallelism (one task per format group).
71pub fn detect(files: &[SourceFile], min_tokens: usize) -> Vec<CpdClone> {
72    detect_with_options(files, min_tokens, false, 0, &[])
73}
74
75/// Detect clones with extended options.
76///
77/// - `skip_local`: skip clone pairs where both fragments are under the same scan root.
78/// - `min_lines`: reject clones whose fragment line span is shorter than this.
79///   The line span is `end.line - start.line`; a clone is kept only if this value
80///   is >= `min_lines`. This mirrors jscpd's `LinesLengthCloneValidator`.
81/// - `scan_roots`: when `skip_local` is true, clone pairs where both fragments
82///   are relative to the same scan root are skipped. Mirrors jscpd's
83///   `SkipLocalValidator` which checks `path.some(dir => isRelative(fileA, dir) && isRelative(fileB, dir))`.
84pub fn detect_with_options(
85    files: &[SourceFile],
86    min_tokens: usize,
87    skip_local: bool,
88    min_lines: usize,
89    scan_roots: &[PathBuf],
90) -> Vec<CpdClone> {
91    if files.is_empty() || min_tokens == 0 {
92        return vec![];
93    }
94
95    // Group files by format. Sort for deterministic order.
96    let mut by_format: FxHashMap<&str, Vec<&SourceFile>> = FxHashMap::default();
97    for file in files {
98        by_format
99            .entry(file.format.as_str())
100            .or_default()
101            .push(file);
102    }
103    let mut format_groups: Vec<(&str, Vec<&SourceFile>)> = by_format.into_iter().collect();
104    format_groups.sort_unstable_by_key(|(fmt, _)| *fmt);
105    for (_, group) in &mut format_groups {
106        group.sort_unstable_by_key(|&file| file.id.as_str());
107    }
108
109    let mut clones: Vec<CpdClone> = format_groups
110        .into_par_iter()
111        .flat_map(|(_format, files)| {
112            // Build per-group prepared data from SourceFile.tokens.
113            // This is the backward-compat path; orchestrate.rs uses
114            // detect_prepared() directly to avoid re-hashing.
115            let prepared: Vec<PreparedSource> = files
116                .into_iter()
117                .map(|file| {
118                    let mut hashes = Vec::with_capacity(file.tokens.len());
119                    let mut spans: Vec<(Location, Location)> =
120                        Vec::with_capacity(file.tokens.len());
121                    for t in &file.tokens {
122                        if t.kind == TokenKind::Ignore {
123                            continue;
124                        }
125                        hashes.push(token_hash(t.kind.discriminant(), &t.value));
126                        spans.push((t.start.clone(), t.end.clone()));
127                    }
128                    PreparedSource {
129                        id: file.id.clone(),
130                        format: file.format.clone(),
131                        hashes,
132                        spans,
133                    }
134                })
135                .collect();
136            detect_in_group(&prepared, min_tokens, skip_local, min_lines, scan_roots)
137        })
138        .collect();
139
140    finalize_clones(&mut clones);
141    clones
142}
143
144fn finalize_clones(clones: &mut Vec<CpdClone>) {
145    dedup_exact_clones(clones);
146    clones.sort_by(|a, b| {
147        (
148            &a.fragment_a.source_id,
149            a.fragment_a.start.line,
150            &a.fragment_b.source_id,
151            a.fragment_b.start.line,
152        )
153            .cmp(&(
154                &b.fragment_a.source_id,
155                b.fragment_a.start.line,
156                &b.fragment_b.source_id,
157                b.fragment_b.start.line,
158            ))
159    });
160}
161
162// ---------------------------------------------------------------------------
163// Direct DetectionToken path (called by orchestrate.rs)
164// ---------------------------------------------------------------------------
165
166/// A file ready for detection: pre-hashed, pre-filtered.
167///
168/// Produced either from `SourceFile.tokens` (backward compat) or directly from
169/// `tokenize_to_detection` output (fast path used by orchestrate.rs).
170pub struct PreparedSource {
171    pub id: String,
172    pub format: String,
173    pub hashes: Vec<u64>,
174    pub spans: Vec<(Location, Location)>,
175}
176
177impl PreparedSource {
178    /// Build from a `DetectionToken` slice — the fast path.
179    pub fn from_detection_tokens(id: String, format: String, tokens: &[DetectionToken]) -> Self {
180        let mut hashes = Vec::with_capacity(tokens.len());
181        let mut spans = Vec::with_capacity(tokens.len());
182        for t in tokens {
183            hashes.push(t.hash);
184            spans.push((t.start.clone(), t.end.clone()));
185        }
186        Self {
187            id,
188            format,
189            hashes,
190            spans,
191        }
192    }
193}
194
195/// Detect clones from pre-prepared sources grouped by format.
196///
197/// Called by orchestrate.rs after `tokenize_to_detection` — skips re-hashing.
198/// - `scan_roots`: when `skip_local` is true, clone pairs where both fragments
199///   are relative to the same scan root are skipped.
200pub fn detect_prepared(
201    format_groups: Vec<Vec<PreparedSource>>,
202    min_tokens: usize,
203    skip_local: bool,
204    min_lines: usize,
205    scan_roots: &[PathBuf],
206) -> Vec<CpdClone> {
207    if format_groups.is_empty() || min_tokens == 0 {
208        return vec![];
209    }
210
211    let mut clones: Vec<CpdClone> = format_groups
212        .into_par_iter()
213        .flat_map(|group| detect_in_group(&group, min_tokens, skip_local, min_lines, scan_roots))
214        .collect();
215
216    finalize_clones(&mut clones);
217    clones
218}
219
220// ---------------------------------------------------------------------------
221// Core detection — per format group
222// ---------------------------------------------------------------------------
223
224fn detect_in_group(
225    prepared: &[PreparedSource],
226    min_tokens: usize,
227    skip_local: bool,
228    min_lines: usize,
229    scan_roots: &[PathBuf],
230) -> Vec<CpdClone> {
231    // Precompute window_power once for this format group.
232    // If per-language min_tokens is introduced, recompute per group (it is already scoped here).
233    let window_power = base_pow(min_tokens.saturating_sub(1));
234
235    // Pre-allocate store capacity to avoid FxHashMap rehashing.
236    let total_windows: usize = prepared
237        .iter()
238        .map(|p| p.hashes.len().saturating_sub(min_tokens))
239        .sum();
240    let mut store: WindowStore =
241        FxHashMap::with_capacity_and_hasher(total_windows, Default::default());
242
243    let mut clones: Vec<CpdClone> = Vec::new();
244    // Cap repeated-window occurrences per hash. Higher values find more clone pairs
245    // among 3+ similar files (e.g., file_1.js, file_1.mjs, file_1.cjs) but use more memory.
246    // The TypeScript jscpd compares all file pairs, so we raise this to match its coverage.
247    const SECONDARY_OCCURRENCE_CAP: usize = 2;
248    let mut repeated_windows: FxHashMap<u64, Vec<Occurrence>> = FxHashMap::default();
249
250    for (file_idx, source) in prepared.iter().enumerate() {
251        let hashes = &source.hashes;
252        if hashes.len() < min_tokens {
253            continue;
254        }
255        let windows_len = hashes.len() - min_tokens + 1;
256
257        // open_clone state machine: replaces emit-every-window + suppress_subclones.
258        // A clone is opened when a matching window is found and enlarged as long as
259        // subsequent windows also match. flush_clone is called when the match breaks
260        // or the file scan ends — only one clone per contiguous matching region.
261        let mut open_clone: Option<OpenClone> = None;
262
263        let mut window_hash = hash_window(&hashes[..min_tokens]);
264
265        for token_start in 0..windows_len {
266            if token_start > 0 {
267                window_hash = roll(
268                    window_hash,
269                    hashes[token_start - 1],
270                    hashes[token_start + min_tokens - 1],
271                    window_power,
272                );
273            }
274
275            let current = Occurrence {
276                source_id: file_idx,
277                token_start,
278            };
279
280            match store.get(&window_hash).copied() {
281                Some(stored) if windows_match(stored, current, prepared, min_tokens) => {
282                    if open_clone.is_none() {
283                        open_clone = Some(OpenClone {
284                            stored_occurrence: stored,
285                            current_start: token_start,
286                            match_len: min_tokens,
287                        });
288                    } else if let Some(ref mut oc) = open_clone {
289                        // Enlarge: the next window also matches — extend by one token.
290                        oc.match_len += 1;
291                    }
292                    remember_repeated_window(
293                        &mut repeated_windows,
294                        window_hash,
295                        stored,
296                        SECONDARY_OCCURRENCE_CAP,
297                    );
298                    remember_repeated_window(
299                        &mut repeated_windows,
300                        window_hash,
301                        current,
302                        SECONDARY_OCCURRENCE_CAP,
303                    );
304                    // Do NOT update store — keep the first occurrence so the enlargement
305                    // stays consistent across the contiguous match region.
306                }
307                _ => {
308                    // Match broke (or no entry). Flush whatever was open.
309                    flush_clone(
310                        open_clone.take(),
311                        file_idx,
312                        prepared,
313                        skip_local,
314                        min_lines,
315                        scan_roots,
316                        &mut clones,
317                    );
318                    store.insert(window_hash, current);
319                }
320            }
321        }
322
323        // Flush any open clone at the end of the file scan.
324        flush_clone(
325            open_clone.take(),
326            file_idx,
327            prepared,
328            skip_local,
329            min_lines,
330            scan_roots,
331            &mut clones,
332        );
333    }
334
335    add_secondary_clones(
336        repeated_windows,
337        prepared,
338        min_tokens,
339        skip_local,
340        min_lines,
341        scan_roots,
342        &mut clones,
343    );
344
345    clones
346}
347
348// ---------------------------------------------------------------------------
349// Open clone state machine helpers
350// ---------------------------------------------------------------------------
351
352/// Returns true if both files share a common scan root directory.
353/// Mirrors jscpd's `SkipLocalValidator.shouldSkipClone`:
354///   `path.some(dir => isRelative(fileA, dir) && isRelative(fileB, dir))`
355fn should_skip_local(file_a: &str, file_b: &str, scan_roots: &[PathBuf]) -> bool {
356    scan_roots
357        .iter()
358        .any(|root| is_relative_to(file_a, root) && is_relative_to(file_b, root))
359}
360
361/// Returns true if `file_path` is contained within `dir`.
362/// Mirrors the TypeScript `SkipLocalValidator.isRelative`:
363///   `const rel = relative(dir, file); return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);`
364fn is_relative_to(file_path: &str, dir: &PathBuf) -> bool {
365    let file = Path::new(file_path);
366    // Fast path: file path starts with the dir prefix
367    if let Ok(rel) = file.strip_prefix(dir) {
368        return !rel.as_os_str().is_empty();
369    }
370    // Mixed absolute/relative can never match via simple prefix
371    if file.is_absolute() != dir.is_absolute() {
372        return false;
373    }
374    // Walk up from the file path checking if any ancestor starts with dir
375    let mut ancestor = file;
376    loop {
377        if ancestor == dir.as_path() {
378            return false;
379        }
380        if ancestor.starts_with(dir) {
381            let rel = ancestor.strip_prefix(dir).unwrap_or(ancestor);
382            return !rel.as_os_str().is_empty();
383        }
384        ancestor = match ancestor.parent() {
385            Some(p) => p,
386            None => return false,
387        };
388    }
389}
390
391struct OpenClone {
392    stored_occurrence: Occurrence,
393    current_start: usize,
394    match_len: usize,
395}
396
397/// Returns true if the window at `current` actually matches the window at `stored`
398/// (hash match is necessary but not sufficient — verify token equality).
399fn windows_match(
400    stored: Occurrence,
401    current: Occurrence,
402    prepared: &[PreparedSource],
403    min_tokens: usize,
404) -> bool {
405    if stored.source_id == current.source_id && stored.token_start == current.token_start {
406        return false;
407    }
408    let stored_hashes = &prepared[stored.source_id].hashes;
409    let current_hashes = &prepared[current.source_id].hashes;
410    if stored.token_start + min_tokens > stored_hashes.len()
411        || current.token_start + min_tokens > current_hashes.len()
412    {
413        return false;
414    }
415    stored_hashes[stored.token_start..stored.token_start + min_tokens]
416        == current_hashes[current.token_start..current.token_start + min_tokens]
417}
418
419/// Flush an open clone to the clones list.
420///
421/// A clone is rejected if its line span is shorter than `min_lines`.
422/// The line span is measured as `end.line - start.line` (which equals
423/// `number_of_lines - 1`). Mirrors jscpd's `LinesLengthCloneValidator`.
424fn flush_clone(
425    open: Option<OpenClone>,
426    current_file_idx: usize,
427    prepared: &[PreparedSource],
428    skip_local: bool,
429    min_lines: usize,
430    scan_roots: &[PathBuf],
431    clones: &mut Vec<CpdClone>,
432) {
433    let oc = match open {
434        Some(o) => o,
435        None => return,
436    };
437
438    let existing = &oc.stored_occurrence;
439    let cur_start = oc.current_start;
440    let match_len = oc.match_len;
441
442    let existing_file = &prepared[existing.source_id];
443    let current_file = &prepared[current_file_idx];
444
445    let ex_start = existing.token_start;
446    let ex_end = ex_start + match_len - 1;
447    let cur_end = cur_start + match_len - 1;
448
449    // skip_local: drop clone pairs where both fragments are under the same scan root.
450    // Mirrors jscpd's SkipLocalValidator which checks
451    //   path.some(dir => isRelative(fileA, dir) && isRelative(fileB, dir))
452    if skip_local && should_skip_local(&existing_file.id, &current_file.id, scan_roots) {
453        return;
454    }
455
456    let fragment_a = match make_fragment(&existing_file.id, &existing_file.spans, ex_start, ex_end)
457    {
458        Some(f) => f,
459        None => return,
460    };
461    let fragment_b = match make_fragment(&current_file.id, &current_file.spans, cur_start, cur_end)
462    {
463        Some(f) => f,
464        None => return,
465    };
466
467    // min_lines filter: reject clones whose fragment A line span is shorter than min_lines.
468    // Mirrors jscpd's LinesLengthCloneValidator which checks only duplicationA:
469    //   duplicationA.end.line - duplicationA.start.line >= minLines
470    if min_lines > 0 {
471        let lines = fragment_a.end.line as usize - fragment_a.start.line as usize;
472        if lines < min_lines {
473            return;
474        }
475    }
476
477    clones.push(CpdClone {
478        format: current_file.format.clone(),
479        fragment_a,
480        fragment_b,
481        token_count: match_len as u32,
482    });
483}
484
485fn make_fragment(
486    source_id: &str,
487    spans: &[(Location, Location)],
488    start_idx: usize,
489    end_idx: usize,
490) -> Option<Fragment> {
491    let (first_start, _) = spans.get(start_idx)?;
492    let (_, last_end) = spans.get(end_idx)?;
493    Some(Fragment {
494        source_id: source_id.to_string(),
495        start: first_start.clone(),
496        end: last_end.clone(),
497        range: [start_idx as u32, end_idx as u32],
498        blame: None,
499    })
500}
501
502// ---------------------------------------------------------------------------
503// Deduplication — O(n) FxHashSet + sub-clone suppression
504// ---------------------------------------------------------------------------
505
506fn dedup_exact_clones(clones: &mut Vec<CpdClone>) {
507    // Normalize each clone so fragment_a <= fragment_b (by id then start line).
508    for clone in clones.iter_mut() {
509        let a_key = (&clone.fragment_a.source_id, clone.fragment_a.start.line);
510        let b_key = (&clone.fragment_b.source_id, clone.fragment_b.start.line);
511        if a_key > b_key {
512            std::mem::swap(&mut clone.fragment_a, &mut clone.fragment_b);
513        }
514    }
515
516    let mut seen: FxHashSet<CloneDedupKey> = FxHashSet::default();
517    clones.retain(|c| seen.insert(CloneDedupKey::from_clone(c)));
518}
519
520// ---------------------------------------------------------------------------
521// Secondary clone pass
522// ---------------------------------------------------------------------------
523
524fn remember_repeated_window(
525    repeated_windows: &mut FxHashMap<u64, Vec<Occurrence>>,
526    hash: u64,
527    occurrence: Occurrence,
528    cap: usize,
529) {
530    let bucket = repeated_windows.entry(hash).or_default();
531    if bucket
532        .iter()
533        .any(|s| s.source_id == occurrence.source_id && s.token_start == occurrence.token_start)
534    {
535        return;
536    }
537    if bucket.len() < cap {
538        bucket.push(occurrence);
539    }
540}
541
542struct SecondaryOpen {
543    clone: CpdClone,
544    source_a: usize,
545    source_b: usize,
546    last_token_start_a: usize,
547    last_token_start_b: usize,
548}
549
550#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
551struct Candidate {
552    source_a: usize,
553    source_b: usize,
554    token_a: usize,
555    token_b: usize,
556}
557
558impl SecondaryOpen {
559    /// True when `candidate` extends this open clone by exactly one token on
560    /// both sides.
561    fn is_continuation(&self, candidate: &Candidate) -> bool {
562        self.source_a == candidate.source_a
563            && self.source_b == candidate.source_b
564            && self.last_token_start_a + 1 == candidate.token_a
565            && self.last_token_start_b + 1 == candidate.token_b
566    }
567
568    /// Grow the open clone by one token on each side, using `prepared` to
569    /// resolve the new endpoint spans.
570    fn grow(&mut self, candidate: &Candidate, prepared: &[PreparedSource], min_tokens: usize) {
571        self.clone.token_count += 1;
572        let end_a = candidate.token_a + min_tokens;
573        let end_b = candidate.token_b + min_tokens;
574        if let Some(span) = prepared[self.source_a].spans.get(end_a) {
575            self.clone.fragment_a.end = span.1.clone();
576            self.clone.fragment_a.range[1] = end_a as u32;
577        }
578        if let Some(span) = prepared[self.source_b].spans.get(end_b) {
579            self.clone.fragment_b.end = span.1.clone();
580            self.clone.fragment_b.range[1] = end_b as u32;
581        }
582        self.last_token_start_a = candidate.token_a;
583        self.last_token_start_b = candidate.token_b;
584    }
585}
586
587fn add_secondary_clones(
588    repeated_windows: FxHashMap<u64, Vec<Occurrence>>,
589    prepared: &[PreparedSource],
590    min_tokens: usize,
591    skip_local: bool,
592    min_lines: usize,
593    scan_roots: &[PathBuf],
594    clones: &mut Vec<CpdClone>,
595) {
596    if repeated_windows.is_empty() {
597        return;
598    }
599
600    let mut candidates: Vec<Candidate> = Vec::new();
601    for occurrences in repeated_windows.values() {
602        if occurrences.len() < 2 {
603            continue;
604        }
605        for li in 0..occurrences.len() {
606            for ri in li + 1..occurrences.len() {
607                let left = &occurrences[li];
608                let right = &occurrences[ri];
609                if left.source_id == right.source_id && left.token_start == right.token_start {
610                    continue;
611                }
612                let lh = &prepared[left.source_id].hashes;
613                let rh = &prepared[right.source_id].hashes;
614                let la = left.token_start;
615                let ra = right.token_start;
616                if la + min_tokens > lh.len() || ra + min_tokens > rh.len() {
617                    continue;
618                }
619                if lh[la..la + min_tokens] != rh[ra..ra + min_tokens] {
620                    continue;
621                }
622                let (sa, ta, sb, tb) =
623                    if (left.source_id, left.token_start) <= (right.source_id, right.token_start) {
624                        (
625                            left.source_id,
626                            left.token_start,
627                            right.source_id,
628                            right.token_start,
629                        )
630                    } else {
631                        (
632                            right.source_id,
633                            right.token_start,
634                            left.source_id,
635                            left.token_start,
636                        )
637                    };
638                candidates.push(Candidate {
639                    source_a: sa,
640                    source_b: sb,
641                    token_a: ta,
642                    token_b: tb,
643                });
644            }
645        }
646    }
647    if candidates.is_empty() {
648        return;
649    }
650    candidates.sort_unstable();
651    candidates.dedup();
652
653    // Build line-coverage from already-found primary clones.
654    let mut coverage = LineCoverage::from_clones(prepared, clones);
655    let mut open: Option<SecondaryOpen> = None;
656
657    for candidate in candidates {
658        if let Some(current) = open.as_mut()
659            && current.is_continuation(&candidate)
660        {
661            current.grow(&candidate, prepared, min_tokens);
662            continue;
663        }
664
665        flush_secondary_clone(
666            open.take(),
667            prepared,
668            skip_local,
669            min_lines,
670            scan_roots,
671            clones,
672            &mut coverage,
673        );
674
675        // Create a new secondary clone candidate.
676        let start_a = candidate.token_a;
677        let end_a = start_a + min_tokens - 1;
678        let start_b = candidate.token_b;
679        let end_b = start_b + min_tokens - 1;
680
681        let frag_a = match make_fragment(
682            &prepared[candidate.source_a].id,
683            &prepared[candidate.source_a].spans,
684            start_a,
685            end_a,
686        ) {
687            Some(f) => f,
688            None => continue,
689        };
690        let frag_b = match make_fragment(
691            &prepared[candidate.source_b].id,
692            &prepared[candidate.source_b].spans,
693            start_b,
694            end_b,
695        ) {
696            Some(f) => f,
697            None => continue,
698        };
699
700        open = Some(SecondaryOpen {
701            clone: CpdClone {
702                format: prepared[candidate.source_a].format.clone(),
703                fragment_a: frag_a,
704                fragment_b: frag_b,
705                token_count: min_tokens as u32,
706            },
707            source_a: candidate.source_a,
708            source_b: candidate.source_b,
709            last_token_start_a: candidate.token_a,
710            last_token_start_b: candidate.token_b,
711        });
712    }
713
714    flush_secondary_clone(
715        open.take(),
716        prepared,
717        skip_local,
718        min_lines,
719        scan_roots,
720        clones,
721        &mut coverage,
722    );
723}
724
725fn flush_secondary_clone(
726    open: Option<SecondaryOpen>,
727    prepared: &[PreparedSource],
728    skip_local: bool,
729    min_lines: usize,
730    scan_roots: &[PathBuf],
731    clones: &mut Vec<CpdClone>,
732    coverage: &mut LineCoverage,
733) {
734    let Some(oc) = open else {
735        return;
736    };
737
738    let range_a = fragment_line_range(&oc.clone.fragment_a);
739    let range_b = fragment_line_range(&oc.clone.fragment_b);
740
741    // skip_local: drop clone pairs where both fragments are under the same scan root.
742    if skip_local
743        && should_skip_local(
744            &prepared[oc.source_a].id,
745            &prepared[oc.source_b].id,
746            scan_roots,
747        )
748    {
749        return;
750    }
751
752    // min_lines filter: only check fragment A, mirroring jscpd's LinesLengthCloneValidator.
753    if min_lines > 0 {
754        let lines = oc.clone.fragment_a.end.line as usize - oc.clone.fragment_a.start.line as usize;
755        if lines < min_lines {
756            return;
757        }
758    }
759
760    // Line-coverage filter: skip secondary clones that don't extend existing coverage
761    // on either side.  This prevents the report from filling up with dozens of
762    // overlapping sub-clones of the same region.
763    if !coverage.extends(oc.source_a, range_a) || !coverage.extends(oc.source_b, range_b) {
764        return;
765    }
766
767    let before = clones.len();
768    clones.push(oc.clone);
769
770    // Insert coverage for newly added clone.
771    if clones.len() > before {
772        coverage.insert(oc.source_a, range_a);
773        coverage.insert(oc.source_b, range_b);
774    }
775}
776
777fn fragment_line_range(fragment: &Fragment) -> (usize, usize) {
778    let start = fragment.start.line as usize;
779    let end = fragment.end.line as usize;
780    (start.min(end), start.max(end))
781}
782
783// ---------------------------------------------------------------------------
784// Line coverage tracking for secondary clones
785// ---------------------------------------------------------------------------
786
787struct LineCoverage {
788    ranges_by_source: Vec<Vec<(usize, usize)>>,
789}
790
791impl LineCoverage {
792    fn from_clones(prepared: &[PreparedSource], clones: &[CpdClone]) -> Self {
793        let mut source_lookup: FxHashMap<&str, usize> = FxHashMap::default();
794        for (idx, source) in prepared.iter().enumerate() {
795            source_lookup.insert(source.id.as_str(), idx);
796        }
797        let mut coverage = Self {
798            ranges_by_source: vec![Vec::new(); prepared.len()],
799        };
800        for clone in clones {
801            if let Some(idx) = source_lookup.get(clone.fragment_a.source_id.as_str()) {
802                coverage.insert(*idx, fragment_line_range(&clone.fragment_a));
803            }
804            if let Some(idx) = source_lookup.get(clone.fragment_b.source_id.as_str()) {
805                coverage.insert(*idx, fragment_line_range(&clone.fragment_b));
806            }
807        }
808        coverage
809    }
810
811    fn extends(&self, source_idx: usize, range: (usize, usize)) -> bool {
812        // ponytail: walk existing intervals in order, advancing the low watermark
813        // past anything already covered. We extend unless the candidate is
814        // already fully covered by an existing interval chain.
815        let Some(intervals) = self.ranges_by_source.get(source_idx) else {
816            return true;
817        };
818        let mut cursor = range.0;
819        for &(start, end) in intervals {
820            if end < cursor {
821                continue;
822            }
823            if start > cursor {
824                return true;
825            }
826            cursor = cursor.max(end.saturating_add(1));
827            if cursor > range.1 {
828                return false;
829            }
830        }
831        cursor <= range.1
832    }
833
834    fn insert(&mut self, source_idx: usize, range: (usize, usize)) {
835        // ponytail: merge-into-sorted approach. Keep the per-source vector
836        // sorted and merged so `extends` can scan it in one pass; we rebuild
837        // it by folding the new range into the existing merged intervals
838        // rather than re-sorting the whole list every insert.
839        let Some(intervals) = self.ranges_by_source.get_mut(source_idx) else {
840            return;
841        };
842        let mut folded = Vec::with_capacity(intervals.len() + 1);
843        let mut pending = Some(range);
844        for &(start, end) in intervals.iter() {
845            let p = match pending.take() {
846                None => {
847                    folded.push((start, end));
848                    continue;
849                }
850                Some(p) => p,
851            };
852            // p is fully before this interval — emit p, then this interval.
853            if p.1.saturating_add(1) < start {
854                folded.push(p);
855                folded.push((start, end));
856            }
857            // this interval is fully before p — emit it, keep p pending.
858            else if end.saturating_add(1) < p.0 {
859                folded.push((start, end));
860                pending = Some(p);
861            }
862            // overlapping or adjacent — merge into p, keep pending.
863            else {
864                pending = Some((p.0.min(start), p.1.max(end)));
865            }
866        }
867        if let Some(p) = pending {
868            folded.push(p);
869        }
870        *intervals = folded;
871    }
872}
873
874// ---------------------------------------------------------------------------
875// Tests
876// ---------------------------------------------------------------------------
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881    use crate::models::{Location, Token, TokenKind};
882
883    fn loc(line: u32, col: u32, offset: u32) -> Location {
884        Location {
885            line,
886            column: col,
887            offset,
888        }
889    }
890
891    fn make_token(kind: TokenKind, value: &str, line: u32, col: u32, offset: u32) -> Token {
892        let end_col = col + value.len() as u32;
893        let end_off = offset + value.len() as u32;
894        Token {
895            kind,
896            value: value.to_string(),
897            start: loc(line, col, offset),
898            end: loc(line, end_col, end_off),
899        }
900    }
901
902    fn make_file(id: &str, format: &str, tokens: Vec<Token>) -> SourceFile {
903        SourceFile {
904            id: id.to_string(),
905            format: format.to_string(),
906            tokens,
907        }
908    }
909
910    fn js_tokens_ab() -> Vec<Token> {
911        vec![
912            make_token(TokenKind::Keyword, "function", 1, 0, 0),
913            make_token(TokenKind::Other, "hello", 1, 9, 9),
914            make_token(TokenKind::Operator, "(", 1, 14, 14),
915            make_token(TokenKind::Operator, ")", 1, 15, 15),
916            make_token(TokenKind::Operator, "{", 1, 16, 16),
917            make_token(TokenKind::Keyword, "return", 2, 0, 18),
918            make_token(TokenKind::Literal, "42", 2, 7, 25),
919            make_token(TokenKind::Operator, ";", 2, 9, 27),
920            make_token(TokenKind::Operator, "}", 3, 0, 29),
921        ]
922    }
923
924    #[test]
925    fn empty_input_returns_empty() {
926        let result = detect(&[], 10);
927        assert!(result.is_empty());
928    }
929
930    fn pair_with_js_tokens(min_tokens: usize) -> Vec<CpdClone> {
931        let tokens = js_tokens_ab();
932        let file_a = make_file("a.js", "javascript", tokens.clone());
933        let file_b = make_file("b.js", "javascript", tokens);
934        detect(&[file_a, file_b], min_tokens)
935    }
936
937    #[test]
938    fn identical_files_detected_as_clone() {
939        assert!(
940            !pair_with_js_tokens(5).is_empty(),
941            "identical files must produce at least one clone"
942        );
943    }
944
945    #[test]
946    fn min_tokens_threshold_respected() {
947        assert!(
948            pair_with_js_tokens(100).is_empty(),
949            "no clones when min_tokens exceeds file length"
950        );
951    }
952
953    #[test]
954    fn deduplication_ab_ba_collapse() {
955        assert_eq!(
956            pair_with_js_tokens(5).len(),
957            1,
958            "symmetric pairs must collapse to 1"
959        );
960    }
961
962    #[test]
963    fn different_formats_not_cross_detected() {
964        let tokens = js_tokens_ab();
965        let file_js = make_file("a.js", "javascript", tokens.clone());
966        let file_py = make_file("a.py", "python", tokens);
967        let clones = detect(&[file_js, file_py], 5);
968        assert!(
969            clones.is_empty(),
970            "tokens from different formats must not match"
971        );
972    }
973
974    #[test]
975    fn identical_files_maximal_clone() {
976        // With the open_clone state machine, a single maximal clone is emitted
977        // instead of multiple sliding-window sub-clones.
978        let tokens = js_tokens_ab();
979        let file_a = make_file("a.js", "javascript", tokens.clone());
980        let file_b = make_file("b.js", "javascript", tokens);
981        let clones = detect(&[file_a, file_b], 5);
982        assert_eq!(
983            clones.len(),
984            1,
985            "open_clone SM must produce one maximal clone"
986        );
987        assert_eq!(
988            clones[0].token_count, 9,
989            "maximal clone must cover all 9 tokens"
990        );
991    }
992
993    #[test]
994    fn three_identical_files_secondary_pass_adds_missing_pair() {
995        let tokens = js_tokens_ab();
996        let file_a = make_file("a.js", "javascript", tokens.clone());
997        let file_b = make_file("b.js", "javascript", tokens.clone());
998        let file_c = make_file("c.js", "javascript", tokens);
999        let clones = detect(&[file_a, file_b, file_c], 5);
1000        assert!(
1001            clones.len() >= 2,
1002            "three identical files must yield at least 2 clone pairs, got {}",
1003            clones.len()
1004        );
1005    }
1006
1007    #[test]
1008    fn clones_sorted_by_source_and_line() {
1009        let tokens = js_tokens_ab();
1010        let file_a = make_file("a.js", "javascript", tokens.clone());
1011        let file_b = make_file("b.js", "javascript", tokens);
1012        let clones = detect(&[file_a, file_b], 5);
1013        for i in 1..clones.len() {
1014            let prev = &clones[i - 1];
1015            let curr = &clones[i];
1016            assert!(
1017                (
1018                    &prev.fragment_a.source_id,
1019                    prev.fragment_a.start.line,
1020                    &prev.fragment_b.source_id,
1021                    prev.fragment_b.start.line,
1022                ) <= (
1023                    &curr.fragment_a.source_id,
1024                    curr.fragment_a.start.line,
1025                    &curr.fragment_b.source_id,
1026                    curr.fragment_b.start.line,
1027                ),
1028                "clones must be sorted"
1029            );
1030        }
1031    }
1032}