Skip to main content

fallow_core/
cross_reference.rs

1//! Cross-reference duplication findings with dead code analysis results.
2//!
3//! When code is both duplicated AND unused, it's a higher-priority finding:
4//! the duplicate can be safely removed without any refactoring. This module
5//! identifies such combined findings.
6
7use rustc_hash::FxHashSet;
8use std::path::PathBuf;
9
10use serde::Serialize;
11
12use crate::duplicates::types::{CloneInstance, DuplicationReport};
13use crate::results::AnalysisResults;
14
15/// A combined finding where a clone instance overlaps with a dead code issue.
16#[derive(Debug, Clone, Serialize)]
17pub struct CombinedFinding {
18    /// The clone instance that is also unused.
19    pub clone_instance: CloneInstance,
20    /// What kind of dead code overlaps with this clone.
21    pub dead_code_kind: DeadCodeKind,
22    /// Clone group index (for associating with the parent group).
23    pub group_index: usize,
24}
25
26/// The type of dead code that overlaps with a clone instance.
27#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
28pub enum DeadCodeKind {
29    /// The entire file containing the clone is unused.
30    UnusedFile,
31    /// A specific unused export overlaps with the clone's line range.
32    UnusedExport { export_name: String },
33    /// A specific unused type overlaps with the clone's line range.
34    UnusedType { type_name: String },
35}
36
37/// Result of cross-referencing duplication with dead code analysis.
38#[derive(Debug, Clone, Serialize)]
39pub struct CrossReferenceResult {
40    /// Clone instances that are also dead code (safe to delete).
41    pub combined_findings: Vec<CombinedFinding>,
42    /// Number of clone instances in unused files.
43    pub clones_in_unused_files: usize,
44    /// Number of clone instances overlapping unused exports.
45    pub clones_with_unused_exports: usize,
46}
47
48/// Cross-reference duplication findings with dead code analysis results.
49///
50/// For each clone instance, checks whether:
51/// 1. The file is entirely unused (in `unused_files`)
52/// 2. An unused export/type at the same line range overlaps
53///
54/// Returns combined findings sorted by priority (unused files first, then exports).
55pub fn cross_reference(
56    duplication: &DuplicationReport,
57    dead_code: &AnalysisResults,
58) -> CrossReferenceResult {
59    // Build lookup sets for fast checking
60    let unused_files: FxHashSet<&PathBuf> =
61        dead_code.unused_files.iter().map(|f| &f.path).collect();
62
63    let mut combined_findings = Vec::new();
64    let mut clones_in_unused_files = 0usize;
65    let mut clones_with_unused_exports = 0usize;
66
67    for (group_idx, group) in duplication.clone_groups.iter().enumerate() {
68        for instance in &group.instances {
69            // Check 1: Is the file entirely unused?
70            if unused_files.contains(&instance.file) {
71                combined_findings.push(CombinedFinding {
72                    clone_instance: instance.clone(),
73                    dead_code_kind: DeadCodeKind::UnusedFile,
74                    group_index: group_idx,
75                });
76                clones_in_unused_files += 1;
77                continue; // No need to check exports if entire file is unused
78            }
79
80            // Check 2: Does an unused export/type overlap with this clone's line range?
81            if let Some(finding) = find_overlapping_unused_export(instance, group_idx, dead_code) {
82                clones_with_unused_exports += 1;
83                combined_findings.push(finding);
84            }
85        }
86    }
87
88    CrossReferenceResult {
89        combined_findings,
90        clones_in_unused_files,
91        clones_with_unused_exports,
92    }
93}
94
95/// Check if any unused export/type overlaps with the clone instance's line range.
96fn find_overlapping_unused_export(
97    instance: &CloneInstance,
98    group_index: usize,
99    dead_code: &AnalysisResults,
100) -> Option<CombinedFinding> {
101    // Check unused exports
102    for export in &dead_code.unused_exports {
103        if export.path == instance.file
104            && (export.line as usize) >= instance.start_line
105            && (export.line as usize) <= instance.end_line
106        {
107            return Some(CombinedFinding {
108                clone_instance: instance.clone(),
109                dead_code_kind: DeadCodeKind::UnusedExport {
110                    export_name: export.export_name.clone(),
111                },
112                group_index,
113            });
114        }
115    }
116
117    // Check unused types
118    for type_export in &dead_code.unused_types {
119        if type_export.path == instance.file
120            && (type_export.line as usize) >= instance.start_line
121            && (type_export.line as usize) <= instance.end_line
122        {
123            return Some(CombinedFinding {
124                clone_instance: instance.clone(),
125                dead_code_kind: DeadCodeKind::UnusedType {
126                    type_name: type_export.export_name.clone(),
127                },
128                group_index,
129            });
130        }
131    }
132
133    None
134}
135
136/// Summary statistics for cross-referenced findings.
137impl CrossReferenceResult {
138    /// Total number of combined findings.
139    pub const fn total(&self) -> usize {
140        self.combined_findings.len()
141    }
142
143    /// Whether any combined findings exist.
144    pub const fn has_findings(&self) -> bool {
145        !self.combined_findings.is_empty()
146    }
147
148    /// Get clone groups that have at least one combined finding, with their indices.
149    pub fn affected_group_indices(&self) -> FxHashSet<usize> {
150        self.combined_findings
151            .iter()
152            .map(|f| f.group_index)
153            .collect()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::duplicates::CloneGroup;
161    use crate::results::{UnusedExport, UnusedFile};
162
163    fn make_instance(file: &str, start: usize, end: usize) -> CloneInstance {
164        CloneInstance {
165            file: PathBuf::from(file),
166            start_line: start,
167            end_line: end,
168            start_col: 0,
169            end_col: 0,
170            fragment: String::new(),
171        }
172    }
173
174    fn make_group(instances: Vec<CloneInstance>) -> CloneGroup {
175        CloneGroup {
176            instances,
177            token_count: 50,
178            line_count: 10,
179        }
180    }
181
182    #[test]
183    fn empty_inputs_produce_no_findings() {
184        let duplication = DuplicationReport {
185            clone_groups: vec![],
186            clone_families: vec![],
187            stats: crate::duplicates::types::DuplicationStats {
188                total_files: 0,
189                files_with_clones: 0,
190                total_lines: 0,
191                duplicated_lines: 0,
192                total_tokens: 0,
193                duplicated_tokens: 0,
194                clone_groups: 0,
195                clone_instances: 0,
196                duplication_percentage: 0.0,
197            },
198        };
199        let dead_code = AnalysisResults::default();
200
201        let result = cross_reference(&duplication, &dead_code);
202        assert!(!result.has_findings());
203        assert_eq!(result.total(), 0);
204    }
205
206    #[test]
207    fn detects_clone_in_unused_file() {
208        let duplication = DuplicationReport {
209            clone_groups: vec![make_group(vec![
210                make_instance("src/a.ts", 1, 10),
211                make_instance("src/b.ts", 1, 10),
212            ])],
213            clone_families: vec![],
214            stats: crate::duplicates::types::DuplicationStats {
215                total_files: 2,
216                files_with_clones: 2,
217                total_lines: 20,
218                duplicated_lines: 10,
219                total_tokens: 100,
220                duplicated_tokens: 50,
221                clone_groups: 1,
222                clone_instances: 2,
223                duplication_percentage: 50.0,
224            },
225        };
226        let mut dead_code = AnalysisResults::default();
227        dead_code.unused_files.push(UnusedFile {
228            path: PathBuf::from("src/a.ts"),
229        });
230
231        let result = cross_reference(&duplication, &dead_code);
232        assert!(result.has_findings());
233        assert_eq!(result.clones_in_unused_files, 1);
234        assert_eq!(
235            result.combined_findings[0].dead_code_kind,
236            DeadCodeKind::UnusedFile
237        );
238    }
239
240    #[test]
241    fn detects_clone_overlapping_unused_export() {
242        let duplication = DuplicationReport {
243            clone_groups: vec![make_group(vec![
244                make_instance("src/a.ts", 5, 15),
245                make_instance("src/b.ts", 5, 15),
246            ])],
247            clone_families: vec![],
248            stats: crate::duplicates::types::DuplicationStats {
249                total_files: 2,
250                files_with_clones: 2,
251                total_lines: 20,
252                duplicated_lines: 10,
253                total_tokens: 100,
254                duplicated_tokens: 50,
255                clone_groups: 1,
256                clone_instances: 2,
257                duplication_percentage: 50.0,
258            },
259        };
260        let mut dead_code = AnalysisResults::default();
261        dead_code.unused_exports.push(UnusedExport {
262            path: PathBuf::from("src/a.ts"),
263            export_name: "processData".to_string(),
264            is_type_only: false,
265            line: 5,
266            col: 0,
267            span_start: 0,
268            is_re_export: false,
269        });
270
271        let result = cross_reference(&duplication, &dead_code);
272        assert!(result.has_findings());
273        assert_eq!(result.clones_with_unused_exports, 1);
274        assert!(matches!(
275            &result.combined_findings[0].dead_code_kind,
276            DeadCodeKind::UnusedExport { export_name } if export_name == "processData"
277        ));
278    }
279
280    #[test]
281    fn no_findings_when_no_overlap() {
282        let duplication = DuplicationReport {
283            clone_groups: vec![make_group(vec![
284                make_instance("src/a.ts", 5, 15),
285                make_instance("src/b.ts", 5, 15),
286            ])],
287            clone_families: vec![],
288            stats: crate::duplicates::types::DuplicationStats {
289                total_files: 2,
290                files_with_clones: 2,
291                total_lines: 20,
292                duplicated_lines: 10,
293                total_tokens: 100,
294                duplicated_tokens: 50,
295                clone_groups: 1,
296                clone_instances: 2,
297                duplication_percentage: 50.0,
298            },
299        };
300        let mut dead_code = AnalysisResults::default();
301        // Unused export on a different line range
302        dead_code.unused_exports.push(UnusedExport {
303            path: PathBuf::from("src/a.ts"),
304            export_name: "other".to_string(),
305            is_type_only: false,
306            line: 20, // outside clone range 5-15
307            col: 0,
308            span_start: 0,
309            is_re_export: false,
310        });
311
312        let result = cross_reference(&duplication, &dead_code);
313        assert!(!result.has_findings());
314    }
315
316    #[test]
317    fn affected_group_indices() {
318        let duplication = DuplicationReport {
319            clone_groups: vec![
320                make_group(vec![
321                    make_instance("src/a.ts", 1, 10),
322                    make_instance("src/b.ts", 1, 10),
323                ]),
324                make_group(vec![
325                    make_instance("src/c.ts", 1, 10),
326                    make_instance("src/d.ts", 1, 10),
327                ]),
328            ],
329            clone_families: vec![],
330            stats: crate::duplicates::types::DuplicationStats {
331                total_files: 4,
332                files_with_clones: 4,
333                total_lines: 40,
334                duplicated_lines: 20,
335                total_tokens: 200,
336                duplicated_tokens: 100,
337                clone_groups: 2,
338                clone_instances: 4,
339                duplication_percentage: 50.0,
340            },
341        };
342        let mut dead_code = AnalysisResults::default();
343        dead_code.unused_files.push(UnusedFile {
344            path: PathBuf::from("src/c.ts"),
345        });
346
347        let result = cross_reference(&duplication, &dead_code);
348        let affected = result.affected_group_indices();
349        assert!(!affected.contains(&0)); // Group 0 not affected
350        assert!(affected.contains(&1)); // Group 1 has clone in unused file
351    }
352}