Skip to main content

fallow_engine/
cross_reference.rs

1//! Cross-reference helpers exposed through the engine boundary.
2
3use rustc_hash::FxHashSet;
4use serde::Serialize;
5
6use crate::duplicates::{CloneInstance, DuplicationReport};
7use crate::results::AnalysisResults;
8
9/// A combined finding where a clone instance overlaps with a dead-code issue.
10#[derive(Debug, Clone, Serialize)]
11pub struct CombinedFinding {
12    /// The clone instance that is also unused.
13    pub clone_instance: CloneInstance,
14    /// What kind of dead code overlaps with this clone.
15    pub dead_code_kind: DeadCodeKind,
16    /// Clone group index for associating with the parent group.
17    pub group_index: usize,
18}
19
20impl From<fallow_core::cross_reference::CombinedFinding> for CombinedFinding {
21    fn from(finding: fallow_core::cross_reference::CombinedFinding) -> Self {
22        Self {
23            clone_instance: finding.clone_instance,
24            dead_code_kind: finding.dead_code_kind.into(),
25            group_index: finding.group_index,
26        }
27    }
28}
29
30/// The type of dead code that overlaps with a clone instance.
31#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
32pub enum DeadCodeKind {
33    /// The entire file containing the clone is unused.
34    UnusedFile,
35    /// A specific unused export overlaps with the clone's line range.
36    UnusedExport { export_name: String },
37    /// A specific unused type overlaps with the clone's line range.
38    UnusedType { type_name: String },
39}
40
41impl From<fallow_core::cross_reference::DeadCodeKind> for DeadCodeKind {
42    fn from(kind: fallow_core::cross_reference::DeadCodeKind) -> Self {
43        match kind {
44            fallow_core::cross_reference::DeadCodeKind::UnusedFile => Self::UnusedFile,
45            fallow_core::cross_reference::DeadCodeKind::UnusedExport { export_name } => {
46                Self::UnusedExport { export_name }
47            }
48            fallow_core::cross_reference::DeadCodeKind::UnusedType { type_name } => {
49                Self::UnusedType { type_name }
50            }
51        }
52    }
53}
54
55/// Result of cross-referencing duplication with dead-code analysis.
56#[derive(Debug, Clone, Serialize)]
57pub struct CrossReferenceResult {
58    /// Clone instances that are also dead code.
59    pub combined_findings: Vec<CombinedFinding>,
60    /// Number of clone instances in unused files.
61    pub clones_in_unused_files: usize,
62    /// Number of clone instances overlapping unused exports.
63    pub clones_with_unused_exports: usize,
64}
65
66impl CrossReferenceResult {
67    /// Total number of combined findings.
68    #[must_use]
69    pub const fn total(&self) -> usize {
70        self.combined_findings.len()
71    }
72
73    /// Whether any combined findings exist.
74    #[must_use]
75    pub const fn has_findings(&self) -> bool {
76        !self.combined_findings.is_empty()
77    }
78
79    /// Get clone groups that have at least one combined finding.
80    #[must_use]
81    pub fn affected_group_indices(&self) -> FxHashSet<usize> {
82        self.combined_findings
83            .iter()
84            .map(|finding| finding.group_index)
85            .collect()
86    }
87}
88
89impl From<fallow_core::cross_reference::CrossReferenceResult> for CrossReferenceResult {
90    fn from(result: fallow_core::cross_reference::CrossReferenceResult) -> Self {
91        Self {
92            combined_findings: result
93                .combined_findings
94                .into_iter()
95                .map(CombinedFinding::from)
96                .collect(),
97            clones_in_unused_files: result.clones_in_unused_files,
98            clones_with_unused_exports: result.clones_with_unused_exports,
99        }
100    }
101}
102
103/// Cross-reference duplication findings with dead-code analysis results.
104#[must_use]
105pub fn cross_reference(
106    duplication: &DuplicationReport,
107    dead_code: &AnalysisResults,
108) -> CrossReferenceResult {
109    fallow_core::cross_reference::cross_reference(duplication, dead_code).into()
110}
111
112#[cfg(test)]
113mod tests {
114    use std::path::PathBuf;
115
116    use super::*;
117
118    fn clone_instance(file: &str, start_line: usize, end_line: usize) -> CloneInstance {
119        CloneInstance {
120            file: PathBuf::from(file),
121            start_line,
122            end_line,
123            start_col: 0,
124            end_col: 0,
125            fragment: String::new(),
126        }
127    }
128
129    #[test]
130    fn cross_reference_result_methods_use_engine_owned_findings() {
131        let result = CrossReferenceResult {
132            combined_findings: vec![
133                CombinedFinding {
134                    clone_instance: clone_instance("src/a.ts", 1, 3),
135                    dead_code_kind: DeadCodeKind::UnusedFile,
136                    group_index: 2,
137                },
138                CombinedFinding {
139                    clone_instance: clone_instance("src/b.ts", 4, 8),
140                    dead_code_kind: DeadCodeKind::UnusedExport {
141                        export_name: "unused".to_string(),
142                    },
143                    group_index: 4,
144                },
145            ],
146            clones_in_unused_files: 1,
147            clones_with_unused_exports: 1,
148        };
149
150        assert_eq!(result.total(), 2);
151        assert!(result.has_findings());
152        assert!(result.affected_group_indices().contains(&2));
153        assert!(result.affected_group_indices().contains(&4));
154    }
155
156    #[test]
157    fn cross_reference_result_converts_from_core_without_leaking_type() {
158        let result =
159            CrossReferenceResult::from(fallow_core::cross_reference::CrossReferenceResult {
160                combined_findings: vec![fallow_core::cross_reference::CombinedFinding {
161                    clone_instance: clone_instance("src/a.ts", 1, 3),
162                    dead_code_kind: fallow_core::cross_reference::DeadCodeKind::UnusedType {
163                        type_name: "UnusedType".to_string(),
164                    },
165                    group_index: 7,
166                }],
167                clones_in_unused_files: 0,
168                clones_with_unused_exports: 1,
169            });
170
171        assert_eq!(result.total(), 1);
172        assert_eq!(result.clones_with_unused_exports, 1);
173        assert!(matches!(
174            result.combined_findings[0].dead_code_kind,
175            DeadCodeKind::UnusedType { ref type_name } if type_name == "UnusedType"
176        ));
177    }
178}