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 { export_name: String },
29 UnusedType { type_name: String },
31}
32
33#[derive(Debug, Clone, Serialize)]
35pub struct CrossReferenceResult {
36 pub combined_findings: Vec<CombinedFinding>,
38 pub clones_in_unused_files: usize,
40 pub clones_with_unused_exports: usize,
42}
43
44impl CrossReferenceResult {
45 #[must_use]
47 pub const fn total(&self) -> usize {
48 self.combined_findings.len()
49 }
50
51 #[must_use]
53 pub const fn has_findings(&self) -> bool {
54 !self.combined_findings.is_empty()
55 }
56
57 #[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#[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}