Skip to main content

fallow_api/diff_risk/
clones.rs

1//! `diverged-clone-sibling`: the change edited one copy of a base clone group,
2//! that copy no longer matches, and no sibling changed.
3
4use rustc_hash::{FxHashMap, FxHashSet};
5
6/// One clone instance location, root-relative with forward slashes.
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
8pub struct CloneSpan {
9    /// Root-relative path.
10    pub path: String,
11    /// 1-based first line.
12    pub start_line: u32,
13    /// 1-based last line, inclusive.
14    pub end_line: u32,
15}
16
17/// The instances of one base clone group. The paths are base paths: the
18/// rename remap of the audit does not move them.
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct BaseCloneGroup {
21    /// Instance locations in the base tree.
22    pub instances: Vec<CloneSpan>,
23}
24
25/// One edited clone copy whose siblings did not change.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct DivergedClone {
28    /// Head path of the edited copy.
29    pub path: String,
30    /// 1-based head line where the edited copy starts, when a line of it
31    /// survives.
32    pub line: Option<u32>,
33    /// The unchanged siblings, at their head paths.
34    pub siblings: Vec<CloneSpan>,
35}
36
37/// Text access for the detector. `base` takes a base path, `head` a head path.
38pub struct CloneTexts<'a> {
39    /// Base text of a file, `None` when it cannot be read.
40    pub base: &'a mut dyn FnMut(&str) -> Option<String>,
41    /// Head text of a file, `None` when the file is gone or unreadable.
42    pub head: &'a mut dyn FnMut(&str) -> Option<String>,
43}
44
45/// Find the base clone groups where exactly one copy was edited, the edit
46/// broke the clone, and every sibling kept its text.
47///
48/// `changed` holds the head paths of the changed files. `renames` maps a base
49/// path to its head path. `head_groups` holds the instances of every head
50/// clone group, at head paths.
51#[must_use]
52#[expect(
53    clippy::implicit_hasher,
54    reason = "fallow standardizes on FxHashSet across the workspace"
55)]
56pub fn diverged_clone_siblings(
57    base_groups: &[BaseCloneGroup],
58    head_groups: &[Vec<CloneSpan>],
59    changed: &FxHashSet<String>,
60    renames: &FxHashMap<String, String>,
61    texts: &mut CloneTexts<'_>,
62) -> Vec<DivergedClone> {
63    let mut base_cache: FxHashMap<String, Option<String>> = FxHashMap::default();
64    let mut head_cache: FxHashMap<String, Option<String>> = FxHashMap::default();
65    let mut found = Vec::new();
66    for group in base_groups {
67        if group.instances.len() < 2 {
68            continue;
69        }
70        let mut edited: Vec<(CloneSpan, Option<u32>)> = Vec::new();
71        let mut siblings: Vec<CloneSpan> = Vec::new();
72        let mut skip_group = false;
73        for instance in &group.instances {
74            let head_path = renames
75                .get(&instance.path)
76                .cloned()
77                .unwrap_or_else(|| instance.path.clone());
78            let head_span = CloneSpan {
79                path: head_path.clone(),
80                ..instance.clone()
81            };
82            if !changed.contains(&head_path) {
83                siblings.push(head_span);
84                continue;
85            }
86            let base_text = base_cache
87                .entry(instance.path.clone())
88                .or_insert_with(|| (texts.base)(&instance.path));
89            let Some(fragment) = base_text
90                .as_deref()
91                .map(|text| fragment_lines(text, instance))
92            else {
93                skip_group = true;
94                break;
95            };
96            let head_text = head_cache
97                .entry(head_path.clone())
98                .or_insert_with(|| (texts.head)(&head_path));
99            let Some(head_text) = head_text.as_deref() else {
100                // A deleted copy is a removal, not a divergence.
101                skip_group = true;
102                break;
103            };
104            match classify_copy(&fragment, head_text) {
105                CopyState::Unchanged(lines) => siblings.push(match lines {
106                    Some((start_line, end_line)) => CloneSpan {
107                        start_line,
108                        end_line,
109                        ..head_span
110                    },
111                    None => head_span,
112                }),
113                CopyState::Edited(line) => edited.push((head_span, line)),
114                CopyState::Removed => {
115                    skip_group = true;
116                    break;
117                }
118            }
119        }
120        if skip_group || edited.len() != 1 || siblings.is_empty() {
121            continue;
122        }
123        let (copy, line) = edited.remove(0);
124        if still_clones(head_groups, &copy.path, &siblings) {
125            continue;
126        }
127        found.push(DivergedClone {
128            path: copy.path,
129            line,
130            siblings,
131        });
132    }
133    merge_same_copy(found)
134}
135
136/// One finding for each edited copy: two base groups that share the copy
137/// (nested or overlapping clones) merge, and their siblings join.
138fn merge_same_copy(found: Vec<DivergedClone>) -> Vec<DivergedClone> {
139    let mut merged: Vec<DivergedClone> = Vec::new();
140    for diverged in found {
141        if let Some(existing) = merged
142            .iter_mut()
143            .find(|existing| existing.path == diverged.path && existing.line == diverged.line)
144        {
145            existing.siblings.extend(diverged.siblings);
146        } else {
147            merged.push(diverged);
148        }
149    }
150    for diverged in &mut merged {
151        diverged
152            .siblings
153            .sort_by(|a, b| a.path.cmp(&b.path).then(a.start_line.cmp(&b.start_line)));
154        diverged
155            .siblings
156            .dedup_by(|a, b| a.path == b.path && a.start_line == b.start_line);
157    }
158    merged.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
159    merged
160}
161
162enum CopyState {
163    /// The fragment is still in the head file, word for word. Carries the
164    /// 1-based head lines of its first and last non-blank line, so a copy
165    /// that moved inside its file keeps its head location.
166    Unchanged(Option<(u32, u32)>),
167    /// The fragment changed, and most of its lines survive. Carries the head
168    /// line of the first surviving line.
169    Edited(Option<u32>),
170    /// Most of the fragment is gone: a removal or a rewrite, not an edit.
171    Removed,
172}
173
174/// The trimmed, non-blank lines of a clone instance in its file.
175fn fragment_lines(text: &str, span: &CloneSpan) -> Vec<String> {
176    let start = usize::try_from(span.start_line.saturating_sub(1)).unwrap_or(usize::MAX);
177    let count = usize::try_from(span.end_line.saturating_sub(span.start_line) + 1).unwrap_or(0);
178    text.lines()
179        .skip(start)
180        .take(count)
181        .map(str::trim)
182        .filter(|line| !line.is_empty())
183        .map(str::to_owned)
184        .collect()
185}
186
187fn classify_copy(fragment: &[String], head_text: &str) -> CopyState {
188    if fragment.is_empty() {
189        return CopyState::Unchanged(None);
190    }
191    // Each non-blank head line with its 1-based line number.
192    let numbered: Vec<(u32, &str)> = head_text
193        .lines()
194        .enumerate()
195        .map(|(index, line)| (index, line.trim()))
196        .filter(|(_, line)| !line.is_empty())
197        .filter_map(|(index, line)| u32::try_from(index + 1).ok().map(|number| (number, line)))
198        .collect();
199    let head_lines: Vec<&str> = numbered.iter().map(|(_, line)| *line).collect();
200    let verbatim = head_lines.windows(fragment.len()).position(|window| {
201        window
202            .iter()
203            .zip(fragment)
204            .all(|(head, base)| *head == base)
205    });
206    if let Some(start) = verbatim {
207        let first = numbered[start].0;
208        let last = numbered[start + fragment.len() - 1].0;
209        return CopyState::Unchanged(Some((first, last)));
210    }
211    let head_set: FxHashSet<&str> = head_lines.iter().copied().collect();
212    let surviving = fragment
213        .iter()
214        .filter(|line| head_set.contains(line.as_str()))
215        .count();
216    if surviving * 2 < fragment.len() {
217        return CopyState::Removed;
218    }
219    let first_line = fragment
220        .iter()
221        .find(|line| head_set.contains(line.as_str()))
222        .and_then(|line| {
223            head_text
224                .lines()
225                .position(|head| head.trim() == line)
226                .and_then(|index| u32::try_from(index + 1).ok())
227        });
228    CopyState::Edited(first_line)
229}
230
231/// Whether a head clone group still joins the edited copy with the whole of
232/// a sibling. A group that covers only part of the sibling means the clone
233/// shrank: the edit broke the match from that point on.
234fn still_clones(head_groups: &[Vec<CloneSpan>], copy_path: &str, siblings: &[CloneSpan]) -> bool {
235    head_groups.iter().any(|group| {
236        group.iter().any(|instance| instance.path == copy_path)
237            && group.iter().any(|instance| {
238                siblings.iter().any(|sibling| {
239                    sibling.path == instance.path
240                        && instance.start_line <= sibling.start_line
241                        && sibling.end_line <= instance.end_line
242                })
243            })
244    })
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    const COPY: &str = "export function total(items) {\n  let sum = 0;\n  for (const item of items) {\n    sum += item.price * item.quantity;\n  }\n  return sum;\n}\n";
252    const EDITED: &str = "export function total(items) {\n  let sum = 0;\n  for (const item of items) {\n    sum += item.price * item.quantity * (1 - item.discount);\n  }\n  return sum;\n}\n";
253
254    fn span(path: &str) -> CloneSpan {
255        CloneSpan {
256            path: path.to_owned(),
257            start_line: 1,
258            end_line: 7,
259        }
260    }
261
262    fn group() -> Vec<BaseCloneGroup> {
263        vec![BaseCloneGroup {
264            instances: vec![span("src/a.ts"), span("src/b.ts")],
265        }]
266    }
267
268    fn run(
269        changed: &[&str],
270        head_a: Option<&str>,
271        head_b: &str,
272        head_groups: &[Vec<CloneSpan>],
273    ) -> Vec<DivergedClone> {
274        let changed: FxHashSet<String> = changed.iter().map(|path| (*path).to_owned()).collect();
275        let head_a = head_a.map(str::to_owned);
276        let head_b = head_b.to_owned();
277        let mut base = |_: &str| Some(COPY.to_owned());
278        let mut head = move |path: &str| match path {
279            "src/a.ts" => head_a.clone(),
280            _ => Some(head_b.clone()),
281        };
282        diverged_clone_siblings(
283            &group(),
284            head_groups,
285            &changed,
286            &FxHashMap::default(),
287            &mut CloneTexts {
288                base: &mut base,
289                head: &mut head,
290            },
291        )
292    }
293
294    #[test]
295    fn edited_copy_with_unchanged_sibling_is_reported() {
296        let found = run(&["src/a.ts"], Some(EDITED), COPY, &[]);
297        assert_eq!(found.len(), 1);
298        assert_eq!(found[0].path, "src/a.ts");
299        assert_eq!(found[0].line, Some(1));
300        assert_eq!(found[0].siblings, vec![span("src/b.ts")]);
301    }
302
303    #[test]
304    fn both_copies_edited_is_not_reported() {
305        assert!(run(&["src/a.ts", "src/b.ts"], Some(EDITED), EDITED, &[]).is_empty());
306    }
307
308    #[test]
309    fn edit_that_keeps_the_clone_is_not_reported() {
310        let head_groups = vec![vec![span("src/a.ts"), span("src/b.ts")]];
311        assert!(run(&["src/a.ts"], Some(EDITED), COPY, &head_groups).is_empty());
312    }
313
314    #[test]
315    fn edit_that_shrinks_the_clone_to_a_prefix_is_reported() {
316        let prefix = |path: &str| CloneSpan {
317            path: path.to_owned(),
318            start_line: 1,
319            end_line: 3,
320        };
321        let head_groups = vec![vec![prefix("src/a.ts"), prefix("src/b.ts")]];
322        assert_eq!(
323            run(&["src/a.ts"], Some(EDITED), COPY, &head_groups).len(),
324            1
325        );
326    }
327
328    #[test]
329    fn two_groups_on_one_edited_copy_report_once_with_every_sibling() {
330        let short = |path: &str| CloneSpan {
331            path: path.to_owned(),
332            start_line: 1,
333            end_line: 6,
334        };
335        let groups = vec![
336            BaseCloneGroup {
337                instances: vec![span("src/a.ts"), span("src/b.ts")],
338            },
339            BaseCloneGroup {
340                instances: vec![short("src/a.ts"), short("src/c.ts")],
341            },
342        ];
343        let changed: FxHashSet<String> = std::iter::once("src/a.ts".to_owned()).collect();
344        let mut base = |_: &str| Some(COPY.to_owned());
345        let mut head = |path: &str| Some(if path == "src/a.ts" { EDITED } else { COPY }.to_owned());
346        let found = diverged_clone_siblings(
347            &groups,
348            &[],
349            &changed,
350            &FxHashMap::default(),
351            &mut CloneTexts {
352                base: &mut base,
353                head: &mut head,
354            },
355        );
356        assert_eq!(found.len(), 1);
357        let paths: Vec<&str> = found[0].siblings.iter().map(|s| s.path.as_str()).collect();
358        assert_eq!(paths, vec!["src/b.ts", "src/c.ts"]);
359    }
360
361    const SHIFTED: &str = "import { round } from './round';\n";
362
363    fn shifted(path: &str) -> CloneSpan {
364        CloneSpan {
365            path: path.to_owned(),
366            start_line: 2,
367            end_line: 8,
368        }
369    }
370
371    #[test]
372    fn sibling_shifted_in_a_changed_file_that_still_clones_is_not_reported() {
373        let head_b = format!("{SHIFTED}{COPY}");
374        let head_groups = vec![vec![span("src/a.ts"), shifted("src/b.ts")]];
375        assert!(
376            run(
377                &["src/a.ts", "src/b.ts"],
378                Some(EDITED),
379                &head_b,
380                &head_groups
381            )
382            .is_empty()
383        );
384    }
385
386    #[test]
387    fn sibling_shifted_in_a_changed_file_reports_its_head_lines() {
388        let head_b = format!("{SHIFTED}{COPY}");
389        let found = run(&["src/a.ts", "src/b.ts"], Some(EDITED), &head_b, &[]);
390        assert_eq!(found.len(), 1);
391        assert_eq!(found[0].siblings, vec![shifted("src/b.ts")]);
392    }
393
394    #[test]
395    fn change_elsewhere_in_the_file_is_not_reported() {
396        let head = format!("{COPY}export const other = 1;\n");
397        assert!(run(&["src/a.ts"], Some(&head), COPY, &[]).is_empty());
398    }
399
400    #[test]
401    fn removed_or_deleted_copy_is_not_reported() {
402        assert!(run(&["src/a.ts"], Some("export const x = 1;\n"), COPY, &[]).is_empty());
403        assert!(run(&["src/a.ts"], None, COPY, &[]).is_empty());
404    }
405}