Skip to main content

fallow_engine/
cross_reference.rs

1//! Cross-reference helpers exposed through the engine boundary.
2
3use std::path::PathBuf;
4
5use rustc_hash::FxHashSet;
6use serde::Serialize;
7
8use crate::duplicates::{CloneInstance, DuplicationReport};
9use crate::results::AnalysisResults;
10
11/// A combined finding where a clone instance overlaps with a dead-code issue.
12#[derive(Debug, Clone, Serialize)]
13pub struct CombinedFinding {
14    /// The clone instance that is also unused.
15    pub clone_instance: CloneInstance,
16    /// What kind of dead code overlaps with this clone.
17    pub dead_code_kind: DeadCodeKind,
18    /// Clone group index for associating with the parent group.
19    pub group_index: usize,
20}
21
22/// The type of dead code that overlaps with a clone instance.
23#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
24pub enum DeadCodeKind {
25    /// The entire file containing the clone is unused.
26    UnusedFile,
27    /// A specific unused export overlaps with the clone's line range.
28    UnusedExport { export_name: String },
29    /// A specific unused type overlaps with the clone's line range.
30    UnusedType { type_name: String },
31}
32
33/// Result of cross-referencing duplication with dead-code analysis.
34#[derive(Debug, Clone, Serialize)]
35pub struct CrossReferenceResult {
36    /// Clone instances that are also dead code.
37    pub combined_findings: Vec<CombinedFinding>,
38    /// Number of clone instances in unused files.
39    pub clones_in_unused_files: usize,
40    /// Number of clone instances overlapping unused exports.
41    pub clones_with_unused_exports: usize,
42}
43
44impl CrossReferenceResult {
45    /// Total number of combined findings.
46    #[must_use]
47    pub const fn total(&self) -> usize {
48        self.combined_findings.len()
49    }
50
51    /// Whether any combined findings exist.
52    #[must_use]
53    pub const fn has_findings(&self) -> bool {
54        !self.combined_findings.is_empty()
55    }
56
57    /// Get clone groups that have at least one combined finding.
58    #[must_use]
59    pub fn affected_group_indices(&self) -> FxHashSet<usize> {
60        self.combined_findings
61            .iter()
62            .map(|finding| finding.group_index)
63            .collect()
64    }
65}
66
67/// Cross-reference duplication findings with dead-code analysis results.
68#[must_use]
69pub fn cross_reference(
70    duplication: &DuplicationReport,
71    dead_code: &AnalysisResults,
72) -> CrossReferenceResult {
73    let unused_files: FxHashSet<&PathBuf> = dead_code
74        .unused_files
75        .iter()
76        .map(|finding| &finding.file.path)
77        .collect();
78
79    let mut combined_findings = Vec::new();
80    let mut clones_in_unused_files = 0usize;
81    let mut clones_with_unused_exports = 0usize;
82
83    for (group_index, group) in duplication.clone_groups.iter().enumerate() {
84        for instance in &group.instances {
85            if unused_files.contains(&instance.file) {
86                combined_findings.push(CombinedFinding {
87                    clone_instance: instance.clone(),
88                    dead_code_kind: DeadCodeKind::UnusedFile,
89                    group_index,
90                });
91                clones_in_unused_files += 1;
92                continue;
93            }
94
95            if let Some(finding) = find_overlapping_unused_export(instance, group_index, dead_code)
96            {
97                clones_with_unused_exports += 1;
98                combined_findings.push(finding);
99            }
100        }
101    }
102
103    CrossReferenceResult {
104        combined_findings,
105        clones_in_unused_files,
106        clones_with_unused_exports,
107    }
108}
109
110fn find_overlapping_unused_export(
111    instance: &CloneInstance,
112    group_index: usize,
113    dead_code: &AnalysisResults,
114) -> Option<CombinedFinding> {
115    for export in &dead_code.unused_exports {
116        if export.export.path == instance.file
117            && (export.export.line as usize) >= instance.start_line
118            && (export.export.line as usize) <= instance.end_line
119        {
120            return Some(CombinedFinding {
121                clone_instance: instance.clone(),
122                dead_code_kind: DeadCodeKind::UnusedExport {
123                    export_name: export.export.export_name.clone(),
124                },
125                group_index,
126            });
127        }
128    }
129
130    for type_export in &dead_code.unused_types {
131        if type_export.export.path == instance.file
132            && (type_export.export.line as usize) >= instance.start_line
133            && (type_export.export.line as usize) <= instance.end_line
134        {
135            return Some(CombinedFinding {
136                clone_instance: instance.clone(),
137                dead_code_kind: DeadCodeKind::UnusedType {
138                    type_name: type_export.export.export_name.clone(),
139                },
140                group_index,
141            });
142        }
143    }
144
145    None
146}
147
148#[cfg(test)]
149mod tests {
150    use std::path::PathBuf;
151
152    use super::*;
153    use crate::duplicates::{CloneGroup, DuplicationStats};
154    use fallow_types::{
155        output_dead_code::{UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding},
156        results::{UnusedExport, UnusedFile},
157    };
158
159    fn clone_instance(file: &str, start_line: usize, end_line: usize) -> CloneInstance {
160        CloneInstance {
161            file: PathBuf::from(file),
162            start_line,
163            end_line,
164            start_col: 0,
165            end_col: 0,
166            fragment: String::new(),
167        }
168    }
169
170    fn duplicate_report(instances: Vec<CloneInstance>) -> DuplicationReport {
171        DuplicationReport {
172            clone_groups: vec![CloneGroup {
173                instances,
174                token_count: 50,
175                line_count: 10,
176            }],
177            clone_families: Vec::new(),
178            mirrored_directories: Vec::new(),
179            stats: DuplicationStats::default(),
180        }
181    }
182
183    #[test]
184    fn cross_reference_result_methods_use_engine_owned_findings() {
185        let result = CrossReferenceResult {
186            combined_findings: vec![
187                CombinedFinding {
188                    clone_instance: clone_instance("src/a.ts", 1, 3),
189                    dead_code_kind: DeadCodeKind::UnusedFile,
190                    group_index: 2,
191                },
192                CombinedFinding {
193                    clone_instance: clone_instance("src/b.ts", 4, 8),
194                    dead_code_kind: DeadCodeKind::UnusedExport {
195                        export_name: "unused".to_string(),
196                    },
197                    group_index: 4,
198                },
199            ],
200            clones_in_unused_files: 1,
201            clones_with_unused_exports: 1,
202        };
203
204        assert_eq!(result.total(), 2);
205        assert!(result.has_findings());
206        assert!(result.affected_group_indices().contains(&2));
207        assert!(result.affected_group_indices().contains(&4));
208    }
209
210    #[test]
211    fn cross_reference_prioritizes_unused_file_overlap() {
212        let duplication = duplicate_report(vec![
213            clone_instance("src/a.ts", 1, 3),
214            clone_instance("src/b.ts", 4, 8),
215        ]);
216        let mut dead_code = AnalysisResults::default();
217        dead_code
218            .unused_files
219            .push(UnusedFileFinding::with_actions(UnusedFile {
220                path: PathBuf::from("src/a.ts"),
221            }));
222
223        let result = cross_reference(&duplication, &dead_code);
224
225        assert_eq!(result.clones_in_unused_files, 1);
226        assert_eq!(result.clones_with_unused_exports, 0);
227        assert!(matches!(
228            result.combined_findings[0].dead_code_kind,
229            DeadCodeKind::UnusedFile
230        ));
231    }
232
233    #[test]
234    fn cross_reference_detects_unused_export_and_type_overlap() {
235        let duplication = duplicate_report(vec![
236            clone_instance("src/a.ts", 10, 20),
237            clone_instance("src/b.ts", 30, 40),
238        ]);
239        let mut dead_code = AnalysisResults::default();
240        dead_code
241            .unused_exports
242            .push(UnusedExportFinding::with_actions(UnusedExport {
243                path: PathBuf::from("src/a.ts"),
244                export_name: "deadValue".to_string(),
245                line: 12,
246                col: 0,
247                span_start: 0,
248                is_re_export: false,
249                is_type_only: false,
250            }));
251        dead_code
252            .unused_types
253            .push(UnusedTypeFinding::with_actions(UnusedExport {
254                path: PathBuf::from("src/b.ts"),
255                export_name: "DeadType".to_string(),
256                line: 35,
257                col: 0,
258                span_start: 0,
259                is_re_export: false,
260                is_type_only: true,
261            }));
262
263        let result = cross_reference(&duplication, &dead_code);
264
265        assert_eq!(result.clones_in_unused_files, 0);
266        assert_eq!(result.clones_with_unused_exports, 2);
267        assert!(matches!(
268            result.combined_findings[0].dead_code_kind,
269            DeadCodeKind::UnusedExport { ref export_name } if export_name == "deadValue"
270        ));
271        assert!(matches!(
272            result.combined_findings[1].dead_code_kind,
273            DeadCodeKind::UnusedType { ref type_name } if type_name == "DeadType"
274        ));
275    }
276}