#[derive(Clone, Copy, Debug, PartialEq)]
enum Op {
Same,
Del,
Ins,
}
const MAX_EDITS: usize = 1000;
const MAX_LINES: usize = 10_000;
const MAX_BYTES: usize = 1 << 20;
fn myers(a: &[&str], b: &[&str]) -> Option<Vec<Op>> {
let (n, m) = (a.len() as isize, b.len() as isize);
let off = (n + m).min(MAX_EDITS as isize) + 1;
let mut v = vec![0isize; 2 * off as usize + 1];
let mut trace: Vec<Vec<isize>> = Vec::new();
for d in 0..=(n + m).min(MAX_EDITS as isize) {
trace.push(v[(off - d - 1) as usize..=(off + d + 1) as usize].to_vec());
for k in (-d..=d).step_by(2) {
let at = |k: isize| v[(off + k) as usize];
let mut x = if k == -d || (k != d && at(k - 1) < at(k + 1)) { at(k + 1) } else { at(k - 1) + 1 };
let mut y = x - k;
while x < n && y < m && a[x as usize] == b[y as usize] {
x += 1;
y += 1;
}
v[(off + k) as usize] = x;
if x >= n && y >= m {
return Some(backtrack(&trace, n, m));
}
}
}
None
}
fn backtrack(trace: &[Vec<isize>], n: isize, m: isize) -> Vec<Op> {
let (mut x, mut y) = (n, m);
let mut ops = Vec::new();
for (d, v) in trace.iter().enumerate().rev() {
let d = d as isize;
let at = |k: isize| v[(k + d + 1) as usize];
let k = x - y;
let prev_k = if k == -d || (k != d && at(k - 1) < at(k + 1)) { k + 1 } else { k - 1 };
let prev_x = at(prev_k);
let prev_y = prev_x - prev_k;
while x > prev_x && y > prev_y {
ops.push(Op::Same);
x -= 1;
y -= 1;
}
if d > 0 {
ops.push(if x == prev_x { Op::Ins } else { Op::Del });
}
(x, y) = (prev_x, prev_y);
}
ops.reverse();
ops
}
pub fn unified(a: &str, b: &str, context: usize) -> Option<Vec<String>> {
if a.len().max(b.len()) > MAX_BYTES {
return None;
}
let (a, b): (Vec<&str>, Vec<&str>) = (a.split_inclusive('\n').collect(), b.split_inclusive('\n').collect());
if a.len().max(b.len()) > MAX_LINES {
return None;
}
let ops = myers(&a, &b).unwrap_or_else(|| [vec![Op::Del; a.len()], vec![Op::Ins; b.len()]].concat());
let mut at = Vec::with_capacity(ops.len());
let (mut i, mut j) = (0, 0);
for op in &ops {
at.push((*op, i, j));
match op {
Op::Same => (i, j) = (i + 1, j + 1),
Op::Del => i += 1,
Op::Ins => j += 1,
}
}
let changes: Vec<usize> = (0..at.len()).filter(|&x| at[x].0 != Op::Same).collect();
let mut out = Vec::new();
let mut c = 0;
while c < changes.len() {
let mut last = c;
while last + 1 < changes.len() && changes[last + 1] - changes[last] <= 2 * context + 1 {
last += 1;
}
let (from, to) = (changes[c].saturating_sub(context), (changes[last] + context).min(at.len() - 1));
let hunk = &at[from..=to];
let count = |skip: Op| hunk.iter().filter(|h| h.0 != skip).count();
let start = |n: usize, line: usize| if n == 0 { line } else { line + 1 };
let (na, nb) = (count(Op::Ins), count(Op::Del));
out.push(format!("@@ -{},{na} +{},{nb} @@", start(na, hunk[0].1), start(nb, hunk[0].2)));
for &(op, i, j) in hunk {
let (mark, line) = match op {
Op::Same => (' ', a[i]),
Op::Del => ('-', a[i]),
Op::Ins => ('+', b[j]),
};
out.push(format!("{mark}{}", line.strip_suffix('\n').unwrap_or(line)));
if !line.ends_with('\n') {
out.push("\\ No newline at end of file".into());
}
}
c = last + 1;
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(s: &str) -> Vec<&str> {
s.split_inclusive('\n').collect()
}
#[test]
fn edit_script_is_minimal() {
let (a, b) = (lines("a\nb\nc\na\nb\nb\na\n"), lines("c\nb\na\nb\na\nc\n"));
let ops = myers(&a, &b).unwrap();
assert_eq!(ops.iter().filter(|o| **o != Op::Same).count(), 5);
assert_eq!(myers(&[], &[]), Some(vec![]));
assert_eq!(myers(&["x\n"], &[]), Some(vec![Op::Del]));
}
fn unified(a: &str, b: &str, context: usize) -> Vec<String> {
super::unified(a, b, context).unwrap()
}
#[test]
fn too_large_files_are_not_compared() {
let lines = |n: usize| "x\n".repeat(n);
assert!(super::unified(&lines(MAX_LINES), &lines(MAX_LINES), 3).is_some());
assert!(super::unified(&lines(MAX_LINES + 1), "", 3).is_none());
assert!(super::unified("", &"x".repeat(MAX_BYTES + 1), 3).is_none());
}
#[test]
fn hunks_with_context() {
assert!(unified("a\nb\n", "a\nb\n", 3).is_empty());
assert_eq!(unified("a\nb\nc\n", "a\nB\nc\n", 3), ["@@ -1,3 +1,3 @@", " a", "-b", "+B", " c"]);
assert_eq!(unified("", "x\n", 3), ["@@ -0,0 +1,1 @@", "+x"]);
assert_eq!(unified("x\n", "", 3), ["@@ -1,1 +0,0 @@", "-x"]);
assert_eq!(unified("a\n", "a", 3), ["@@ -1,1 +1,1 @@", "-a", "+a", "\\ No newline at end of file"]);
let text = |changed: &[usize]| -> String {
(1..=20).map(|i| if changed.contains(&i) { format!("new {i}\n") } else { format!("{i}\n") }).collect()
};
let h = unified(&text(&[]), &text(&[2, 19]), 3);
assert_eq!(h.iter().filter(|l| l.starts_with("@@")).count(), 2, "{h:?}");
assert_eq!(h[0], "@@ -1,5 +1,5 @@");
assert_eq!(h[7], "@@ -16,5 +16,5 @@");
let hunks = |a: &[usize], b: &[usize]| unified(&text(a), &text(b), 3).iter().filter(|l| l.starts_with("@@")).count();
assert_eq!(hunks(&[], &[2, 9]), 1, "6 unchanged lines between changes join");
assert_eq!(hunks(&[], &[2, 10]), 2);
}
#[test]
fn huge_edits_replace_the_file() {
let old: String = (0..MAX_EDITS + 10).map(|i| format!("{i}\n")).collect();
let new: String = (0..MAX_EDITS + 10).map(|i| format!("n{i}\n")).collect();
let h = unified(&old, &new, 3);
assert_eq!(h[0], format!("@@ -1,{0} +1,{0} @@", MAX_EDITS + 10));
assert_eq!(h.len(), 1 + 2 * (MAX_EDITS + 10));
}
}