1use rustc_hash::FxHashSet;
8use std::path::PathBuf;
9
10use serde::Serialize;
11
12use crate::duplicates::types::{CloneInstance, DuplicationReport};
13use crate::results::AnalysisResults;
14
15#[derive(Debug, Clone, Serialize)]
17pub struct CombinedFinding {
18 pub clone_instance: CloneInstance,
20 pub dead_code_kind: DeadCodeKind,
22 pub group_index: usize,
24}
25
26#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
28pub enum DeadCodeKind {
29 UnusedFile,
31 UnusedExport { export_name: String },
33 UnusedType { type_name: String },
35}
36
37#[derive(Debug, Clone, Serialize)]
39pub struct CrossReferenceResult {
40 pub combined_findings: Vec<CombinedFinding>,
42 pub clones_in_unused_files: usize,
44 pub clones_with_unused_exports: usize,
46}
47
48pub fn cross_reference(
56 duplication: &DuplicationReport,
57 dead_code: &AnalysisResults,
58) -> CrossReferenceResult {
59 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 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; }
79
80 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
95fn find_overlapping_unused_export(
97 instance: &CloneInstance,
98 group_index: usize,
99 dead_code: &AnalysisResults,
100) -> Option<CombinedFinding> {
101 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 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
136impl CrossReferenceResult {
138 pub const fn total(&self) -> usize {
140 self.combined_findings.len()
141 }
142
143 pub const fn has_findings(&self) -> bool {
145 !self.combined_findings.is_empty()
146 }
147
148 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 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, 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)); assert!(affected.contains(&1)); }
352}