pub const CONTEXT_LINES: usize = 2;
pub const MAX_DIFF_LINES: usize = 60;
const MAX_LINE_LENGTH: usize = 200;
#[derive(Debug, Clone, PartialEq)]
pub struct FileDiff {
pub empty: bool,
pub added: usize,
pub removed: usize,
pub body: String,
}
fn lcs_table(a: &[&str], b: &[&str]) -> Vec<u32> {
let width = b.len() + 1;
let mut table = vec![0_u32; (a.len() + 1) * width];
for i in (0..a.len()).rev() {
for j in (0..b.len()).rev() {
table[i * width + j] = if a[i] == b[j] {
table[(i + 1) * width + j + 1] + 1
} else {
table[(i + 1) * width + j].max(table[i * width + j + 1])
};
}
}
table
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OpKind {
Kept,
Added,
Removed,
}
impl OpKind {
fn mark(self) -> char {
match self {
OpKind::Kept => ' ',
OpKind::Added => '+',
OpKind::Removed => '-',
}
}
}
fn operations<'a>(a: &[&'a str], b: &[&'a str]) -> Vec<(OpKind, &'a str)> {
let width = b.len() + 1;
let table = lcs_table(a, b);
let mut ops = Vec::new();
let mut i = 0;
let mut j = 0;
while i < a.len() && j < b.len() {
if a[i] == b[j] {
ops.push((OpKind::Kept, a[i]));
i += 1;
j += 1;
} else if table[(i + 1) * width + j] >= table[i * width + j + 1] {
ops.push((OpKind::Removed, a[i]));
i += 1;
} else {
ops.push((OpKind::Added, b[j]));
j += 1;
}
}
for rest in &a[i..] {
ops.push((OpKind::Removed, rest));
}
for rest in &b[j..] {
ops.push((OpKind::Added, rest));
}
ops
}
fn point_len(text: &str) -> usize {
text.chars().count()
}
fn clip(text: &str) -> String {
if point_len(text) > MAX_LINE_LENGTH {
let mut cut: String = text.chars().take(MAX_LINE_LENGTH).collect();
cut.push_str(" ...");
cut
} else {
text.to_owned()
}
}
pub fn file_diff(before: &str, after: &str) -> FileDiff {
if before == after {
return FileDiff {
empty: true,
added: 0,
removed: 0,
body: String::new(),
};
}
let a: Vec<&str> = before.split('\n').collect();
let b: Vec<&str> = after.split('\n').collect();
let ops = operations(&a, &b);
let added = ops
.iter()
.filter(|(kind, _)| *kind == OpKind::Added)
.count();
let removed = ops
.iter()
.filter(|(kind, _)| *kind == OpKind::Removed)
.count();
let mut keep = vec![false; ops.len()];
for (index, (kind, _)) in ops.iter().enumerate() {
if *kind == OpKind::Kept {
continue;
}
let from = index.saturating_sub(CONTEXT_LINES);
let to = (index + CONTEXT_LINES).min(ops.len() - 1);
for kept in &mut keep[from..=to] {
*kept = true;
}
}
let mut lines: Vec<String> = Vec::new();
let mut truncated = 0_usize;
let mut previous_kept: Option<usize> = None;
for (index, kept) in keep.iter().enumerate() {
if !kept {
continue;
}
if lines.len() >= MAX_DIFF_LINES {
truncated += 1;
continue;
}
if let Some(previous) = previous_kept
&& index > previous + 1
{
lines.push("@@".to_owned());
}
let (kind, text) = ops[index];
lines.push(format!("{}{}", kind.mark(), clip(text)));
previous_kept = Some(index);
}
if truncated > 0 {
lines.push(format!("@@ {truncated} further line(s) not shown"));
}
FileDiff {
empty: false,
added,
removed,
body: lines.join("\n"),
}
}
pub fn render_diff(path: &str, diff: &FileDiff) -> String {
format!(
"`{path}` +{} -{}\n```diff\n{}\n```",
diff.added, diff.removed, diff.body
)
}
#[cfg(test)]
mod tests;