1use std::path::PathBuf;
4
5use rustc_hash::FxHashSet;
6use serde::Serialize;
7
8use crate::duplicates::{CloneInstance, DuplicationReport};
9use crate::results::AnalysisResults;
10
11#[derive(Debug, Clone, Serialize)]
13pub struct CombinedFinding {
14 pub clone_instance: CloneInstance,
16 pub dead_code_kind: DeadCodeKind,
18 pub group_index: usize,
20}
21
22#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
24pub enum DeadCodeKind {
25 UnusedFile,
27 UnusedExport {
29 export_name: String,
31 },
32 UnusedType {
34 type_name: String,
36 },
37}
38
39#[derive(Debug, Clone, Serialize)]
41pub struct CrossReferenceResult {
42 pub combined_findings: Vec<CombinedFinding>,
44 pub clones_in_unused_files: usize,
46 pub clones_with_unused_exports: usize,
48}
49
50impl CrossReferenceResult {
51 #[must_use]
53 pub const fn total(&self) -> usize {
54 self.combined_findings.len()
55 }
56
57 #[must_use]
59 pub const fn has_findings(&self) -> bool {
60 !self.combined_findings.is_empty()
61 }
62
63 #[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#[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 }));
258 dead_code
259 .unused_types
260 .push(UnusedTypeFinding::with_actions(UnusedExport {
261 path: PathBuf::from("src/b.ts"),
262 export_name: "DeadType".to_string(),
263 line: 35,
264 col: 0,
265 span_start: 0,
266 is_re_export: false,
267 is_type_only: true,
268 }));
269
270 let result = cross_reference(&duplication, &dead_code);
271
272 assert_eq!(result.clones_in_unused_files, 0);
273 assert_eq!(result.clones_with_unused_exports, 2);
274 assert!(matches!(
275 result.combined_findings[0].dead_code_kind,
276 DeadCodeKind::UnusedExport { ref export_name } if export_name == "deadValue"
277 ));
278 assert!(matches!(
279 result.combined_findings[1].dead_code_kind,
280 DeadCodeKind::UnusedType { ref type_name } if type_name == "DeadType"
281 ));
282 }
283}