Skip to main content

fallow_core/
cross_reference.rs

1//! Cross-reference duplication findings with dead code analysis results.
2//!
3//! When code is both duplicated AND unused, it's a higher-priority finding:
4//! the duplicate can be safely removed without any refactoring. This module
5//! identifies such combined findings.
6
7use rustc_hash::FxHashSet;
8use std::path::PathBuf;
9
10use serde::Serialize;
11
12use crate::duplicates::types::{CloneInstance, DuplicationReport};
13use crate::results::AnalysisResults;
14
15/// A combined finding where a clone instance overlaps with a dead code issue.
16#[derive(Debug, Clone, Serialize)]
17pub struct CombinedFinding {
18    /// The clone instance that is also unused.
19    pub clone_instance: CloneInstance,
20    /// What kind of dead code overlaps with this clone.
21    pub dead_code_kind: DeadCodeKind,
22    /// Clone group index (for associating with the parent group).
23    pub group_index: usize,
24}
25
26/// The type of dead code that overlaps with a clone instance.
27#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
28pub enum DeadCodeKind {
29    /// The entire file containing the clone is unused.
30    UnusedFile,
31    /// A specific unused export overlaps with the clone's line range.
32    UnusedExport { export_name: String },
33    /// A specific unused type overlaps with the clone's line range.
34    UnusedType { type_name: String },
35}
36
37/// Result of cross-referencing duplication with dead code analysis.
38#[derive(Debug, Clone, Serialize)]
39pub struct CrossReferenceResult {
40    /// Clone instances that are also dead code (safe to delete).
41    pub combined_findings: Vec<CombinedFinding>,
42    /// Number of clone instances in unused files.
43    pub clones_in_unused_files: usize,
44    /// Number of clone instances overlapping unused exports.
45    pub clones_with_unused_exports: usize,
46}
47
48/// Cross-reference duplication findings with dead code analysis results.
49///
50/// For each clone instance, checks whether:
51/// 1. The file is entirely unused (in `unused_files`)
52/// 2. An unused export/type at the same line range overlaps
53///
54/// Returns combined findings sorted by priority (unused files first, then exports).
55#[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; // No need to check exports if entire file is unused
80            }
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
96/// Check if any unused export/type overlaps with the clone instance's line range.
97fn 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
135/// Summary statistics for cross-referenced findings.
136impl CrossReferenceResult {
137    /// Total number of combined findings.
138    #[must_use]
139    pub const fn total(&self) -> usize {
140        self.combined_findings.len()
141    }
142
143    /// Whether any combined findings exist.
144    #[must_use]
145    pub const fn has_findings(&self) -> bool {
146        !self.combined_findings.is_empty()
147    }
148
149    /// Get clone groups that have at least one combined finding, with their indices.
150    #[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}