Skip to main content

dry4rust/
analysis.rs

1// Copyright (c) 2026 Matjaz Domen Pecan
2// Copyright 2026 Umberto Gotti <umberto.gotti@umbertogotti.dev>
3// Licensed under the MIT License
4// SPDX-License-Identifier: MIT
5
6use std::collections::HashSet;
7use std::path::PathBuf;
8
9use crate::analyzer::LanguageAnalyzer;
10use crate::code_unit::CodeUnit;
11use crate::config::Config;
12use crate::error::Result;
13use crate::extractor::extract_sub_units;
14use crate::fingerprint::Fingerprint;
15use crate::grouper::compute_stats_with_sub;
16use crate::grouper::find_near_duplicates;
17use crate::grouper::group_exact_duplicates;
18use crate::grouper::{DuplicateGroup, DuplicationStats};
19use crate::node::NormalizedNode;
20use crate::suppression::baseline_filter::BaselineFilter;
21use crate::suppression::baseline_kind::BaselineKind;
22use crate::suppression::ignore_file::IgnoreFile;
23use std::fs;
24
25/// The result of a full analysis run.
26pub struct AnalysisResult {
27    pub stats: DuplicationStats,
28    pub exact_groups: Vec<DuplicateGroup>,
29    pub near_groups: Vec<DuplicateGroup>,
30    pub sub_exact_groups: Vec<DuplicateGroup>,
31    pub sub_near_groups: Vec<DuplicateGroup>,
32    pub warnings: Vec<String>,
33    /// All group fingerprints (exact + near) before ignore filtering.
34    /// Used by the cleanup command to identify stale ignore entries.
35    pub all_fingerprints: HashSet<Fingerprint>,
36}
37
38/// Run the full analysis pipeline using a language analyzer.
39///
40/// Reads each file, parses it via the analyzer, optionally filters test code,
41/// then delegates to [`analyze_units`] for grouping, similarity, and stats.
42pub fn analyze(
43    analyzer: &dyn LanguageAnalyzer,
44    files: &[PathBuf],
45    config: &Config,
46) -> Result<AnalysisResult> {
47    let analysis_config = config.analysis_config();
48    let mut units = Vec::new();
49    let mut warnings = Vec::new();
50
51    for path in files {
52        let source = match fs::read_to_string(path) {
53            Ok(s) => s,
54            Err(e) => {
55                warnings.push(format!("Failed to read {}: {}", path.display(), e));
56                continue;
57            }
58        };
59        match analyzer.parse_file(path, &source, &analysis_config) {
60            Ok(mut file_units) => {
61                if config.exclude_tests {
62                    file_units.retain(|u| !analyzer.is_test_code(u));
63                }
64                units.extend(file_units);
65            }
66            Err(e) => warnings.push(e.to_string()),
67        }
68    }
69
70    analyze_units(&units, warnings, config)
71}
72
73/// Run the analysis pipeline on pre-parsed code units.
74///
75/// The caller is responsible for scanning files and parsing them into `CodeUnit`s.
76/// This function handles grouping, similarity detection, ignore filtering, and stats.
77pub fn analyze_units(
78    units: &[CodeUnit],
79    warnings: Vec<String>,
80    config: &Config,
81) -> Result<AnalysisResult> {
82    // 1. Group exact duplicates
83    let exact_groups = group_exact_duplicates(units);
84
85    // 2. Find near-duplicates
86    let exact_fps: Vec<_> = exact_groups.iter().map(|g| g.fingerprint).collect();
87    let near_groups =
88        find_near_duplicates(units, config.similarity_threshold.as_fraction(), &exact_fps);
89
90    // 3. Sub-function duplicate detection (opt-in)
91    let (sub_exact_groups, sub_near_groups) = if config.sub_function {
92        // Extract sub-units from each code unit
93        let sub_units: Vec<CodeUnit> = units
94            .iter()
95            .flat_map(|unit| {
96                let sub_units = extract_sub_units(&unit.body, config.min_sub_nodes);
97                sub_units.into_iter().map(|su| CodeUnit {
98                    kind: su.kind,
99                    name: su.description,
100                    file: unit.file.clone(),
101                    line_start: unit.line_start,
102                    line_end: unit.line_end,
103                    signature: NormalizedNode::leaf(crate::node::NodeKind::Opaque),
104                    body: su.node.clone(),
105                    fingerprint: Fingerprint::from_node(&su.node),
106                    node_count: su.node_count,
107                    parent_name: Some(unit.name.clone()),
108                    is_test: unit.is_test,
109                })
110            })
111            .collect();
112
113        let sub_exact = group_exact_duplicates(&sub_units);
114        let sub_exact_fps: Vec<_> = sub_exact.iter().map(|g| g.fingerprint).collect();
115        let sub_near = find_near_duplicates(
116            &sub_units,
117            config.similarity_threshold.as_fraction(),
118            &sub_exact_fps,
119        );
120        (sub_exact, sub_near)
121    } else {
122        (Vec::new(), Vec::new())
123    };
124
125    // 4. Collect all fingerprints before filtering (for cleanup staleness check)
126    let all_fingerprints: HashSet<Fingerprint> = exact_groups
127        .iter()
128        .chain(near_groups.iter())
129        .chain(sub_exact_groups.iter())
130        .chain(sub_near_groups.iter())
131        .map(|g| g.fingerprint)
132        .collect();
133
134    // 5. Apply ignore filtering
135    let ignore_file = IgnoreFile::load(&config.root);
136    let exact_groups = ignore_file.retain_unsuppressed(exact_groups);
137    let near_groups = ignore_file.retain_unsuppressed(near_groups);
138    let sub_exact_groups = ignore_file.retain_unsuppressed(sub_exact_groups);
139    let sub_near_groups = ignore_file.retain_unsuppressed(sub_near_groups);
140
141    // 6. Set aside what the baseline already accounted for
142    let baseline = BaselineFilter::load(config)?;
143    let found =
144        exact_groups.len() + near_groups.len() + sub_exact_groups.len() + sub_near_groups.len();
145    let exact_groups = baseline.retain_new(BaselineKind::Exact, exact_groups);
146    let near_groups = baseline.retain_new(BaselineKind::Near, near_groups);
147    let sub_exact_groups = baseline.retain_new(BaselineKind::SubExact, sub_exact_groups);
148    let sub_near_groups = baseline.retain_new(BaselineKind::SubNear, sub_near_groups);
149    let reported =
150        exact_groups.len() + near_groups.len() + sub_exact_groups.len() + sub_near_groups.len();
151
152    // 7. Compute stats
153    let stats = compute_stats_with_sub(
154        units,
155        &exact_groups,
156        &near_groups,
157        &sub_exact_groups,
158        &sub_near_groups,
159    )
160    .with_baseline_suppressed(baseline.is_in_effect().then_some(found - reported));
161
162    Ok(AnalysisResult {
163        stats,
164        exact_groups,
165        near_groups,
166        sub_exact_groups,
167        sub_near_groups,
168        warnings,
169        all_fingerprints,
170    })
171}