Skip to main content

cpd_core/
detect.rs

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