Skip to main content

fallow_engine/
duplicates.rs

1//! Duplication result types exposed through the engine boundary.
2
3use std::path::{Path, PathBuf};
4
5use fallow_config::DuplicatesConfig;
6use fallow_types::discover::DiscoveredFile;
7use rustc_hash::{FxHashMap, FxHashSet};
8
9use crate::results::DuplicationAnalysis;
10
11#[path = "duplication_detector/mod.rs"]
12mod detector;
13
14#[cfg(test)]
15pub(crate) use detector::token_types;
16pub(crate) use detector::types;
17
18/// Detector internals re-exported for the engine's own benches and
19/// integration tests; not part of the supported engine API surface.
20#[doc(hidden)]
21pub use detector::{detect, normalize, tokenize};
22
23/// Engine alias for [`fallow_types::duplicates::CloneGroup`].
24pub type CloneGroup = fallow_types::duplicates::CloneGroup;
25/// Engine alias for [`fallow_types::duplicates::CloneGroupKind`].
26pub type CloneGroupKind = fallow_types::duplicates::CloneGroupKind;
27/// Engine alias for [`fallow_types::duplicates::CloneInstance`].
28pub type CloneInstance = fallow_types::duplicates::CloneInstance;
29/// Engine alias for [`fallow_types::duplicates::DefaultIgnoreSkips`].
30pub type DefaultIgnoreSkips = fallow_types::duplicates::DefaultIgnoreSkips;
31/// Engine alias for [`fallow_types::duplicates::DuplicationReport`].
32pub type DuplicationReport = fallow_types::duplicates::DuplicationReport;
33/// Engine alias for [`fallow_types::duplicates::DuplicationStats`].
34pub type DuplicationStats = fallow_types::duplicates::DuplicationStats;
35/// Engine alias for [`fallow_types::duplicates::RefactoringKind`].
36pub type RefactoringKind = fallow_types::duplicates::RefactoringKind;
37/// Engine alias for [`fallow_types::duplicates::RefactoringSuggestion`].
38pub type RefactoringSuggestion = fallow_types::duplicates::RefactoringSuggestion;
39
40pub use detector::{
41    CloneFingerprintKey, CloneFingerprintSet, FINGERPRINT_PREFIX, clone_fingerprint,
42    dominant_identifier, group_refactoring_suggestion, is_unparsed_collision_key,
43};
44
45/// Refresh clone-family and mirrored-directory fields after clone groups change.
46pub fn refresh_clone_families(report: &mut DuplicationReport, root: &Path) {
47    report.clone_families = detector::families::group_into_families(&report.clone_groups, root);
48    report.mirrored_directories =
49        detector::families::detect_mirrored_directories(&report.clone_families, root);
50}
51
52/// Rebuild the fields that a scope filter invalidates: clone families,
53/// mirrored directories, statistics and the report order.
54///
55/// A scope filter (`--changed-since`, `--workspace`, a diff) narrows the corpus,
56/// so `stats` describes the narrowed corpus after this call. A presentation cap
57/// such as `--top` must not call this.
58pub fn refresh_scoped_report(report: &mut DuplicationReport, root: &Path) {
59    refresh_clone_families(report, root);
60    report.stats = recompute_stats(report);
61    report.sort();
62}
63
64/// The scope of one duplication run, as the surface resolved it.
65///
66/// Every field is optional. A field that is `None` does not narrow the run.
67#[derive(Debug, Clone, Copy)]
68pub struct DuplicationScope<'a> {
69    /// The resolved change scope: a global changed-file set or the
70    /// configured package baselines.
71    pub changes: Option<&'a crate::change_scope::ChangeScope>,
72    /// A unified diff. Finding paths resolve against the report root.
73    pub diff: Option<&'a fallow_output::DiffIndex>,
74    /// `--workspace`, `--changed-workspaces` and a positional path: the union
75    /// of these roots.
76    pub workspace_roots: Option<&'a [PathBuf]>,
77}
78
79/// Narrow a duplication report to the scope of the run.
80///
81/// The CLI, the programmatic API and the MCP typed path call this one function,
82/// so a scope narrows the same way on every surface. Each filter keeps a clone
83/// group when at least one instance is in scope, and keeps every instance of
84/// that group: a reviewer sees the full clone family. The filters run in this
85/// order: changed files, the diff, the workspace roots.
86pub fn apply_scope(report: &mut DuplicationReport, scope: &DuplicationScope<'_>, root: &Path) {
87    if let Some(changes) = scope.changes {
88        changes.retain_duplication(report, root);
89    }
90    if let Some(diff) = scope.diff {
91        crate::diff_scope::filter_duplication_by_diff(report, diff, root);
92    }
93    if let Some(roots) = scope.workspace_roots {
94        filter_to_workspaces(report, roots, root);
95    }
96}
97
98/// Keep only the clone groups with at least one instance under one of the
99/// workspace roots.
100///
101/// The full cross-workspace index is still built, so a group can hold an
102/// instance in the selected workspace and one in another workspace. The group
103/// stays whole: the documented rule is that a group is in scope when one of
104/// its instances is. Clone families, statistics and the order are rebuilt from
105/// the groups that stay.
106pub fn filter_to_workspaces(report: &mut DuplicationReport, roots: &[PathBuf], root: &Path) {
107    report.clone_groups.retain(|group| {
108        group
109            .instances
110            .iter()
111            .any(|instance| roots.iter().any(|scope| instance.file.starts_with(scope)))
112    });
113    refresh_scoped_report(report, root);
114}
115
116/// Keep only the `n` highest-ranked clone groups (`--top`).
117///
118/// `stats` keeps describing the corpus the run measured. Truncation is a
119/// presentation choice, so rewriting `clone_groups` or `clone_instances` from
120/// the truncated array would put two scopes in one object next to the
121/// untouched `files_with_clones` and `duplication_percentage`. Consumers read
122/// the shown and omitted split from `DuplicationReport::clone_groups_shown`
123/// and `clone_groups_omitted`.
124pub fn apply_top(report: &mut DuplicationReport, n: usize, root: &Path) {
125    report.sort();
126    report.clone_groups.truncate(n);
127    refresh_clone_families(report, root);
128    report.sort();
129}
130
131/// Recompute duplication statistics after clone groups have been filtered.
132///
133/// Uses per-file line deduplication, matching the detector's stats model, so
134/// overlapping clone instances do not inflate the duplicated line count.
135///
136/// `clone_families` is read from `report.clone_families`, so a scope filter
137/// must call [`refresh_clone_families`] before this. A presentation cap such
138/// as `--top` must not call this at all: it truncates the arrays while `stats`
139/// keeps describing the corpus the run measured.
140#[must_use]
141pub fn recompute_stats(report: &DuplicationReport) -> DuplicationStats {
142    let mut files_with_clones: FxHashSet<&Path> = FxHashSet::default();
143    let mut file_dup_lines: FxHashMap<&Path, FxHashSet<usize>> = FxHashMap::default();
144    let mut duplicated_tokens = 0usize;
145    let mut clone_instances = 0usize;
146
147    for group in &report.clone_groups {
148        for instance in &group.instances {
149            files_with_clones.insert(&instance.file);
150            clone_instances += 1;
151            let lines = file_dup_lines.entry(&instance.file).or_default();
152            for line in instance.start_line..=instance.end_line {
153                lines.insert(line);
154            }
155        }
156        duplicated_tokens += group.token_count * group.instances.len().saturating_sub(1);
157    }
158
159    let duplicated_lines: usize = file_dup_lines.values().map(FxHashSet::len).sum();
160
161    DuplicationStats {
162        total_files: report.stats.total_files,
163        files_with_clones: files_with_clones.len(),
164        total_lines: report.stats.total_lines,
165        duplicated_lines,
166        total_tokens: report.stats.total_tokens,
167        duplicated_tokens: duplicated_tokens.min(report.stats.total_tokens),
168        clone_groups: report.clone_groups.len(),
169        clone_families: report.clone_families.len(),
170        clone_instances,
171        duplication_percentage: if report.stats.total_lines > 0 {
172            (duplicated_lines as f64 / report.stats.total_lines as f64) * 100.0
173        } else {
174            0.0
175        },
176        clone_groups_below_min_occurrences: report.stats.clone_groups_below_min_occurrences,
177        clone_groups_ignored: report.stats.clone_groups_ignored,
178        near_candidates_skipped: report.stats.near_candidates_skipped,
179    }
180}
181
182/// Compare JS/TS sources for audit-safe reuse of the current analysis at base.
183///
184/// Token kinds retain identifiers and ordinary literal values. Template and
185/// regex token kinds omit content, so their raw source spans must also match.
186/// Whole-template comparison conservatively rejects formatting changes inside
187/// interpolation expressions. The boolean is forwarded as tokenizer `skip_imports`.
188#[must_use]
189pub fn source_token_kinds_equivalent(
190    path: &Path,
191    current: &str,
192    base: &str,
193    cross_language: bool,
194) -> bool {
195    let current_tokens = detector::tokenize::tokenize_file(path, current, cross_language);
196    let base_tokens = detector::tokenize::tokenize_file(path, base, cross_language);
197    if current_tokens.tokens.len() != base_tokens.tokens.len() {
198        return false;
199    }
200    current_tokens
201        .tokens
202        .iter()
203        .zip(&base_tokens.tokens)
204        .all(|(current_token, base_token)| {
205            if current_token.kind != base_token.kind {
206                return false;
207            }
208            if matches!(
209                current_token.kind,
210                detector::token_types::TokenKind::TemplateLiteral
211                    | detector::token_types::TokenKind::RegExpLiteral
212            ) {
213                return match (
214                    current.get(current_token.span.start as usize..current_token.span.end as usize),
215                    base.get(base_token.span.start as usize..base_token.span.end as usize),
216                ) {
217                    (Some(current_literal), Some(base_literal)) => current_literal == base_literal,
218                    _ => false,
219                };
220            }
221            true
222        })
223}
224
225/// Run duplication detection on a discovered file set.
226#[must_use]
227pub fn find_duplicates(
228    root: &Path,
229    files: &[DiscoveredFile],
230    config: &DuplicatesConfig,
231) -> DuplicationReport {
232    detector::find_duplicates(root, files, config)
233}
234
235/// Run duplication detection and include metadata about built-in ignored files.
236#[must_use]
237pub fn find_duplicates_with_defaults(
238    root: &Path,
239    files: &[DiscoveredFile],
240    config: &DuplicatesConfig,
241    cache_dir: Option<&Path>,
242) -> DuplicationAnalysis {
243    detector::detect_duplicates(root, files, config, None, cache_dir)
244}
245
246/// Run focused duplication detection and include metadata about built-in ignored files.
247#[must_use]
248pub fn find_duplicates_touching_files_with_defaults(
249    root: &Path,
250    files: &[DiscoveredFile],
251    config: &DuplicatesConfig,
252    changed_files: &[PathBuf],
253    cache_dir: Option<&Path>,
254) -> DuplicationAnalysis {
255    let changed_files = changed_files.iter().cloned().collect::<FxHashSet<_>>();
256    detector::detect_duplicates(root, files, config, Some(&changed_files), cache_dir)
257}
258
259#[cfg(test)]
260mod tests {
261    use std::path::PathBuf;
262
263    use super::*;
264
265    fn instance(file: &str, start_line: usize, end_line: usize) -> CloneInstance {
266        CloneInstance {
267            is_symlink: false,
268            file: PathBuf::from(file),
269            start_line,
270            end_line,
271            start_col: 0,
272            end_col: 0,
273            fragment: String::new(),
274        }
275    }
276
277    fn report(clone_groups: Vec<CloneGroup>) -> DuplicationReport {
278        DuplicationReport {
279            clone_groups,
280            clone_families: Vec::new(),
281            mirrored_directories: Vec::new(),
282            stats: DuplicationStats {
283                total_files: 3,
284                total_lines: 100,
285                total_tokens: 1_000,
286                clone_groups_below_min_occurrences: 4,
287                ..DuplicationStats::default()
288            },
289        }
290    }
291
292    #[test]
293    fn recompute_stats_deduplicates_overlapping_lines_per_file() {
294        let report = report(vec![
295            CloneGroup {
296                instances: vec![instance("src/a.ts", 1, 10), instance("src/b.ts", 20, 24)],
297                token_count: 30,
298                line_count: 10,
299                similarity: None,
300            },
301            CloneGroup {
302                instances: vec![instance("src/a.ts", 5, 12), instance("src/c.ts", 40, 44)],
303                token_count: 20,
304                line_count: 8,
305                similarity: None,
306            },
307        ]);
308
309        let stats = recompute_stats(&report);
310
311        assert_eq!(stats.total_files, 3);
312        assert_eq!(stats.files_with_clones, 3);
313        assert_eq!(stats.total_lines, 100);
314        assert_eq!(stats.duplicated_lines, 22);
315        assert_eq!(stats.total_tokens, 1_000);
316        assert_eq!(stats.duplicated_tokens, 50);
317        assert_eq!(stats.clone_groups, 2);
318        assert_eq!(stats.clone_instances, 4);
319        assert!((stats.duplication_percentage - 22.0).abs() < f64::EPSILON);
320        assert_eq!(stats.clone_groups_below_min_occurrences, 4);
321    }
322
323    #[test]
324    fn recompute_stats_handles_zero_total_lines() {
325        let mut report = report(vec![CloneGroup {
326            instances: vec![instance("src/a.ts", 1, 1)],
327            token_count: 5,
328            line_count: 1,
329            similarity: None,
330        }]);
331        report.stats.total_lines = 0;
332
333        let stats = recompute_stats(&report);
334
335        assert_eq!(stats.duplicated_lines, 1);
336        assert!(stats.duplication_percentage.abs() < f64::EPSILON);
337    }
338
339    #[test]
340    fn clone_fingerprint_set_delegates_without_leaking_core_type() {
341        let groups = vec![CloneGroup {
342            instances: vec![
343                CloneInstance {
344                    fragment: "const value = 1;".to_string(),
345                    ..instance("src/a.ts", 1, 1)
346                },
347                CloneInstance {
348                    fragment: "const value = 1;".to_string(),
349                    ..instance("src/b.ts", 2, 2)
350                },
351            ],
352            token_count: 5,
353            line_count: 1,
354            similarity: None,
355        }];
356        let fingerprints = CloneFingerprintSet::from_groups(&groups);
357        let fingerprint = fingerprints.fingerprint_for_group(&groups[0]);
358
359        assert!(fingerprint.starts_with(FINGERPRINT_PREFIX));
360        assert!(fingerprints.find_group(&groups, &fingerprint).is_some());
361    }
362}