use std::collections::{HashMap, HashSet};
use crate::diff::RowKind;
use crate::review::{DiffModes, FileReview};
pub const MIN_MOVED_LINES: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
To,
From,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Move {
pub direction: Direction,
pub path: String,
pub line: usize,
pub starts_block: bool,
pub block_len: usize,
}
impl Move {
pub fn describe(&self, here: &str) -> String {
let direction = match self.direction {
Direction::To => "to",
Direction::From => "from",
};
let place = if self.path == here {
format!("line {}", self.line)
} else {
format!("{}:{}", self.path, self.line)
};
format!("⇄ {} lines moved {direction} {place}", self.block_len)
}
}
pub type Moves = HashMap<(usize, usize, usize), Move>;
struct Entry {
key: (usize, usize, usize),
path: String,
line: usize,
text: String,
}
pub fn detect(files: &[&FileReview], modes: DiffModes) -> Moves {
let mut removed: Vec<Vec<Entry>> = Vec::new();
let mut added: Vec<Vec<Entry>> = Vec::new();
for (file_index, file) in files.iter().enumerate() {
let view = file.view(modes);
for (hunk_index, hunk) in view.hunks.iter().enumerate() {
let mut run_kind = RowKind::Context;
for (row_index, row) in hunk.rows.iter().enumerate() {
let text = row.text.trim();
if row.kind != run_kind {
run_kind = row.kind;
match row.kind {
RowKind::Removed => removed.push(Vec::new()),
RowKind::Added => added.push(Vec::new()),
RowKind::Context => continue,
}
}
if text.is_empty() || row.kind == RowKind::Context {
continue;
}
let (runs, line) = match row.kind {
RowKind::Removed => (&mut removed, row.old_line),
_ => (&mut added, row.new_line),
};
if let (Some(run), Some(line)) = (runs.last_mut(), line) {
run.push(Entry {
key: (file_index, hunk_index, row_index),
path: file.path.clone(),
line,
text: text.to_string(),
});
}
}
}
}
let window = |run: &[Entry], at: usize| -> String {
run[at..at + MIN_MOVED_LINES]
.iter()
.map(|entry| entry.text.as_str())
.collect::<Vec<_>>()
.join("\n")
};
let mut index: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
for (run_index, run) in added.iter().enumerate() {
for at in 0..(run.len() + 1).saturating_sub(MIN_MOVED_LINES) {
index
.entry(window(run, at))
.or_default()
.push((run_index, at));
}
}
let mut moves = Moves::new();
let mut claimed: HashSet<(usize, usize)> = HashSet::new();
for run in &removed {
let mut at = 0;
while at + MIN_MOVED_LINES <= run.len() {
let best = index
.get(&window(run, at))
.into_iter()
.flatten()
.map(|&(target, start)| {
let other = &added[target];
let len = (0..)
.take_while(|&k| {
at + k < run.len()
&& start + k < other.len()
&& run[at + k].text == other[start + k].text
&& !claimed.contains(&(target, start + k))
})
.count();
(len, target, start)
})
.max_by_key(|&(len, _, _)| len);
let Some((len, target, start)) = best.filter(|&(len, _, _)| len >= MIN_MOVED_LINES)
else {
at += 1;
continue;
};
let block = &run[at..at + len];
let substance: usize = block
.iter()
.map(|entry| entry.text.chars().filter(|c| c.is_alphanumeric()).count())
.sum();
if substance < 10 {
at += 1;
continue;
}
let destination = &added[target][start..start + len];
for (k, (from, to)) in block.iter().zip(destination).enumerate() {
claimed.insert((target, start + k));
moves.insert(
from.key,
Move {
direction: Direction::To,
path: destination[0].path.clone(),
line: destination[0].line,
starts_block: k == 0,
block_len: len,
},
);
moves.insert(
to.key,
Move {
direction: Direction::From,
path: block[0].path.clone(),
line: block[0].line,
starts_block: k == 0,
block_len: len,
},
);
}
at += len;
}
}
moves
}