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