use std::fmt::Write as _;
const RED: &str = "\x1b[31m";
const GREEN: &str = "\x1b[32m";
const RESET: &str = "\x1b[0m";
const DIM: &str = "\x1b[2m";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffResult<T> {
Left(T),
Both(T, T),
Right(T),
}
fn lcs_table<T: PartialEq>(left: &[T], right: &[T]) -> Vec<Vec<usize>> {
let m = left.len();
let n = right.len();
let mut table = vec![vec![0; n + 1]; m + 1];
for i in 1..=m {
for j in 1..=n {
if left[i - 1] == right[j - 1] {
table[i][j] = table[i - 1][j - 1] + 1;
} else {
table[i][j] = table[i - 1][j].max(table[i][j - 1]);
}
}
}
table
}
fn backtrack<T: Clone + PartialEq>(
left: &[T],
right: &[T],
table: &[Vec<usize>],
i: usize,
j: usize,
result: &mut Vec<DiffResult<T>>,
) {
if i == 0 && j == 0 {
return;
}
if i > 0 && j > 0 && left[i - 1] == right[j - 1] {
backtrack(left, right, table, i - 1, j - 1, result);
result.push(DiffResult::Both(left[i - 1].clone(), right[j - 1].clone()));
} else if j > 0 && (i == 0 || table[i][j - 1] >= table[i - 1][j]) {
backtrack(left, right, table, i, j - 1, result);
result.push(DiffResult::Right(right[j - 1].clone()));
} else if i > 0 {
backtrack(left, right, table, i - 1, j, result);
result.push(DiffResult::Left(left[i - 1].clone()));
}
}
pub fn slice<T: Clone + PartialEq>(left: &[T], right: &[T]) -> Vec<DiffResult<T>> {
let table = lcs_table(left, right);
let mut result = Vec::new();
backtrack(left, right, &table, left.len(), right.len(), &mut result);
result
}
pub fn lines<'a>(left: &'a str, right: &'a str) -> Vec<DiffResult<&'a str>> {
let left_lines: Vec<&str> = left.lines().collect();
let right_lines: Vec<&str> = right.lines().collect();
slice(&left_lines, &right_lines)
}
#[derive(Debug, Default, Clone, Copy)]
pub struct DiffOpts {
pub context: Option<usize>,
pub color: bool,
}
pub fn render(a: &str, b: &str, opts: DiffOpts) -> String {
let results = lines(a, b);
#[allow(clippy::option_if_let_else)] let keep: Vec<bool> = match opts.context {
None => vec![true; results.len()],
Some(ctx) => {
let mut keep = vec![false; results.len()];
for (i, r) in results.iter().enumerate() {
if !matches!(r, DiffResult::Both(..)) {
let lo = i.saturating_sub(ctx);
let hi = (i + ctx + 1).min(results.len());
#[allow(clippy::needless_range_loop)]
for k in lo..hi {
keep[k] = true;
}
}
}
keep
}
};
let (red, green, dim, reset) = if opts.color {
(RED, GREEN, DIM, RESET)
} else {
("", "", "", "")
};
let mut out = String::new();
let mut last_kept = true;
for (i, r) in results.iter().enumerate() {
if !keep[i] {
if last_kept {
out.push_str(" ...\n");
}
last_kept = false;
continue;
}
last_kept = true;
match r {
DiffResult::Left(l) => {
let _ = writeln!(out, "{red}- {l}{reset}");
}
DiffResult::Right(r) => {
let _ = writeln!(out, "{green}+ {r}{reset}");
}
DiffResult::Both(l, _) => {
let _ = writeln!(out, "{dim} {l}{reset}");
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identical_strings() {
let left = "foo\nbar\nbaz";
let right = "foo\nbar\nbaz";
let result = lines(left, right);
assert_eq!(result.len(), 3);
assert!(matches!(result[0], DiffResult::Both("foo", "foo")));
assert!(matches!(result[1], DiffResult::Both("bar", "bar")));
assert!(matches!(result[2], DiffResult::Both("baz", "baz")));
}
#[test]
fn test_addition() {
let left = "foo\nbaz";
let right = "foo\nbar\nbaz";
let result = lines(left, right);
assert_eq!(result.len(), 3);
assert!(matches!(result[0], DiffResult::Both("foo", "foo")));
assert!(matches!(result[1], DiffResult::Right("bar")));
assert!(matches!(result[2], DiffResult::Both("baz", "baz")));
}
#[test]
fn test_removal() {
let left = "foo\nbar\nbaz";
let right = "foo\nbaz";
let result = lines(left, right);
assert_eq!(result.len(), 3);
assert!(matches!(result[0], DiffResult::Both("foo", "foo")));
assert!(matches!(result[1], DiffResult::Left("bar")));
assert!(matches!(result[2], DiffResult::Both("baz", "baz")));
}
#[test]
fn test_replacement() {
let left = "foo\nbar\nbaz";
let right = "foo\nqux\nbaz";
let result = lines(left, right);
assert_eq!(result.len(), 4);
assert!(matches!(result[0], DiffResult::Both("foo", "foo")));
assert!(matches!(result[1], DiffResult::Left("bar")));
assert!(matches!(result[2], DiffResult::Right("qux")));
assert!(matches!(result[3], DiffResult::Both("baz", "baz")));
}
#[test]
fn test_empty_strings() {
let left = "";
let right = "";
let result = lines(left, right);
assert_eq!(result.len(), 0);
}
#[test]
fn test_empty_left() {
let left = "";
let right = "foo\nbar";
let result = lines(left, right);
assert_eq!(result.len(), 2);
assert!(matches!(result[0], DiffResult::Right("foo")));
assert!(matches!(result[1], DiffResult::Right("bar")));
}
#[test]
fn test_empty_right() {
let left = "foo\nbar";
let right = "";
let result = lines(left, right);
assert_eq!(result.len(), 2);
assert!(matches!(result[0], DiffResult::Left("foo")));
assert!(matches!(result[1], DiffResult::Left("bar")));
}
#[test]
fn test_slice_with_integers() {
let left = vec![1, 2, 3, 4];
let right = vec![1, 2, 5, 4];
let result = slice(&left, &right);
assert_eq!(result.len(), 5);
assert!(matches!(result[0], DiffResult::Both(1, 1)));
assert!(matches!(result[1], DiffResult::Both(2, 2)));
assert!(matches!(result[2], DiffResult::Left(3)));
assert!(matches!(result[3], DiffResult::Right(5)));
assert!(matches!(result[4], DiffResult::Both(4, 4)));
}
#[test]
fn test_complex_diff() {
let left = "a\nb\nc\nd\ne";
let right = "a\nx\ny\nd\ne\nf";
let result = lines(left, right);
assert_eq!(result.len(), 8);
assert!(matches!(result[0], DiffResult::Both("a", "a")));
assert!(matches!(result[1], DiffResult::Left("b")));
assert!(matches!(result[2], DiffResult::Left("c")));
assert!(matches!(result[3], DiffResult::Right("x")));
assert!(matches!(result[4], DiffResult::Right("y")));
assert!(matches!(result[5], DiffResult::Both("d", "d")));
assert!(matches!(result[6], DiffResult::Both("e", "e")));
assert!(matches!(result[7], DiffResult::Right("f")));
}
}