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