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
48#[must_use]
56pub fn cross_reference(
57 duplication: &DuplicationReport,
58 dead_code: &AnalysisResults,
59) -> CrossReferenceResult {
60 let unused_files: FxHashSet<&PathBuf> = dead_code
61 .unused_files
62 .iter()
63 .map(|f| &f.file.path)
64 .collect();
65
66 let mut combined_findings = Vec::new();
67 let mut clones_in_unused_files = 0usize;
68 let mut clones_with_unused_exports = 0usize;
69
70 for (group_idx, group) in duplication.clone_groups.iter().enumerate() {
71 for instance in &group.instances {
72 if unused_files.contains(&instance.file) {
73 combined_findings.push(CombinedFinding {
74 clone_instance: instance.clone(),
75 dead_code_kind: DeadCodeKind::UnusedFile,
76 group_index: group_idx,
77 });
78 clones_in_unused_files += 1;
79 continue; }
81
82 if let Some(finding) = find_overlapping_unused_export(instance, group_idx, dead_code) {
83 clones_with_unused_exports += 1;
84 combined_findings.push(finding);
85 }
86 }
87 }
88
89 CrossReferenceResult {
90 combined_findings,
91 clones_in_unused_files,
92 clones_with_unused_exports,
93 }
94}
95
96fn find_overlapping_unused_export(
98 instance: &CloneInstance,
99 group_index: usize,
100 dead_code: &AnalysisResults,
101) -> Option<CombinedFinding> {
102 for export in &dead_code.unused_exports {
103 if export.export.path == instance.file
104 && (export.export.line as usize) >= instance.start_line
105 && (export.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.export_name.clone(),
111 },
112 group_index,
113 });
114 }
115 }
116
117 for type_export in &dead_code.unused_types {
118 if type_export.export.path == instance.file
119 && (type_export.export.line as usize) >= instance.start_line
120 && (type_export.export.line as usize) <= instance.end_line
121 {
122 return Some(CombinedFinding {
123 clone_instance: instance.clone(),
124 dead_code_kind: DeadCodeKind::UnusedType {
125 type_name: type_export.export.export_name.clone(),
126 },
127 group_index,
128 });
129 }
130 }
131
132 None
133}
134
135impl CrossReferenceResult {
137 #[must_use]
139 pub const fn total(&self) -> usize {
140 self.combined_findings.len()
141 }
142
143 #[must_use]
145 pub const fn has_findings(&self) -> bool {
146 !self.combined_findings.is_empty()
147 }
148
149 #[must_use]
151 pub fn affected_group_indices(&self) -> FxHashSet<usize> {
152 self.combined_findings
153 .iter()
154 .map(|f| f.group_index)
155 .collect()
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::duplicates::CloneGroup;
163 use crate::results::{UnusedExport, UnusedFile};
164 use fallow_types::output_dead_code::{
165 UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
166 };
167
168 fn make_instance(file: &str, start: usize, end: usize) -> CloneInstance {
169 CloneInstance {
170 file: PathBuf::from(file),
171 start_line: start,
172 end_line: end,
173 start_col: 0,
174 end_col: 0,
175 fragment: String::new(),
176 }
177 }
178
179 fn make_group(instances: Vec<CloneInstance>) -> CloneGroup {
180 CloneGroup {
181 instances,
182 token_count: 50,
183 line_count: 10,
184 }
185 }
186
187 #[test]
188 fn empty_inputs_produce_no_findings() {
189 let duplication = DuplicationReport {
190 clone_groups: vec![],
191 clone_families: vec![],
192 mirrored_directories: vec![],
193 stats: crate::duplicates::types::DuplicationStats {
194 total_files: 0,
195 files_with_clones: 0,
196 total_lines: 0,
197 duplicated_lines: 0,
198 total_tokens: 0,
199 duplicated_tokens: 0,
200 clone_groups: 0,
201 clone_instances: 0,
202 duplication_percentage: 0.0,
203 clone_groups_below_min_occurrences: 0,
204 },
205 };
206 let dead_code = AnalysisResults::default();
207
208 let result = cross_reference(&duplication, &dead_code);
209 assert!(!result.has_findings());
210 assert_eq!(result.total(), 0);
211 }
212
213 #[test]
214 fn detects_clone_in_unused_file() {
215 let duplication = DuplicationReport {
216 clone_groups: vec![make_group(vec![
217 make_instance("src/a.ts", 1, 10),
218 make_instance("src/b.ts", 1, 10),
219 ])],
220 clone_families: vec![],
221 mirrored_directories: vec![],
222 stats: crate::duplicates::types::DuplicationStats {
223 total_files: 2,
224 files_with_clones: 2,
225 total_lines: 20,
226 duplicated_lines: 10,
227 total_tokens: 100,
228 duplicated_tokens: 50,
229 clone_groups: 1,
230 clone_instances: 2,
231 duplication_percentage: 50.0,
232 clone_groups_below_min_occurrences: 0,
233 },
234 };
235 let mut dead_code = AnalysisResults::default();
236 dead_code
237 .unused_files
238 .push(UnusedFileFinding::with_actions(UnusedFile {
239 path: PathBuf::from("src/a.ts"),
240 }));
241
242 let result = cross_reference(&duplication, &dead_code);
243 assert!(result.has_findings());
244 assert_eq!(result.clones_in_unused_files, 1);
245 assert_eq!(
246 result.combined_findings[0].dead_code_kind,
247 DeadCodeKind::UnusedFile
248 );
249 }
250
251 #[test]
252 fn detects_clone_overlapping_unused_export() {
253 let duplication = DuplicationReport {
254 clone_groups: vec![make_group(vec![
255 make_instance("src/a.ts", 5, 15),
256 make_instance("src/b.ts", 5, 15),
257 ])],
258 clone_families: vec![],
259 mirrored_directories: vec![],
260 stats: crate::duplicates::types::DuplicationStats {
261 total_files: 2,
262 files_with_clones: 2,
263 total_lines: 20,
264 duplicated_lines: 10,
265 total_tokens: 100,
266 duplicated_tokens: 50,
267 clone_groups: 1,
268 clone_instances: 2,
269 duplication_percentage: 50.0,
270 clone_groups_below_min_occurrences: 0,
271 },
272 };
273 let mut dead_code = AnalysisResults::default();
274 dead_code
275 .unused_exports
276 .push(UnusedExportFinding::with_actions(UnusedExport {
277 path: PathBuf::from("src/a.ts"),
278 export_name: "processData".to_string(),
279 is_type_only: false,
280 line: 5,
281 col: 0,
282 span_start: 0,
283 is_re_export: false,
284 }));
285
286 let result = cross_reference(&duplication, &dead_code);
287 assert!(result.has_findings());
288 assert_eq!(result.clones_with_unused_exports, 1);
289 assert!(matches!(
290 &result.combined_findings[0].dead_code_kind,
291 DeadCodeKind::UnusedExport { export_name } if export_name == "processData"
292 ));
293 }
294
295 #[test]
296 fn no_findings_when_no_overlap() {
297 let duplication = DuplicationReport {
298 clone_groups: vec![make_group(vec![
299 make_instance("src/a.ts", 5, 15),
300 make_instance("src/b.ts", 5, 15),
301 ])],
302 clone_families: vec![],
303 mirrored_directories: vec![],
304 stats: crate::duplicates::types::DuplicationStats {
305 total_files: 2,
306 files_with_clones: 2,
307 total_lines: 20,
308 duplicated_lines: 10,
309 total_tokens: 100,
310 duplicated_tokens: 50,
311 clone_groups: 1,
312 clone_instances: 2,
313 duplication_percentage: 50.0,
314 clone_groups_below_min_occurrences: 0,
315 },
316 };
317 let mut dead_code = AnalysisResults::default();
318 dead_code
319 .unused_exports
320 .push(UnusedExportFinding::with_actions(UnusedExport {
321 path: PathBuf::from("src/a.ts"),
322 export_name: "other".to_string(),
323 is_type_only: false,
324 line: 20,
325 col: 0,
326 span_start: 0,
327 is_re_export: false,
328 }));
329
330 let result = cross_reference(&duplication, &dead_code);
331 assert!(!result.has_findings());
332 }
333
334 #[test]
335 fn affected_group_indices() {
336 let duplication = DuplicationReport {
337 clone_groups: vec![
338 make_group(vec![
339 make_instance("src/a.ts", 1, 10),
340 make_instance("src/b.ts", 1, 10),
341 ]),
342 make_group(vec![
343 make_instance("src/c.ts", 1, 10),
344 make_instance("src/d.ts", 1, 10),
345 ]),
346 ],
347 clone_families: vec![],
348 mirrored_directories: vec![],
349 stats: crate::duplicates::types::DuplicationStats {
350 total_files: 4,
351 files_with_clones: 4,
352 total_lines: 40,
353 duplicated_lines: 20,
354 total_tokens: 200,
355 duplicated_tokens: 100,
356 clone_groups: 2,
357 clone_instances: 4,
358 duplication_percentage: 50.0,
359 clone_groups_below_min_occurrences: 0,
360 },
361 };
362 let mut dead_code = AnalysisResults::default();
363 dead_code
364 .unused_files
365 .push(UnusedFileFinding::with_actions(UnusedFile {
366 path: PathBuf::from("src/c.ts"),
367 }));
368
369 let result = cross_reference(&duplication, &dead_code);
370 let affected = result.affected_group_indices();
371 assert!(!affected.contains(&0));
372 assert!(affected.contains(&1));
373 }
374
375 #[test]
376 fn unused_file_takes_priority_over_export() {
377 let duplication = DuplicationReport {
378 clone_groups: vec![make_group(vec![
379 make_instance("src/a.ts", 5, 15),
380 make_instance("src/b.ts", 5, 15),
381 ])],
382 clone_families: vec![],
383 mirrored_directories: vec![],
384 stats: crate::duplicates::types::DuplicationStats {
385 total_files: 2,
386 files_with_clones: 2,
387 total_lines: 20,
388 duplicated_lines: 10,
389 total_tokens: 100,
390 duplicated_tokens: 50,
391 clone_groups: 1,
392 clone_instances: 2,
393 duplication_percentage: 50.0,
394 clone_groups_below_min_occurrences: 0,
395 },
396 };
397 let mut dead_code = AnalysisResults::default();
398 dead_code
399 .unused_files
400 .push(UnusedFileFinding::with_actions(UnusedFile {
401 path: PathBuf::from("src/a.ts"),
402 }));
403 dead_code
404 .unused_exports
405 .push(UnusedExportFinding::with_actions(UnusedExport {
406 path: PathBuf::from("src/a.ts"),
407 export_name: "foo".to_string(),
408 is_type_only: false,
409 line: 10,
410 col: 0,
411 span_start: 0,
412 is_re_export: false,
413 }));
414
415 let result = cross_reference(&duplication, &dead_code);
416 let a_findings: Vec<_> = result
417 .combined_findings
418 .iter()
419 .filter(|f| f.clone_instance.file == std::path::Path::new("src/a.ts"))
420 .collect();
421 assert_eq!(a_findings.len(), 1);
422 assert_eq!(a_findings[0].dead_code_kind, DeadCodeKind::UnusedFile);
423 }
424
425 #[test]
426 fn detects_clone_overlapping_unused_type() {
427 let duplication = DuplicationReport {
428 clone_groups: vec![make_group(vec![
429 make_instance("src/types.ts", 1, 20),
430 make_instance("src/other.ts", 1, 20),
431 ])],
432 clone_families: vec![],
433 mirrored_directories: vec![],
434 stats: crate::duplicates::types::DuplicationStats {
435 total_files: 2,
436 files_with_clones: 2,
437 total_lines: 40,
438 duplicated_lines: 20,
439 total_tokens: 100,
440 duplicated_tokens: 50,
441 clone_groups: 1,
442 clone_instances: 2,
443 duplication_percentage: 50.0,
444 clone_groups_below_min_occurrences: 0,
445 },
446 };
447 let mut dead_code = AnalysisResults::default();
448 dead_code
449 .unused_types
450 .push(UnusedTypeFinding::with_actions(UnusedExport {
451 path: PathBuf::from("src/types.ts"),
452 export_name: "OldInterface".to_string(),
453 is_type_only: true,
454 line: 10,
455 col: 0,
456 span_start: 0,
457 is_re_export: false,
458 }));
459
460 let result = cross_reference(&duplication, &dead_code);
461 assert!(result.has_findings());
462 assert!(matches!(
463 &result.combined_findings[0].dead_code_kind,
464 DeadCodeKind::UnusedType { type_name } if type_name == "OldInterface"
465 ));
466 }
467
468 #[test]
469 fn empty_result_methods() {
470 let result = CrossReferenceResult {
471 combined_findings: vec![],
472 clones_in_unused_files: 0,
473 clones_with_unused_exports: 0,
474 };
475 assert_eq!(result.total(), 0);
476 assert!(!result.has_findings());
477 assert!(result.affected_group_indices().is_empty());
478 }
479
480 #[test]
481 fn multiple_groups_with_findings() {
482 let duplication = DuplicationReport {
483 clone_groups: vec![
484 make_group(vec![
485 make_instance("src/a.ts", 1, 10),
486 make_instance("src/b.ts", 1, 10),
487 ]),
488 make_group(vec![
489 make_instance("src/c.ts", 5, 15),
490 make_instance("src/d.ts", 5, 15),
491 ]),
492 make_group(vec![
493 make_instance("src/e.ts", 1, 10),
494 make_instance("src/f.ts", 1, 10),
495 ]),
496 ],
497 clone_families: vec![],
498 mirrored_directories: vec![],
499 stats: crate::duplicates::types::DuplicationStats {
500 total_files: 6,
501 files_with_clones: 6,
502 total_lines: 60,
503 duplicated_lines: 30,
504 total_tokens: 300,
505 duplicated_tokens: 150,
506 clone_groups: 3,
507 clone_instances: 6,
508 duplication_percentage: 50.0,
509 clone_groups_below_min_occurrences: 0,
510 },
511 };
512 let mut dead_code = AnalysisResults::default();
513 dead_code
514 .unused_files
515 .push(UnusedFileFinding::with_actions(UnusedFile {
516 path: PathBuf::from("src/a.ts"),
517 }));
518 dead_code
519 .unused_exports
520 .push(UnusedExportFinding::with_actions(UnusedExport {
521 path: PathBuf::from("src/c.ts"),
522 export_name: "helper".to_string(),
523 is_type_only: false,
524 line: 10,
525 col: 0,
526 span_start: 0,
527 is_re_export: false,
528 }));
529
530 let result = cross_reference(&duplication, &dead_code);
531 assert_eq!(result.total(), 2);
532 assert_eq!(result.clones_in_unused_files, 1);
533 assert_eq!(result.clones_with_unused_exports, 1);
534
535 let affected = result.affected_group_indices();
536 assert!(affected.contains(&0));
537 assert!(affected.contains(&1));
538 assert!(!affected.contains(&2));
539 }
540
541 #[test]
542 fn clone_instance_outside_export_line_range() {
543 let duplication = DuplicationReport {
544 clone_groups: vec![make_group(vec![
545 make_instance("src/a.ts", 1, 5),
546 make_instance("src/b.ts", 1, 5),
547 ])],
548 clone_families: vec![],
549 mirrored_directories: vec![],
550 stats: crate::duplicates::types::DuplicationStats::default(),
551 };
552 let mut dead_code = AnalysisResults::default();
553 dead_code
554 .unused_exports
555 .push(UnusedExportFinding::with_actions(UnusedExport {
556 path: PathBuf::from("src/a.ts"),
557 export_name: "fn".to_string(),
558 is_type_only: false,
559 line: 10,
560 col: 0,
561 span_start: 0,
562 is_re_export: false,
563 }));
564
565 let result = cross_reference(&duplication, &dead_code);
566 assert!(!result.has_findings());
567 }
568
569 #[test]
570 fn clone_in_different_file_than_unused_export() {
571 let duplication = DuplicationReport {
572 clone_groups: vec![make_group(vec![
573 make_instance("src/a.ts", 5, 15),
574 make_instance("src/b.ts", 5, 15),
575 ])],
576 clone_families: vec![],
577 mirrored_directories: vec![],
578 stats: crate::duplicates::types::DuplicationStats::default(),
579 };
580 let mut dead_code = AnalysisResults::default();
581 dead_code
582 .unused_exports
583 .push(UnusedExportFinding::with_actions(UnusedExport {
584 path: PathBuf::from("src/x.ts"),
585 export_name: "fn".to_string(),
586 is_type_only: false,
587 line: 10,
588 col: 0,
589 span_start: 0,
590 is_re_export: false,
591 }));
592
593 let result = cross_reference(&duplication, &dead_code);
594 assert!(!result.has_findings());
595 }
596}