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        is_new: false,
513    });
514}
515
516fn make_fragment(
517    source_id: &str,
518    spans: &[(Location, Location)],
519    start_idx: usize,
520    end_idx: usize,
521) -> Option<Fragment> {
522    let (first_start, _) = spans.get(start_idx)?;
523    let (_, last_end) = spans.get(end_idx)?;
524    Some(Fragment {
525        source_id: source_id.to_string(),
526        source_root: None,
527        start: first_start.clone(),
528        end: last_end.clone(),
529        range: [start_idx as u32, end_idx as u32],
530        blame: None,
531    })
532}
533
534// ---------------------------------------------------------------------------
535// Deduplication — O(n) FxHashSet + sub-clone suppression
536// ---------------------------------------------------------------------------
537
538fn dedup_exact_clones(clones: &mut Vec<CpdClone>) {
539    // Normalize each clone so fragment_a <= fragment_b (by id then start line).
540    for clone in clones.iter_mut() {
541        let a_key = (&clone.fragment_a.source_id, clone.fragment_a.start.line);
542        let b_key = (&clone.fragment_b.source_id, clone.fragment_b.start.line);
543        if a_key > b_key {
544            std::mem::swap(&mut clone.fragment_a, &mut clone.fragment_b);
545        }
546    }
547
548    let mut seen: FxHashSet<CloneDedupKey> = FxHashSet::default();
549    clones.retain(|c| seen.insert(CloneDedupKey::from_clone(c)));
550}
551
552// ---------------------------------------------------------------------------
553// Secondary clone pass
554// ---------------------------------------------------------------------------
555
556fn remember_repeated_window(
557    repeated_windows: &mut FxHashMap<u64, Vec<Occurrence>>,
558    hash: u64,
559    occurrence: Occurrence,
560    cap: usize,
561) {
562    let bucket = repeated_windows.entry(hash).or_default();
563    if bucket
564        .iter()
565        .any(|s| s.source_id == occurrence.source_id && s.token_start == occurrence.token_start)
566    {
567        return;
568    }
569    if bucket.len() < cap {
570        bucket.push(occurrence);
571    }
572}
573
574struct SecondaryOpen {
575    clone: CpdClone,
576    source_a: usize,
577    source_b: usize,
578    last_token_start_a: usize,
579    last_token_start_b: usize,
580}
581
582#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
583struct Candidate {
584    source_a: usize,
585    source_b: usize,
586    token_a: usize,
587    token_b: usize,
588}
589
590impl SecondaryOpen {
591    /// True when `candidate` extends this open clone by exactly one token on
592    /// both sides.
593    fn is_continuation(&self, candidate: &Candidate) -> bool {
594        self.source_a == candidate.source_a
595            && self.source_b == candidate.source_b
596            && self.last_token_start_a + 1 == candidate.token_a
597            && self.last_token_start_b + 1 == candidate.token_b
598    }
599
600    /// Grow the open clone by one token on each side, using `prepared` to
601    /// resolve the new endpoint spans.
602    fn grow(&mut self, candidate: &Candidate, prepared: &[PreparedSource], min_tokens: usize) {
603        self.clone.token_count += 1;
604        let end_a = candidate.token_a + min_tokens;
605        let end_b = candidate.token_b + min_tokens;
606        if let Some(span) = prepared[self.source_a].spans.get(end_a) {
607            self.clone.fragment_a.end = span.1.clone();
608            self.clone.fragment_a.range[1] = end_a as u32;
609        }
610        if let Some(span) = prepared[self.source_b].spans.get(end_b) {
611            self.clone.fragment_b.end = span.1.clone();
612            self.clone.fragment_b.range[1] = end_b as u32;
613        }
614        self.last_token_start_a = candidate.token_a;
615        self.last_token_start_b = candidate.token_b;
616    }
617}
618
619fn add_secondary_clones(
620    repeated_windows: FxHashMap<u64, Vec<Occurrence>>,
621    prepared: &[PreparedSource],
622    min_tokens: usize,
623    min_lines: usize,
624    filters: &PathFilters,
625    clones: &mut Vec<CpdClone>,
626) {
627    if repeated_windows.is_empty() {
628        return;
629    }
630
631    let mut candidates: Vec<Candidate> = Vec::new();
632    for occurrences in repeated_windows.values() {
633        if occurrences.len() < 2 {
634            continue;
635        }
636        for li in 0..occurrences.len() {
637            for ri in li + 1..occurrences.len() {
638                let left = &occurrences[li];
639                let right = &occurrences[ri];
640                if left.source_id == right.source_id && left.token_start == right.token_start {
641                    continue;
642                }
643                let lh = &prepared[left.source_id].hashes;
644                let rh = &prepared[right.source_id].hashes;
645                let la = left.token_start;
646                let ra = right.token_start;
647                if la + min_tokens > lh.len() || ra + min_tokens > rh.len() {
648                    continue;
649                }
650                if lh[la..la + min_tokens] != rh[ra..ra + min_tokens] {
651                    continue;
652                }
653                let (sa, ta, sb, tb) =
654                    if (left.source_id, left.token_start) <= (right.source_id, right.token_start) {
655                        (
656                            left.source_id,
657                            left.token_start,
658                            right.source_id,
659                            right.token_start,
660                        )
661                    } else {
662                        (
663                            right.source_id,
664                            right.token_start,
665                            left.source_id,
666                            left.token_start,
667                        )
668                    };
669                candidates.push(Candidate {
670                    source_a: sa,
671                    source_b: sb,
672                    token_a: ta,
673                    token_b: tb,
674                });
675            }
676        }
677    }
678    if candidates.is_empty() {
679        return;
680    }
681    candidates.sort_unstable();
682    candidates.dedup();
683
684    // Build line-coverage from already-found primary clones.
685    let mut coverage = LineCoverage::from_clones(prepared, clones);
686    let mut open: Option<SecondaryOpen> = None;
687
688    for candidate in candidates {
689        if let Some(current) = open.as_mut()
690            && current.is_continuation(&candidate)
691        {
692            current.grow(&candidate, prepared, min_tokens);
693            continue;
694        }
695
696        flush_secondary_clone(
697            open.take(),
698            prepared,
699            min_lines,
700            filters,
701            clones,
702            &mut coverage,
703        );
704
705        // Create a new secondary clone candidate.
706        let start_a = candidate.token_a;
707        let end_a = start_a + min_tokens - 1;
708        let start_b = candidate.token_b;
709        let end_b = start_b + min_tokens - 1;
710
711        let frag_a = match make_fragment(
712            &prepared[candidate.source_a].id,
713            &prepared[candidate.source_a].spans,
714            start_a,
715            end_a,
716        ) {
717            Some(f) => f,
718            None => continue,
719        };
720        let frag_b = match make_fragment(
721            &prepared[candidate.source_b].id,
722            &prepared[candidate.source_b].spans,
723            start_b,
724            end_b,
725        ) {
726            Some(f) => f,
727            None => continue,
728        };
729
730        open = Some(SecondaryOpen {
731            clone: CpdClone {
732                format: prepared[candidate.source_a].format.clone(),
733                fragment_a: frag_a,
734                fragment_b: frag_b,
735                token_count: min_tokens as u32,
736                is_new: false,
737            },
738            source_a: candidate.source_a,
739            source_b: candidate.source_b,
740            last_token_start_a: candidate.token_a,
741            last_token_start_b: candidate.token_b,
742        });
743    }
744
745    flush_secondary_clone(
746        open.take(),
747        prepared,
748        min_lines,
749        filters,
750        clones,
751        &mut coverage,
752    );
753}
754
755fn flush_secondary_clone(
756    open: Option<SecondaryOpen>,
757    prepared: &[PreparedSource],
758    min_lines: usize,
759    filters: &PathFilters,
760    clones: &mut Vec<CpdClone>,
761    coverage: &mut LineCoverage,
762) {
763    let Some(oc) = open else {
764        return;
765    };
766
767    let range_a = fragment_line_range(&oc.clone.fragment_a);
768    let range_b = fragment_line_range(&oc.clone.fragment_b);
769
770    // Path filters: drop clone pairs by fragment location (skip_local, skip_isolated).
771    if filters.should_skip(&prepared[oc.source_a].id, &prepared[oc.source_b].id) {
772        return;
773    }
774
775    // min_lines filter: only check fragment A, mirroring jscpd's LinesLengthCloneValidator.
776    if min_lines > 0 {
777        let lines = oc.clone.fragment_a.end.line as usize - oc.clone.fragment_a.start.line as usize;
778        if lines < min_lines {
779            return;
780        }
781    }
782
783    // Line-coverage filter: skip secondary clones that don't extend existing coverage
784    // on either side.  This prevents the report from filling up with dozens of
785    // overlapping sub-clones of the same region.
786    if !coverage.extends(oc.source_a, range_a) || !coverage.extends(oc.source_b, range_b) {
787        return;
788    }
789
790    let before = clones.len();
791    clones.push(oc.clone);
792
793    // Insert coverage for newly added clone.
794    if clones.len() > before {
795        coverage.insert(oc.source_a, range_a);
796        coverage.insert(oc.source_b, range_b);
797    }
798}
799
800fn fragment_line_range(fragment: &Fragment) -> (usize, usize) {
801    let start = fragment.start.line as usize;
802    let end = fragment.end.line as usize;
803    (start.min(end), start.max(end))
804}
805
806// ---------------------------------------------------------------------------
807// Line coverage tracking for secondary clones
808// ---------------------------------------------------------------------------
809
810struct LineCoverage {
811    ranges_by_source: Vec<Vec<(usize, usize)>>,
812}
813
814impl LineCoverage {
815    fn from_clones(prepared: &[PreparedSource], clones: &[CpdClone]) -> Self {
816        let mut source_lookup: FxHashMap<&str, usize> = FxHashMap::default();
817        for (idx, source) in prepared.iter().enumerate() {
818            source_lookup.insert(source.id.as_str(), idx);
819        }
820        let mut coverage = Self {
821            ranges_by_source: vec![Vec::new(); prepared.len()],
822        };
823        for clone in clones {
824            if let Some(idx) = source_lookup.get(clone.fragment_a.source_id.as_str()) {
825                coverage.insert(*idx, fragment_line_range(&clone.fragment_a));
826            }
827            if let Some(idx) = source_lookup.get(clone.fragment_b.source_id.as_str()) {
828                coverage.insert(*idx, fragment_line_range(&clone.fragment_b));
829            }
830        }
831        coverage
832    }
833
834    fn extends(&self, source_idx: usize, range: (usize, usize)) -> bool {
835        // ponytail: walk existing intervals in order, advancing the low watermark
836        // past anything already covered. We extend unless the candidate is
837        // already fully covered by an existing interval chain.
838        let Some(intervals) = self.ranges_by_source.get(source_idx) else {
839            return true;
840        };
841        let mut cursor = range.0;
842        for &(start, end) in intervals {
843            if end < cursor {
844                continue;
845            }
846            if start > cursor {
847                return true;
848            }
849            cursor = cursor.max(end.saturating_add(1));
850            if cursor > range.1 {
851                return false;
852            }
853        }
854        cursor <= range.1
855    }
856
857    fn insert(&mut self, source_idx: usize, range: (usize, usize)) {
858        // ponytail: merge-into-sorted approach. Keep the per-source vector
859        // sorted and merged so `extends` can scan it in one pass; we rebuild
860        // it by folding the new range into the existing merged intervals
861        // rather than re-sorting the whole list every insert.
862        let Some(intervals) = self.ranges_by_source.get_mut(source_idx) else {
863            return;
864        };
865        let mut folded = Vec::with_capacity(intervals.len() + 1);
866        let mut pending = Some(range);
867        for &(start, end) in intervals.iter() {
868            let p = match pending.take() {
869                None => {
870                    folded.push((start, end));
871                    continue;
872                }
873                Some(p) => p,
874            };
875            // p is fully before this interval — emit p, then this interval.
876            if p.1.saturating_add(1) < start {
877                folded.push(p);
878                folded.push((start, end));
879            }
880            // this interval is fully before p — emit it, keep p pending.
881            else if end.saturating_add(1) < p.0 {
882                folded.push((start, end));
883                pending = Some(p);
884            }
885            // overlapping or adjacent — merge into p, keep pending.
886            else {
887                pending = Some((p.0.min(start), p.1.max(end)));
888            }
889        }
890        if let Some(p) = pending {
891            folded.push(p);
892        }
893        *intervals = folded;
894    }
895}
896
897// ---------------------------------------------------------------------------
898// Tests
899// ---------------------------------------------------------------------------
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904    use crate::models::{Location, Token, TokenKind};
905
906    fn loc(line: u32, col: u32, offset: u32) -> Location {
907        Location {
908            line,
909            column: col,
910            offset,
911        }
912    }
913
914    fn make_token(kind: TokenKind, value: &str, line: u32, col: u32, offset: u32) -> Token {
915        let end_col = col + value.len() as u32;
916        let end_off = offset + value.len() as u32;
917        Token {
918            kind,
919            value: value.to_string(),
920            start: loc(line, col, offset),
921            end: loc(line, end_col, end_off),
922        }
923    }
924
925    fn make_file(id: &str, format: &str, tokens: Vec<Token>) -> SourceFile {
926        SourceFile {
927            bytes: 0,
928            id: id.to_string(),
929            format: format.to_string(),
930            tokens,
931        }
932    }
933
934    fn js_tokens_ab() -> Vec<Token> {
935        vec![
936            make_token(TokenKind::Keyword, "function", 1, 0, 0),
937            make_token(TokenKind::Other, "hello", 1, 9, 9),
938            make_token(TokenKind::Operator, "(", 1, 14, 14),
939            make_token(TokenKind::Operator, ")", 1, 15, 15),
940            make_token(TokenKind::Operator, "{", 1, 16, 16),
941            make_token(TokenKind::Keyword, "return", 2, 0, 18),
942            make_token(TokenKind::Literal, "42", 2, 7, 25),
943            make_token(TokenKind::Operator, ";", 2, 9, 27),
944            make_token(TokenKind::Operator, "}", 3, 0, 29),
945        ]
946    }
947
948    #[test]
949    fn empty_input_returns_empty() {
950        let result = detect(&[], 10);
951        assert!(result.is_empty());
952    }
953
954    fn pair_with_js_tokens(min_tokens: usize) -> Vec<CpdClone> {
955        let tokens = js_tokens_ab();
956        let file_a = make_file("a.js", "javascript", tokens.clone());
957        let file_b = make_file("b.js", "javascript", tokens);
958        detect(&[file_a, file_b], min_tokens)
959    }
960
961    #[test]
962    fn identical_files_detected_as_clone() {
963        assert!(
964            !pair_with_js_tokens(5).is_empty(),
965            "identical files must produce at least one clone"
966        );
967    }
968
969    #[test]
970    fn min_tokens_threshold_respected() {
971        assert!(
972            pair_with_js_tokens(100).is_empty(),
973            "no clones when min_tokens exceeds file length"
974        );
975    }
976
977    #[test]
978    fn deduplication_ab_ba_collapse() {
979        assert_eq!(
980            pair_with_js_tokens(5).len(),
981            1,
982            "symmetric pairs must collapse to 1"
983        );
984    }
985
986    #[test]
987    fn different_formats_not_cross_detected() {
988        let tokens = js_tokens_ab();
989        let file_js = make_file("a.js", "javascript", tokens.clone());
990        let file_py = make_file("a.py", "python", tokens);
991        let clones = detect(&[file_js, file_py], 5);
992        assert!(
993            clones.is_empty(),
994            "tokens from different formats must not match"
995        );
996    }
997
998    #[test]
999    fn cross_format_group_detected() {
1000        // Inverse of different_formats_not_cross_detected: when two formats
1001        // are pooled into ONE prepared group (--cross-formats), identical
1002        // token streams match across formats.
1003        let to_prepared = |id: &str, format: &str| {
1004            let tokens = js_tokens_ab();
1005            let mut hashes = Vec::new();
1006            let mut spans = Vec::new();
1007            for t in &tokens {
1008                hashes.push(token_hash(t.kind.discriminant(), &t.value));
1009                spans.push((t.start.clone(), t.end.clone()));
1010            }
1011            PreparedSource {
1012                id: id.to_string(),
1013                format: format.to_string(),
1014                hashes,
1015                spans,
1016            }
1017        };
1018        let group = vec![
1019            to_prepared("a.js", "javascript"),
1020            to_prepared("a.ts", "typescript"),
1021        ];
1022        let clones = detect_prepared(vec![group], 5, 0, &PathFilters::default());
1023        assert_eq!(
1024            clones.len(),
1025            1,
1026            "identical token streams in one pool must match across formats"
1027        );
1028    }
1029
1030    #[test]
1031    fn identical_files_maximal_clone() {
1032        // With the open_clone state machine, a single maximal clone is emitted
1033        // instead of multiple sliding-window sub-clones.
1034        let tokens = js_tokens_ab();
1035        let file_a = make_file("a.js", "javascript", tokens.clone());
1036        let file_b = make_file("b.js", "javascript", tokens);
1037        let clones = detect(&[file_a, file_b], 5);
1038        assert_eq!(
1039            clones.len(),
1040            1,
1041            "open_clone SM must produce one maximal clone"
1042        );
1043        assert_eq!(
1044            clones[0].token_count, 9,
1045            "maximal clone must cover all 9 tokens"
1046        );
1047    }
1048
1049    #[test]
1050    fn three_identical_files_secondary_pass_adds_missing_pair() {
1051        let tokens = js_tokens_ab();
1052        let file_a = make_file("a.js", "javascript", tokens.clone());
1053        let file_b = make_file("b.js", "javascript", tokens.clone());
1054        let file_c = make_file("c.js", "javascript", tokens);
1055        let clones = detect(&[file_a, file_b, file_c], 5);
1056        assert!(
1057            clones.len() >= 2,
1058            "three identical files must yield at least 2 clone pairs, got {}",
1059            clones.len()
1060        );
1061    }
1062
1063    #[test]
1064    fn clones_sorted_by_source_and_line() {
1065        let tokens = js_tokens_ab();
1066        let file_a = make_file("a.js", "javascript", tokens.clone());
1067        let file_b = make_file("b.js", "javascript", tokens);
1068        let clones = detect(&[file_a, file_b], 5);
1069        for i in 1..clones.len() {
1070            let prev = &clones[i - 1];
1071            let curr = &clones[i];
1072            assert!(
1073                (
1074                    &prev.fragment_a.source_id,
1075                    prev.fragment_a.start.line,
1076                    &prev.fragment_b.source_id,
1077                    prev.fragment_b.start.line,
1078                ) <= (
1079                    &curr.fragment_a.source_id,
1080                    curr.fragment_a.start.line,
1081                    &curr.fragment_b.source_id,
1082                    curr.fragment_b.start.line,
1083                ),
1084                "clones must be sorted"
1085            );
1086        }
1087    }
1088
1089    fn isolated(groups: &[&[&str]]) -> Vec<Vec<PathBuf>> {
1090        groups
1091            .iter()
1092            .map(|g| g.iter().map(PathBuf::from).collect())
1093            .collect()
1094    }
1095
1096    #[test]
1097    fn skip_isolated_drops_pairs_across_group_folders() {
1098        let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1099        assert!(should_skip_isolated(
1100            "/repo/packages/a/src/x.js",
1101            "/repo/packages/b/src/y.js",
1102            &groups
1103        ));
1104    }
1105
1106    #[test]
1107    fn skip_isolated_keeps_pairs_inside_one_folder() {
1108        let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1109        assert!(!should_skip_isolated(
1110            "/repo/packages/a/src/x.js",
1111            "/repo/packages/a/lib/y.js",
1112            &groups
1113        ));
1114    }
1115
1116    #[test]
1117    fn skip_isolated_keeps_pairs_with_one_file_outside_group() {
1118        let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1119        assert!(!should_skip_isolated(
1120            "/repo/packages/a/src/x.js",
1121            "/repo/globals/y.js",
1122            &groups
1123        ));
1124        assert!(!should_skip_isolated(
1125            "/repo/globals/x.js",
1126            "/repo/infra/y.js",
1127            &groups
1128        ));
1129    }
1130
1131    #[test]
1132    fn skip_isolated_folders_in_different_groups_do_not_isolate() {
1133        let groups = isolated(&[
1134            &["/repo/packages/a", "/repo/packages/b"],
1135            &["/repo/libs/a", "/repo/libs/b"],
1136        ]);
1137        assert!(!should_skip_isolated(
1138            "/repo/packages/a/x.js",
1139            "/repo/libs/b/y.js",
1140            &groups
1141        ));
1142        assert!(should_skip_isolated(
1143            "/repo/libs/a/x.js",
1144            "/repo/libs/b/y.js",
1145            &groups
1146        ));
1147    }
1148
1149    #[test]
1150    fn path_filters_combine_skip_local_and_skip_isolated() {
1151        let scan_roots = vec![PathBuf::from("/repo/shared")];
1152        let groups = isolated(&[&["/repo/packages/a", "/repo/packages/b"]]);
1153        let filters = PathFilters {
1154            skip_local: true,
1155            scan_roots: &scan_roots,
1156            isolated_groups: &groups,
1157        };
1158        assert!(filters.should_skip("/repo/shared/x.js", "/repo/shared/y.js"));
1159        assert!(filters.should_skip("/repo/packages/a/x.js", "/repo/packages/b/y.js"));
1160        assert!(!filters.should_skip("/repo/shared/x.js", "/repo/packages/a/y.js"));
1161    }
1162}