use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash as _, Hasher as _};
use std::ops::Range;
use serde_json::Value;
use similar::{Algorithm, DiffOp, capture_diff_slices};
pub const CONTEXT: usize = 3;
pub const ARRAY_SUMMARY: usize = 16;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineKind {
Context,
Added,
Removed,
Summary,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiffLine {
pub kind: LineKind,
pub old: Option<usize>,
pub new: Option<usize>,
pub text: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hunk {
pub lines: Range<usize>,
pub old_start: usize,
pub old_len: usize,
pub new_start: usize,
pub new_len: usize,
}
impl Hunk {
pub fn header(&self) -> String {
format!("@@ -{},{} +{},{} @@", self.old_start, self.old_len, self.new_start, self.new_len)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Diff {
pub lines: Vec<DiffLine>,
pub hunks: Vec<Hunk>,
pub json: bool,
pub note: Option<String>,
}
impl Diff {
pub fn text(old: &str, new: &str, context: usize) -> Diff {
let (a, b): (Vec<&str>, Vec<&str>) = (old.lines().collect(), new.lines().collect());
let lines = walk(&a, &b, |i| a[i].to_string(), |i| b[i].to_string());
let hunks = hunks(&lines, context);
Diff { lines, hunks, json: false, note: None }
}
pub fn json(old: &str, new: &str, context: usize, array_summary: usize) -> Diff {
let parse = |s: &str| if s.trim().is_empty() { Ok(Value::Null) } else { serde_json::from_str::<Value>(s) };
match (parse(old), parse(new)) {
(Ok(a), Ok(b)) => Diff::json_values(&a, &b, context, array_summary),
(a, b) => {
let which = if a.is_err() { "old" } else { "new" };
let e = a.err().or(b.err()).map(|e| e.to_string()).unwrap_or_default();
Diff { note: Some(format!("The {which} side is not JSON ({e}); compared as text.")), ..Diff::text(old, new, context) }
}
}
}
pub fn json_values(old: &Value, new: &Value, context: usize, array_summary: usize) -> Diff {
let (a, b) = (print_json(old, array_summary), print_json(new, array_summary));
let (ka, kb): (Vec<&str>, Vec<&str>) = (a.iter().map(|l| l.key.as_str()).collect(), b.iter().map(|l| l.key.as_str()).collect());
let mut lines = walk(&ka, &kb, |i| a[i].display.clone(), |i| b[i].display.clone());
merge_summaries(&mut lines, &a, &b);
let hunks = hunks(&lines, context);
Diff { lines, hunks, json: true, note: None }
}
pub fn is_empty(&self) -> bool {
self.hunks.is_empty()
}
pub fn added(&self) -> usize {
self.lines.iter().filter(|l| matches!(l.kind, LineKind::Added | LineKind::Summary)).count()
}
pub fn removed(&self) -> usize {
self.lines.iter().filter(|l| matches!(l.kind, LineKind::Removed | LineKind::Summary)).count()
}
pub fn gaps(&self) -> Vec<Range<usize>> {
let mut out = Vec::new();
let mut at = 0;
for h in &self.hunks {
if h.lines.start > at {
out.push(at..h.lines.start);
}
at = h.lines.end;
}
if at < self.lines.len() {
out.push(at..self.lines.len());
}
out
}
pub fn unified(&self) -> String {
let mut out = String::new();
for h in &self.hunks {
out.push_str(&h.header());
out.push('\n');
for l in &self.lines[h.lines.clone()] {
out.push(match l.kind {
LineKind::Context => ' ',
LineKind::Added => '+',
LineKind::Removed => '-',
LineKind::Summary => '~',
});
out.push_str(&l.text);
out.push('\n');
}
}
out
}
pub fn apply(&self, accepted: &[bool]) -> Option<String> {
if self.json {
return None;
}
let hunk_of = |i: usize| self.hunks.iter().position(|h| h.lines.contains(&i));
let mut out: Vec<&str> = Vec::new();
for (i, l) in self.lines.iter().enumerate() {
let take = match l.kind {
LineKind::Context | LineKind::Summary => true,
LineKind::Added => hunk_of(i).is_some_and(|h| accepted.get(h).copied().unwrap_or(false)),
LineKind::Removed => !hunk_of(i).is_some_and(|h| accepted.get(h).copied().unwrap_or(false)),
};
if take {
out.push(&l.text);
}
}
let mut s = out.join("\n");
if !s.is_empty() {
s.push('\n');
}
Some(s)
}
}
fn walk<K: Eq + std::hash::Hash>(a: &[K], b: &[K], old_text: impl Fn(usize) -> String, new_text: impl Fn(usize) -> String) -> Vec<DiffLine> {
let mut lines = Vec::with_capacity(a.len().max(b.len()));
let removed = |lines: &mut Vec<DiffLine>, from: usize, len: usize| {
lines.extend((from..from + len).map(|i| DiffLine { kind: LineKind::Removed, old: Some(i + 1), new: None, text: old_text(i) }));
};
let added = |lines: &mut Vec<DiffLine>, from: usize, len: usize| {
lines.extend((from..from + len).map(|i| DiffLine { kind: LineKind::Added, old: None, new: Some(i + 1), text: new_text(i) }));
};
for op in capture_diff_slices(Algorithm::Myers, a, b) {
match op {
DiffOp::Equal { old_index, new_index, len } => {
lines.extend((0..len).map(|k| DiffLine { kind: LineKind::Context, old: Some(old_index + k + 1), new: Some(new_index + k + 1), text: new_text(new_index + k) }));
}
DiffOp::Delete { old_index, old_len, .. } => removed(&mut lines, old_index, old_len),
DiffOp::Insert { new_index, new_len, .. } => added(&mut lines, new_index, new_len),
DiffOp::Replace { old_index, old_len, new_index, new_len } => {
removed(&mut lines, old_index, old_len);
added(&mut lines, new_index, new_len);
}
}
}
lines
}
fn hunks(lines: &[DiffLine], context: usize) -> Vec<Hunk> {
let mut ranges: Vec<Range<usize>> = Vec::new();
for (i, l) in lines.iter().enumerate() {
if l.kind == LineKind::Context {
continue;
}
let r = i.saturating_sub(context)..(i + 1 + context).min(lines.len());
match ranges.last_mut() {
Some(last) if r.start <= last.end => last.end = last.end.max(r.end),
_ => ranges.push(r),
}
}
ranges
.into_iter()
.map(|r| {
let before = &lines[..r.start];
let old_before = before.iter().filter(|l| l.old.is_some()).count();
let new_before = before.iter().filter(|l| l.new.is_some()).count();
let inside = &lines[r.clone()];
let old_len = inside.iter().filter(|l| l.old.is_some()).count();
let new_len = inside.iter().filter(|l| l.new.is_some()).count();
Hunk {
lines: r,
old_start: if old_len > 0 { old_before + 1 } else { old_before },
old_len,
new_start: if new_len > 0 { new_before + 1 } else { new_before },
new_len,
}
})
.collect()
}
struct JsonLine {
key: String,
display: String,
array: Option<(String, usize)>,
}
fn print_json(v: &Value, array_summary: usize) -> Vec<JsonLine> {
let mut out = Vec::new();
print_value(v, 0, "", None, false, array_summary, &mut out);
out
}
fn print_value(v: &Value, depth: usize, prefix: &str, label: Option<&str>, comma: bool, max: usize, out: &mut Vec<JsonLine>) {
let pad = " ".repeat(depth);
let c = if comma { "," } else { "" };
let mut line = |body: String, array: Option<(String, usize)>, hash: Option<u64>| {
let key = match hash {
Some(h) => format!("{pad}{prefix}{body}#{h:016x}"),
None => format!("{pad}{prefix}{body}"),
};
out.push(JsonLine { display: format!("{pad}{prefix}{body}{c}"), key, array });
};
match v {
Value::Object(map) if !map.is_empty() => {
line("{".into(), None, None);
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let n = keys.len();
for (i, k) in keys.into_iter().enumerate() {
let p = format!("{}: ", Value::String(k.clone()));
print_value(&map[k], depth + 1, &p, Some(k), i + 1 < n, max, out);
}
out.push(JsonLine { key: format!("{pad}}}"), display: format!("{pad}}}{c}"), array: None });
}
Value::Array(items) if items.len() > max => {
let mut h = DefaultHasher::new();
serde_json::to_string(v).unwrap_or_default().hash(&mut h);
let name = label.unwrap_or("items").to_string();
line(format!("[{} items]", group_digits(items.len())), Some((name, items.len())), Some(h.finish()));
}
Value::Array(items) if !items.is_empty() => {
line("[".into(), None, None);
let n = items.len();
for (i, item) in items.iter().enumerate() {
print_value(item, depth + 1, "", label, i + 1 < n, max, out);
}
out.push(JsonLine { key: format!("{pad}]"), display: format!("{pad}]{c}"), array: None });
}
_ => line(serde_json::to_string(v).unwrap_or_default(), None, None),
}
}
fn merge_summaries(lines: &mut Vec<DiffLine>, a: &[JsonLine], b: &[JsonLine]) {
let array_of = |l: &DiffLine| match l.kind {
LineKind::Removed => l.old.and_then(|n| a[n - 1].array.clone()),
LineKind::Added => l.new.and_then(|n| b[n - 1].array.clone()),
_ => None,
};
let mut i = 0;
while i < lines.len() {
if lines[i].kind == LineKind::Context {
i += 1;
continue;
}
let end = (i..lines.len()).find(|&j| lines[j].kind == LineKind::Context).unwrap_or(lines.len());
let mut drop = Vec::new();
for r in i..end {
if lines[r].kind != LineKind::Removed {
continue;
}
let Some((name, old_len)) = array_of(&lines[r]) else { continue };
let found = (i..end).find(|&j| !drop.contains(&j) && lines[j].kind == LineKind::Added && array_of(&lines[j]).is_some_and(|(n, _)| n == name));
if let Some(j) = found {
let new_len = array_of(&lines[j]).map(|(_, n)| n).unwrap_or(0);
let pad: String = lines[r].text.chars().take_while(|c| *c == ' ').collect();
let text = if old_len == new_len {
format!("{pad}{name}: {} items, changed", group_digits(new_len))
} else {
format!("{pad}{name}: {} → {}", group_digits(old_len), group_digits(new_len))
};
lines[r] = DiffLine { kind: LineKind::Summary, old: lines[r].old, new: lines[j].new, text };
drop.push(j);
}
}
drop.sort_unstable();
for j in drop.iter().rev() {
lines.remove(*j);
}
i = end - drop.len();
}
}
pub fn group_digits(n: usize) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, ch) in s.chars().enumerate() {
if i > 0 && (s.len() - i) % 3 == 0 {
out.push(',');
}
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn kinds(d: &Diff) -> String {
d.lines
.iter()
.map(|l| match l.kind {
LineKind::Context => ' ',
LineKind::Added => '+',
LineKind::Removed => '-',
LineKind::Summary => '~',
})
.collect()
}
#[test]
fn text_diff_numbers_lines_and_folds_context() {
let old: String = (1..=20).map(|i| format!("line {i}\n")).collect();
let new = old.replace("line 5\n", "line five\n").replace("line 18\n", "line 18\nline 18b\n");
let d = Diff::text(&old, &new, 2);
assert_eq!(d.added(), 2);
assert_eq!(d.removed(), 1);
assert_eq!(d.hunks.len(), 2, "two changes far apart: two hunks");
let h = &d.hunks[0];
assert_eq!(h.header(), "@@ -3,5 +3,5 @@");
let first: Vec<&DiffLine> = d.lines[h.lines.clone()].iter().collect();
assert_eq!(first[2].kind, LineKind::Removed);
assert_eq!((first[2].old, first[2].new), (Some(5), None));
assert_eq!(first[3].kind, LineKind::Added);
assert_eq!((first[3].old, first[3].new), (None, Some(5)));
assert_eq!(d.hunks[1].header(), "@@ -17,4 +17,5 @@");
let gaps = d.gaps();
assert_eq!(gaps.len(), 2);
assert_eq!(gaps[0], 0..2);
assert_eq!(gaps[1].len(), 9);
assert!(d.gaps().iter().flat_map(|g| d.lines[g.clone()].iter()).all(|l| l.kind == LineKind::Context));
}
#[test]
fn near_changes_share_a_hunk() {
let d = Diff::text("a\nb\nc\nd\ne\n", "a\nB\nc\nD\ne\n", 3);
assert_eq!(d.hunks.len(), 1);
assert_eq!(kinds(&d), " -+ -+ ");
assert!(Diff::text("same\n", "same\n", 3).is_empty());
}
#[test]
fn apply_takes_only_accepted_hunks() {
let old: String = (1..=20).map(|i| format!("l{i}\n")).collect();
let new = old.replace("l2\n", "two\n").replace("l19\n", "nineteen\n");
let d = Diff::text(&old, &new, 1);
assert_eq!(d.hunks.len(), 2);
assert_eq!(d.apply(&[true, true]).unwrap(), new);
assert_eq!(d.apply(&[false, false]).unwrap(), old);
assert_eq!(d.apply(&[false, true]).unwrap(), old.replace("l19\n", "nineteen\n"));
assert!(d.unified().starts_with("@@ -1,3 +1,3 @@\n l1\n-l2\n+two\n"), "{}", d.unified());
}
#[test]
fn json_ignores_key_order_and_trailing_commas() {
let old = r#"{"b": 1, "a": {"y": 2, "x": 1}}"#;
let new = r#"{"a": {"x": 1, "y": 2}, "b": 1}"#;
assert!(Diff::json(old, new, CONTEXT, ARRAY_SUMMARY).is_empty());
let d = Diff::json(r#"{"a": 1}"#, r#"{"a": 1, "b": 2}"#, CONTEXT, ARRAY_SUMMARY);
assert_eq!(kinds(&d), " + ");
assert_eq!(d.lines[1].text, r#" "a": 1,"#);
assert_eq!(d.lines[2].text, r#" "b": 2"#);
}
#[test]
fn json_long_arrays_are_one_summary_line() {
let old = json!({"cells": (0..2539).collect::<Vec<_>>(), "image": "slide-01", "tiles": (0..40).collect::<Vec<_>>()});
let new = json!({"tiles": (0..40).collect::<Vec<_>>(), "image": "slide-01", "cells": (0..2712).collect::<Vec<_>>()});
let d = Diff::json_values(&old, &new, CONTEXT, ARRAY_SUMMARY);
assert_eq!(d.lines.len(), 5, "{:#?}", d.lines);
assert_eq!(kinds(&d), " ~ ");
assert_eq!(d.lines[1].text, " cells: 2,539 → 2,712");
assert_eq!(d.lines[3].text, r#" "tiles": [40 items]"#);
let d = Diff::json_values(&json!({"cells": (0..100).collect::<Vec<_>>()}), &json!({"cells": (1..101).collect::<Vec<_>>()}), CONTEXT, ARRAY_SUMMARY);
assert_eq!(d.lines[1].text, " cells: 100 items, changed");
let d = Diff::json_values(&json!({"steps": [1, 2]}), &json!({"steps": [1, 3]}), CONTEXT, ARRAY_SUMMARY);
assert_eq!(kinds(&d), " -+ ");
}
#[test]
fn json_that_does_not_parse_falls_back_to_text() {
let d = Diff::json("{\"a\": 1}", "{oops", CONTEXT, ARRAY_SUMMARY);
assert!(!d.json);
assert!(d.note.as_deref().is_some_and(|n| n.starts_with("The new side is not JSON")));
assert_eq!(d.added(), 1);
}
#[test]
fn digits_are_grouped() {
assert_eq!(group_digits(0), "0");
assert_eq!(group_digits(999), "999");
assert_eq!(group_digits(2539), "2,539");
assert_eq!(group_digits(1234567), "1,234,567");
}
}