Skip to main content

fallow_types/
duplicates.rs

1//! Shared duplicate-code output contracts.
2
3use std::cmp::{Ordering, Reverse};
4use std::path::{Component, Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::serde_path;
9
10/// A single instance of duplicated code at a specific location.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13pub struct CloneInstance {
14    /// Path to the file containing this clone instance.
15    #[serde(serialize_with = "serde_path::serialize")]
16    pub file: PathBuf,
17    /// 1-based start line of the clone.
18    pub start_line: usize,
19    /// 1-based end line of the clone.
20    pub end_line: usize,
21    /// 0-based start column.
22    pub start_col: usize,
23    /// 0-based end column.
24    pub end_col: usize,
25    /// The actual source code fragment.
26    pub fragment: String,
27}
28
29/// A group of code clones -- the same (or normalized-equivalent) code appearing
30/// in multiple places.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33pub struct CloneGroup {
34    /// All instances where this duplicated code appears.
35    pub instances: Vec<CloneInstance>,
36    /// Number of tokens in the duplicated block.
37    pub token_count: usize,
38    /// Number of lines in the duplicated block.
39    pub line_count: usize,
40    /// Lowest all-pairs similarity for a near-miss clone group. Exact clone
41    /// groups omit this field.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    #[cfg_attr(feature = "schema", schemars(with = "f64"))]
44    pub similarity: Option<f64>,
45}
46
47/// Whether a clone group is exact or a near-miss match.
48///
49/// This is derived from the serialized clone-group fields and does not add a
50/// separate discriminator to the output contract.
51#[derive(Debug, Clone, Copy, PartialEq)]
52#[non_exhaustive]
53pub enum CloneGroupKind {
54    /// An exact or normalization-equivalent clone group.
55    Exact,
56    /// A near-miss group and its lowest all-pairs similarity.
57    Near {
58        /// Lowest all-pairs similarity for the group.
59        similarity: f64,
60    },
61}
62
63impl CloneGroup {
64    /// Return the semantic kind represented by this clone group.
65    #[must_use]
66    pub fn kind(&self) -> CloneGroupKind {
67        self.similarity
68            .map_or(CloneGroupKind::Exact, |similarity| CloneGroupKind::Near {
69                similarity,
70            })
71    }
72
73    /// Maximum directory-tree or same-file line distance between instances.
74    #[must_use]
75    pub fn spread(&self) -> usize {
76        clone_group_spread(&self.instances)
77    }
78}
79
80const SAME_FILE_SPREAD_STEP: usize = 250;
81const MAX_RANKED_SPREAD: usize = 8;
82const SPREAD_RANK_WEIGHTS: [u64; MAX_RANKED_SPREAD + 1] = [
83    1_000_000_000,
84    1_047_319_732,
85    1_075_000_000,
86    1_094_639_463,
87    1_109_873_014,
88    1_122_319_732,
89    1_132_843_281,
90    1_141_959_195,
91    1_150_000_000,
92];
93
94/// Compute the maximum distance between clone instances.
95///
96/// Instances in different files use lexical parent-directory distance.
97/// Instances in the same file use the non-overlapping line gap, rounded up in
98/// 250-line steps. The returned value is not capped; only ranking caps spread.
99#[must_use]
100pub fn clone_group_spread(instances: &[CloneInstance]) -> usize {
101    clone_location_spread(instances.iter().map(|instance| {
102        (
103            instance.file.as_path(),
104            instance.start_line,
105            instance.end_line,
106        )
107    }))
108}
109
110/// Compute clone spread from borrowed file and line locations.
111///
112/// This is equivalent to [`clone_group_spread`] without requiring callers
113/// that wrap clone instances to clone their source fragments.
114#[must_use]
115pub fn clone_location_spread<'a>(
116    locations: impl IntoIterator<Item = (&'a Path, usize, usize)>,
117) -> usize {
118    let mut by_file = locations
119        .into_iter()
120        .map(|(file, start_line, end_line)| CloneLocation {
121            file,
122            start_line,
123            end_line,
124        })
125        .collect::<Vec<_>>();
126    if by_file.len() < 2 {
127        return 0;
128    }
129
130    by_file.sort_unstable_by(|left, right| left.file.cmp(right.file));
131
132    let mut same_file_max = 0;
133    let mut parent_components = Vec::new();
134    let mut start = 0;
135    while start < by_file.len() {
136        let mut end = start + 1;
137        while end < by_file.len() && by_file[end].file == by_file[start].file {
138            end += 1;
139        }
140
141        parent_components.push(path_parent_components(by_file[start].file));
142        if end - start >= 2 {
143            let min_end = by_file[start..end]
144                .iter()
145                .map(|instance| instance.end_line)
146                .min()
147                .unwrap_or(0);
148            let max_start = by_file[start..end]
149                .iter()
150                .map(|instance| instance.start_line)
151                .max()
152                .unwrap_or(0);
153            let gap = max_start.saturating_sub(min_end).saturating_sub(1);
154            same_file_max = same_file_max.max(gap.div_ceil(SAME_FILE_SPREAD_STEP));
155        }
156        start = end;
157    }
158
159    same_file_max.max(directory_tree_diameter(&parent_components))
160}
161
162#[derive(Clone, Copy)]
163struct CloneLocation<'a> {
164    file: &'a Path,
165    start_line: usize,
166    end_line: usize,
167}
168
169/// Compare clone groups in shared spread-aware priority order.
170#[must_use]
171pub fn compare_clone_groups(left: &CloneGroup, right: &CloneGroup) -> Ordering {
172    clone_group_rank_key(left).cmp(&clone_group_rank_key(right))
173}
174
175#[cfg(test)]
176fn instance_pair_spread(left: &CloneInstance, right: &CloneInstance) -> usize {
177    if left.file == right.file {
178        return same_file_spread(left, right);
179    }
180    directory_distance(&left.file, &right.file)
181}
182
183#[cfg(test)]
184fn same_file_spread(left: &CloneInstance, right: &CloneInstance) -> usize {
185    let gap = if left.end_line < right.start_line {
186        right
187            .start_line
188            .saturating_sub(left.end_line)
189            .saturating_sub(1)
190    } else if right.end_line < left.start_line {
191        left.start_line
192            .saturating_sub(right.end_line)
193            .saturating_sub(1)
194    } else {
195        0
196    };
197    gap.div_ceil(SAME_FILE_SPREAD_STEP)
198}
199
200fn path_parent_components(path: &Path) -> Vec<Component<'_>> {
201    path.parent()
202        .unwrap_or_else(|| Path::new(""))
203        .components()
204        .collect()
205}
206
207fn directory_tree_diameter(paths: &[Vec<Component<'_>>]) -> usize {
208    if paths.len() < 2 {
209        return 0;
210    }
211
212    let endpoint = farthest_path(paths, 0).0;
213    farthest_path(paths, endpoint).1
214}
215
216fn farthest_path(paths: &[Vec<Component<'_>>], origin: usize) -> (usize, usize) {
217    paths
218        .iter()
219        .enumerate()
220        .map(|(index, path)| (index, component_distance(&paths[origin], path)))
221        .max_by_key(|&(index, distance)| (distance, index))
222        .unwrap_or((origin, 0))
223}
224
225fn component_distance(left: &[Component<'_>], right: &[Component<'_>]) -> usize {
226    let shared = left
227        .iter()
228        .zip(right)
229        .take_while(|(left, right)| left == right)
230        .count();
231    left.len()
232        .saturating_sub(shared)
233        .saturating_add(right.len().saturating_sub(shared))
234}
235
236#[cfg(test)]
237fn directory_distance(left: &Path, right: &Path) -> usize {
238    component_distance(
239        &path_parent_components(left),
240        &path_parent_components(right),
241    )
242}
243
244type CloneGroupRankKey = (
245    Reverse<u128>,
246    Reverse<usize>,
247    Reverse<usize>,
248    Reverse<usize>,
249    Reverse<usize>,
250    bool,
251    PathBuf,
252    usize,
253);
254
255fn clone_group_rank_key(group: &CloneGroup) -> CloneGroupRankKey {
256    let spread = group.spread();
257    let weight = SPREAD_RANK_WEIGHTS[spread.min(MAX_RANKED_SPREAD)];
258    let token_count = u128::try_from(group.token_count).unwrap_or(u128::MAX);
259    let instance_count = u128::try_from(group.instances.len()).unwrap_or(u128::MAX);
260    let score = token_count
261        .saturating_mul(instance_count)
262        .saturating_mul(u128::from(weight));
263    let first = group.instances.iter().min_by(|left, right| {
264        left.file
265            .cmp(&right.file)
266            .then(left.start_line.cmp(&right.start_line))
267    });
268    (
269        Reverse(score),
270        Reverse(spread),
271        Reverse(group.token_count),
272        Reverse(group.instances.len()),
273        Reverse(group.line_count),
274        first.is_none(),
275        first.map_or_else(PathBuf::new, |instance| instance.file.clone()),
276        first.map_or(0, |instance| instance.start_line),
277    )
278}
279
280fn sort_clone_groups(groups: &mut [CloneGroup]) {
281    groups.sort_by_cached_key(clone_group_rank_key);
282}
283
284/// The kind of refactoring suggested for a clone family.
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
287pub enum RefactoringKind {
288    /// Extract a shared function/utility.
289    ExtractFunction,
290    /// Extract a shared module.
291    ExtractModule,
292}
293
294/// A refactoring suggestion for a clone family.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
297pub struct RefactoringSuggestion {
298    /// What kind of refactoring is suggested.
299    pub kind: RefactoringKind,
300    /// Human-readable description of the suggestion.
301    pub description: String,
302    /// Estimated lines that could be eliminated.
303    pub estimated_savings: usize,
304}
305
306/// A clone family: a set of clone groups that share the same file set.
307///
308/// When multiple clone groups are all duplicated between the same set of files,
309/// they form a family, indicating a deeper structural relationship that should
310/// be refactored together rather than group-by-group.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
313pub struct CloneFamily {
314    /// The files involved in this family (sorted for stable output).
315    #[serde(serialize_with = "serde_path::serialize_vec")]
316    pub files: Vec<PathBuf>,
317    /// Clone groups belonging to this family.
318    pub groups: Vec<CloneGroup>,
319    /// Total number of duplicated lines across all groups.
320    pub total_duplicated_lines: usize,
321    /// Total number of duplicated tokens across all groups.
322    pub total_duplicated_tokens: usize,
323    /// Refactoring suggestions for this family.
324    pub suggestions: Vec<RefactoringSuggestion>,
325}
326
327/// A detected mirrored directory pattern: two directory prefixes that contain
328/// identical files (e.g., `src/` and `deno/lib/`).
329#[derive(Debug, Clone, Serialize, Deserialize)]
330#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
331pub struct MirroredDirectory {
332    /// First directory path (lexically smaller).
333    pub dir_a: String,
334    /// Second directory path.
335    pub dir_b: String,
336    /// Filenames shared between the two directories.
337    pub shared_files: Vec<String>,
338    /// Total duplicated lines across all shared files.
339    pub total_lines: usize,
340}
341
342/// Number of files skipped by one built-in duplicates ignore pattern.
343#[derive(Debug, Clone, Default)]
344pub struct DefaultIgnoreSkipCount {
345    /// Glob pattern that matched skipped files.
346    pub pattern: &'static str,
347    /// Number of files skipped by this pattern.
348    pub count: usize,
349}
350
351/// Human-format-only skipped-file stats for built-in duplicates ignores.
352#[derive(Debug, Clone, Default)]
353pub struct DefaultIgnoreSkips {
354    /// Total number of files skipped by built-in duplicates ignores.
355    pub total: usize,
356    /// Per-pattern skip counts, in default pattern order.
357    pub by_pattern: Vec<DefaultIgnoreSkipCount>,
358}
359
360/// Overall duplication analysis report.
361#[derive(Debug, Clone, Default, Serialize, Deserialize)]
362#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
363pub struct DuplicationReport {
364    /// All detected clone groups. Each group contains 2+ instances of identical
365    /// or near-identical code.
366    pub clone_groups: Vec<CloneGroup>,
367    /// Clone families: groups of clone groups sharing the same file set,
368    /// indicating systematic duplication patterns.
369    pub clone_families: Vec<CloneFamily>,
370    /// Detected mirrored directory trees (directories with many identical files).
371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
372    pub mirrored_directories: Vec<MirroredDirectory>,
373    /// Aggregate statistics.
374    pub stats: DuplicationStats,
375}
376
377impl DuplicationReport {
378    /// Sort all result arrays for deterministic output ordering.
379    ///
380    /// Clone groups use spread-aware priority order, instances use file path and
381    /// line order, and clone families use their file set.
382    pub fn sort(&mut self) {
383        for group in &mut self.clone_groups {
384            group
385                .instances
386                .sort_by(|a, b| a.file.cmp(&b.file).then(a.start_line.cmp(&b.start_line)));
387        }
388        sort_clone_groups(&mut self.clone_groups);
389
390        for family in &mut self.clone_families {
391            for group in &mut family.groups {
392                group
393                    .instances
394                    .sort_by(|a, b| a.file.cmp(&b.file).then(a.start_line.cmp(&b.start_line)));
395            }
396            sort_clone_groups(&mut family.groups);
397        }
398        self.clone_families.sort_by(|a, b| a.files.cmp(&b.files));
399    }
400}
401
402/// Aggregate duplication statistics.
403#[derive(Debug, Clone, Default, Serialize, Deserialize)]
404#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
405pub struct DuplicationStats {
406    /// Total files analyzed.
407    pub total_files: usize,
408    /// Files containing at least one clone instance.
409    pub files_with_clones: usize,
410    /// Total lines across all analyzed files.
411    pub total_lines: usize,
412    /// Lines that are part of at least one clone.
413    pub duplicated_lines: usize,
414    /// Total tokens across all analyzed files.
415    pub total_tokens: usize,
416    /// Tokens in redundant clone copies, excluding one retained copy per group.
417    pub duplicated_tokens: usize,
418    /// Number of clone groups in the reported `clone_groups[]` array after
419    /// filtering and optional `--top` truncation.
420    pub clone_groups: usize,
421    /// Total clone instances across all reported groups after filtering and
422    /// optional `--top` truncation.
423    pub clone_instances: usize,
424    /// Percentage of duplicated lines (0.0 to 100.0). `--top` does not change
425    /// this scoped corpus metric.
426    pub duplication_percentage: f64,
427    /// Number of clone groups hidden by `duplicates.minOccurrences`. Absent (or
428    /// `0`) when the filter is at its default of `2` and nothing was hidden.
429    /// This counter covers only the minimum-occurrence filter.
430    #[serde(default, skip_serializing_if = "is_zero_usize")]
431    pub clone_groups_below_min_occurrences: usize,
432    /// Number of clone groups hidden by `duplicates.ignoredClones`.
433    #[serde(default, skip_serializing_if = "is_zero_usize")]
434    pub clone_groups_ignored: usize,
435    /// Near-miss candidate comparisons skipped by bounded-work limits.
436    #[serde(default, skip_serializing_if = "is_zero_usize")]
437    pub near_candidates_skipped: usize,
438}
439
440#[expect(
441    clippy::trivially_copy_pass_by_ref,
442    reason = "serde skip_serializing_if requires &T signature"
443)]
444const fn is_zero_usize(value: &usize) -> bool {
445    *value == 0
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use proptest::prelude::*;
452
453    fn pairwise_clone_group_spread(instances: &[CloneInstance]) -> usize {
454        let mut spread = 0;
455        for (index, left) in instances.iter().enumerate() {
456            for right in &instances[index + 1..] {
457                spread = spread.max(instance_pair_spread(left, right));
458            }
459        }
460        spread
461    }
462
463    fn instance(file: &str, start_line: usize, end_line: usize) -> CloneInstance {
464        CloneInstance {
465            file: PathBuf::from(file),
466            start_line,
467            end_line,
468            start_col: 0,
469            end_col: 0,
470            fragment: String::new(),
471        }
472    }
473
474    fn group(instances: Vec<CloneInstance>, token_count: usize, line_count: usize) -> CloneGroup {
475        CloneGroup {
476            instances,
477            token_count,
478            line_count,
479            similarity: None,
480        }
481    }
482
483    #[test]
484    fn spread_counts_non_shared_parent_components() {
485        let clone = group(
486            vec![
487                instance("/repo/packages/a/src/a.ts", 1, 10),
488                instance("/repo/packages/b/src/b.ts", 1, 10),
489            ],
490            100,
491            10,
492        );
493        assert_eq!(clone.spread(), 4);
494    }
495
496    #[test]
497    fn spread_is_zero_for_different_files_in_the_same_directory() {
498        let clone = group(
499            vec![
500                instance("/repo/src/a.ts", 1, 10),
501                instance("/repo/src/b.ts", 1, 10),
502            ],
503            100,
504            10,
505        );
506        assert_eq!(clone.spread(), 0);
507    }
508
509    #[test]
510    fn adjacent_same_file_instances_have_zero_spread() {
511        let clone = group(
512            vec![
513                instance("/repo/src/a.ts", 1, 10),
514                instance("/repo/src/a.ts", 11, 20),
515            ],
516            100,
517            10,
518        );
519        assert_eq!(clone.spread(), 0);
520    }
521
522    #[test]
523    fn same_file_spread_uses_ceiling_rounded_intervening_lines() {
524        let clone = group(
525            vec![
526                instance("/repo/src/a.ts", 1, 10),
527                instance("/repo/src/a.ts", 260, 269),
528                instance("/repo/src/a.ts", 261, 270),
529                instance("/repo/src/a.ts", 262, 271),
530            ],
531            100,
532            10,
533        );
534        assert_eq!(clone_group_spread(&clone.instances[..2]), 1);
535        assert_eq!(clone_group_spread(&clone.instances[..3]), 1);
536        assert_eq!(clone.spread(), 2);
537    }
538
539    #[test]
540    fn overlapping_same_file_instances_have_zero_spread() {
541        let clone = group(
542            vec![
543                instance("/repo/src/a.ts", 1, 20),
544                instance("/repo/src/a.ts", 10, 30),
545            ],
546            100,
547            20,
548        );
549        assert_eq!(clone.spread(), 0);
550    }
551
552    #[test]
553    fn directory_diameter_handles_ties_and_mixed_roots() {
554        let instances = vec![
555            instance("src/a.ts", 1, 10),
556            instance("packages/a/b.ts", 1, 10),
557            instance("packages/c/d.ts", 1, 10),
558            instance("/repo/src/e.ts", 1, 10),
559        ];
560
561        assert_eq!(
562            clone_group_spread(&instances),
563            pairwise_clone_group_spread(&instances)
564        );
565    }
566
567    #[cfg(windows)]
568    #[test]
569    fn directory_diameter_handles_windows_prefixes() {
570        let instances = vec![
571            instance(r"C:\repo\src\a.ts", 1, 10),
572            instance(r"C:\repo\packages\b.ts", 1, 10),
573            instance(r"D:\other\c.ts", 1, 10),
574        ];
575
576        assert_eq!(
577            clone_group_spread(&instances),
578            pairwise_clone_group_spread(&instances)
579        );
580    }
581
582    #[test]
583    fn clone_group_kind_does_not_change_serialized_contract() {
584        let mut clone = group(vec![instance("src/a.ts", 1, 10)], 20, 10);
585        assert_eq!(clone.kind(), CloneGroupKind::Exact);
586        assert!(serde_json::to_value(&clone).unwrap()["similarity"].is_null());
587
588        clone.similarity = Some(0.85);
589        assert_eq!(clone.kind(), CloneGroupKind::Near { similarity: 0.85 });
590        assert_eq!(serde_json::to_value(&clone).unwrap()["similarity"], 0.85);
591    }
592
593    mod proptests {
594        use super::*;
595
596        proptest! {
597            #[test]
598            fn optimized_spread_matches_pairwise_reference(
599                entries in prop::collection::vec((0_u8..8, 1_usize..4_000, 1_usize..500), 0..80)
600            ) {
601                let paths = [
602                    "src/a.ts",
603                    "src/b.ts",
604                    "packages/a/src/c.ts",
605                    "packages/b/src/d.ts",
606                    "packages/b/test/e.ts",
607                    "/repo/apps/web/f.ts",
608                    "/repo/crates/core/g.ts",
609                    "h.ts",
610                ];
611                let instances = entries
612                    .into_iter()
613                    .map(|(path, start, len)| instance(paths[usize::from(path)], start, start + len))
614                    .collect::<Vec<_>>();
615
616                prop_assert_eq!(
617                    clone_group_spread(&instances),
618                    pairwise_clone_group_spread(&instances)
619                );
620            }
621        }
622    }
623
624    #[test]
625    fn ranking_uses_spread_without_overriding_a_larger_base_score() {
626        let distant = group(
627            vec![
628                instance("/repo/a/b/c/d/e/a.ts", 1, 10),
629                instance("/repo/f/g/h/i/j/b.ts", 1, 10),
630            ],
631            100,
632            10,
633        );
634        let slightly_larger_local = group(
635            vec![
636                instance("/repo/src/a.ts", 1, 10),
637                instance("/repo/src/b.ts", 1, 10),
638            ],
639            116,
640            10,
641        );
642        assert_eq!(distant.spread(), 10);
643        assert_eq!(
644            compare_clone_groups(&distant, &slightly_larger_local),
645            Ordering::Greater
646        );
647
648        let slightly_smaller_local = group(
649            vec![
650                instance("/repo/src/c.ts", 1, 10),
651                instance("/repo/src/d.ts", 1, 10),
652            ],
653            114,
654            10,
655        );
656        assert_eq!(
657            compare_clone_groups(&distant, &slightly_smaller_local),
658            Ordering::Less
659        );
660    }
661
662    #[test]
663    fn report_sort_uses_canonical_location_as_final_tiebreaker() {
664        let later = group(
665            vec![
666                instance("/repo/src/z.ts", 1, 10),
667                instance("/repo/src/y.ts", 1, 10),
668            ],
669            100,
670            10,
671        );
672        let earlier = group(
673            vec![
674                instance("/repo/src/a.ts", 20, 29),
675                instance("/repo/src/b.ts", 20, 29),
676            ],
677            100,
678            10,
679        );
680        let mut report = DuplicationReport {
681            clone_groups: vec![later, earlier],
682            ..DuplicationReport::default()
683        };
684        report.sort();
685        assert_eq!(
686            report.clone_groups[0].instances[0].file,
687            Path::new("/repo/src/a.ts")
688        );
689    }
690
691    #[test]
692    fn exact_clone_similarity_is_omitted() {
693        let clone = group(Vec::new(), 100, 10);
694        let value = serde_json::to_value(clone).unwrap();
695        assert!(value.get("similarity").is_none());
696    }
697
698    #[test]
699    fn near_clone_similarity_is_serialized() {
700        let mut clone = group(Vec::new(), 100, 10);
701        clone.similarity = Some(0.85);
702        let value = serde_json::to_value(clone).unwrap();
703        assert_eq!(value["similarity"], 0.85);
704    }
705
706    #[test]
707    fn optional_duplication_stats_are_omitted_at_zero() {
708        let empty = serde_json::to_value(DuplicationStats::default()).unwrap();
709        assert!(empty.get("clone_groups_ignored").is_none());
710        assert!(empty.get("near_candidates_skipped").is_none());
711
712        let populated = serde_json::to_value(DuplicationStats {
713            clone_groups_ignored: 2,
714            near_candidates_skipped: 3,
715            ..DuplicationStats::default()
716        })
717        .unwrap();
718        assert_eq!(populated["clone_groups_ignored"], 2);
719        assert_eq!(populated["near_candidates_skipped"], 3);
720    }
721}