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 is_symlink: false,
168 file: PathBuf::from(file),
169 start_line,
170 end_line,
171 start_col: 0,
172 end_col: 0,
173 fragment: String::new(),
174 }
175 }
176
177 fn duplicate_report(instances: Vec<CloneInstance>) -> DuplicationReport {
178 DuplicationReport {
179 clone_groups: vec![CloneGroup {
180 instances,
181 token_count: 50,
182 line_count: 10,
183 similarity: None,
184 }],
185 clone_families: Vec::new(),
186 mirrored_directories: Vec::new(),
187 stats: DuplicationStats::default(),
188 }
189 }
190
191 #[test]
192 fn cross_reference_result_methods_use_engine_owned_findings() {
193 let result = CrossReferenceResult {
194 combined_findings: vec![
195 CombinedFinding {
196 clone_instance: clone_instance("src/a.ts", 1, 3),
197 dead_code_kind: DeadCodeKind::UnusedFile,
198 group_index: 2,
199 },
200 CombinedFinding {
201 clone_instance: clone_instance("src/b.ts", 4, 8),
202 dead_code_kind: DeadCodeKind::UnusedExport {
203 export_name: "unused".to_string(),
204 },
205 group_index: 4,
206 },
207 ],
208 clones_in_unused_files: 1,
209 clones_with_unused_exports: 1,
210 };
211
212 assert_eq!(result.total(), 2);
213 assert!(result.has_findings());
214 assert!(result.affected_group_indices().contains(&2));
215 assert!(result.affected_group_indices().contains(&4));
216 }
217
218 #[test]
219 fn cross_reference_prioritizes_unused_file_overlap() {
220 let duplication = duplicate_report(vec![
221 clone_instance("src/a.ts", 1, 3),
222 clone_instance("src/b.ts", 4, 8),
223 ]);
224 let mut dead_code = AnalysisResults::default();
225 dead_code
226 .unused_files
227 .push(UnusedFileFinding::with_actions(UnusedFile {
228 path: PathBuf::from("src/a.ts"),
229 }));
230
231 let result = cross_reference(&duplication, &dead_code);
232
233 assert_eq!(result.clones_in_unused_files, 1);
234 assert_eq!(result.clones_with_unused_exports, 0);
235 assert!(matches!(
236 result.combined_findings[0].dead_code_kind,
237 DeadCodeKind::UnusedFile
238 ));
239 }
240
241 #[test]
242 fn cross_reference_detects_unused_export_and_type_overlap() {
243 let duplication = duplicate_report(vec![
244 clone_instance("src/a.ts", 10, 20),
245 clone_instance("src/b.ts", 30, 40),
246 ]);
247 let mut dead_code = AnalysisResults::default();
248 dead_code
249 .unused_exports
250 .push(UnusedExportFinding::with_actions(UnusedExport {
251 path: PathBuf::from("src/a.ts"),
252 export_name: "deadValue".to_string(),
253 line: 12,
254 col: 0,
255 span_start: 0,
256 is_re_export: false,
257 is_type_only: false,
258 deprecated: false,
259 deprecated_reason: None,
260 }));
261 dead_code
262 .unused_types
263 .push(UnusedTypeFinding::with_actions(UnusedExport {
264 path: PathBuf::from("src/b.ts"),
265 export_name: "DeadType".to_string(),
266 line: 35,
267 col: 0,
268 span_start: 0,
269 is_re_export: false,
270 is_type_only: true,
271 deprecated: false,
272 deprecated_reason: None,
273 }));
274
275 let result = cross_reference(&duplication, &dead_code);
276
277 assert_eq!(result.clones_in_unused_files, 0);
278 assert_eq!(result.clones_with_unused_exports, 2);
279 assert!(matches!(
280 result.combined_findings[0].dead_code_kind,
281 DeadCodeKind::UnusedExport { ref export_name } if export_name == "deadValue"
282 ));
283 assert!(matches!(
284 result.combined_findings[1].dead_code_kind,
285 DeadCodeKind::UnusedType { ref type_name } if type_name == "DeadType"
286 ));
287 }
288}