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