Skip to main content

declutter/
moves.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::diff::RowKind;
4use crate::review::{DiffModes, FileReview};
5
6/// The shortest run of lines treated as a move; shorter matches are mostly coincidence.
7pub const MIN_MOVED_LINES: usize = 3;
8
9/// Which end of a move a row is.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Direction {
12    /// A removed line that reappears elsewhere.
13    To,
14    /// An added line that was removed elsewhere.
15    From,
16}
17
18/// A changed row that is part of a block moved within the change.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Move {
21    pub direction: Direction,
22    /// Where the other end of the block is: its file and first line.
23    pub path: String,
24    pub line: usize,
25    /// The first row of the block, where the viewer puts the "moved" marker.
26    pub starts_block: bool,
27    pub block_len: usize,
28}
29
30impl Move {
31    /// "5 lines moved to Cart.swift:12", or just the line when the block stayed in `here`.
32    pub fn describe(&self, here: &str) -> String {
33        let direction = match self.direction {
34            Direction::To => "to",
35            Direction::From => "from",
36        };
37        let place = if self.path == here {
38            format!("line {}", self.line)
39        } else {
40            format!("{}:{}", self.path, self.line)
41        };
42        format!("⇄ {} lines moved {direction} {place}", self.block_len)
43    }
44}
45
46/// Moved rows, keyed by (index into `files`, hunk, row).
47pub type Moves = HashMap<(usize, usize, usize), Move>;
48
49struct Entry {
50    key: (usize, usize, usize),
51    path: String,
52    line: usize,
53    /// The line with surrounding whitespace removed, so re-indented moves still match.
54    text: String,
55}
56
57/// Finds blocks of at least `MIN_MOVED_LINES` removed lines that are added again
58/// elsewhere — in the same file or another — under the given layers.
59pub fn detect(files: &[&FileReview], modes: DiffModes) -> Moves {
60    let mut removed: Vec<Vec<Entry>> = Vec::new();
61    let mut added: Vec<Vec<Entry>> = Vec::new();
62    for (file_index, file) in files.iter().enumerate() {
63        let view = file.view(modes);
64        for (hunk_index, hunk) in view.hunks.iter().enumerate() {
65            let mut run_kind = RowKind::Context;
66            for (row_index, row) in hunk.rows.iter().enumerate() {
67                let text = row.text.trim();
68                if row.kind != run_kind {
69                    run_kind = row.kind;
70                    match row.kind {
71                        RowKind::Removed => removed.push(Vec::new()),
72                        RowKind::Added => added.push(Vec::new()),
73                        RowKind::Context => continue,
74                    }
75                }
76                // Blank lines neither count towards a block nor break one.
77                if text.is_empty() || row.kind == RowKind::Context {
78                    continue;
79                }
80                let (runs, line) = match row.kind {
81                    RowKind::Removed => (&mut removed, row.old_line),
82                    _ => (&mut added, row.new_line),
83                };
84                if let (Some(run), Some(line)) = (runs.last_mut(), line) {
85                    run.push(Entry {
86                        key: (file_index, hunk_index, row_index),
87                        path: file.path.clone(),
88                        line,
89                        text: text.to_string(),
90                    });
91                }
92            }
93        }
94    }
95
96    let window = |run: &[Entry], at: usize| -> String {
97        run[at..at + MIN_MOVED_LINES]
98            .iter()
99            .map(|entry| entry.text.as_str())
100            .collect::<Vec<_>>()
101            .join("\n")
102    };
103    let mut index: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
104    for (run_index, run) in added.iter().enumerate() {
105        for at in 0..(run.len() + 1).saturating_sub(MIN_MOVED_LINES) {
106            index
107                .entry(window(run, at))
108                .or_default()
109                .push((run_index, at));
110        }
111    }
112
113    let mut moves = Moves::new();
114    let mut claimed: HashSet<(usize, usize)> = HashSet::new();
115    for run in &removed {
116        let mut at = 0;
117        while at + MIN_MOVED_LINES <= run.len() {
118            let best = index
119                .get(&window(run, at))
120                .into_iter()
121                .flatten()
122                .map(|&(target, start)| {
123                    let other = &added[target];
124                    let len = (0..)
125                        .take_while(|&k| {
126                            at + k < run.len()
127                                && start + k < other.len()
128                                && run[at + k].text == other[start + k].text
129                                && !claimed.contains(&(target, start + k))
130                        })
131                        .count();
132                    (len, target, start)
133                })
134                .max_by_key(|&(len, _, _)| len);
135            let Some((len, target, start)) = best.filter(|&(len, _, _)| len >= MIN_MOVED_LINES)
136            else {
137                at += 1;
138                continue;
139            };
140            let block = &run[at..at + len];
141            // Three closing braces in a row are not a move worth pointing at.
142            let substance: usize = block
143                .iter()
144                .map(|entry| entry.text.chars().filter(|c| c.is_alphanumeric()).count())
145                .sum();
146            if substance < 10 {
147                at += 1;
148                continue;
149            }
150            let destination = &added[target][start..start + len];
151            for (k, (from, to)) in block.iter().zip(destination).enumerate() {
152                claimed.insert((target, start + k));
153                moves.insert(
154                    from.key,
155                    Move {
156                        direction: Direction::To,
157                        path: destination[0].path.clone(),
158                        line: destination[0].line,
159                        starts_block: k == 0,
160                        block_len: len,
161                    },
162                );
163                moves.insert(
164                    to.key,
165                    Move {
166                        direction: Direction::From,
167                        path: block[0].path.clone(),
168                        line: block[0].line,
169                        starts_block: k == 0,
170                        block_len: len,
171                    },
172                );
173            }
174            at += len;
175        }
176    }
177    moves
178}