//! Diff rendering: `oxy`'s per-file hunks become unified rows with //! context, and two snapshots become per-file hunks. use oxy_core::repo::{DiffHunk, FileDiff}; use oxy_core::state::{FileContent, Snapshot}; use similar::{Algorithm, DiffOp}; use std::collections::BTreeMap; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { Context, Add, Del, /// Elided lines between hunks. Gap, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Line { pub kind: Kind, pub old_no: Option, pub new_no: Option, /// Raw text, not escaped. pub text: String, /// For a changed line paired with its counterpart: the text in runs, /// marking the words that changed. Empty means show `text` as is. pub segments: Vec, } /// A run of a changed line's text. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Segment { pub text: String, pub changed: bool, } /// How to render a diff. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Options { /// Treat lines that differ only in whitespace as unchanged (`?w=1`). pub ignore_whitespace: bool, } impl Line { fn new(kind: Kind, old_no: Option, new_no: Option, text: String) -> Line { Line { kind, old_no, new_no, text, segments: vec![] } } } impl Kind { pub fn class(self) -> &'static str { match self { Kind::Context => "ctx", Kind::Add => "add", Kind::Del => "del", Kind::Gap => "gap", } } pub fn sign(self) -> &'static str { match self { Kind::Context => " ", Kind::Add => "+", Kind::Del => "-", Kind::Gap => "…", } } } fn text(b: &[u8]) -> String { String::from_utf8_lossy(b).trim_end_matches(['\n', '\r']).to_string() } /// Unified rows for one file: `base` is the file before the change (empty /// for an added file) and `hunks` come from `oxy diff`, whose `base_line` /// is the 1-based line the additions follow (0 = before the first line). pub fn unified(base: &[u8], hunks: &[DiffHunk], context: usize) -> Vec { unified_with(base, hunks, context, Options::default()) } /// `unified`, with options, and changed lines refined word by word. pub fn unified_with(base: &[u8], hunks: &[DiffHunk], context: usize, opts: Options) -> Vec { let base_lines: Vec = oxy_core::diff::split_lines(base).into_iter().map(text).collect(); // Turn hunks into a per-old-line plan: removed set, and additions keyed // by the base line they follow. let mut removed = std::collections::HashSet::new(); let mut added: BTreeMap> = BTreeMap::new(); for h in hunks { for (line, _) in &h.removed { removed.insert(*line); } added.entry(h.base_line).or_default().extend(h.added.iter().map(|b| text(b))); } // Walk the base, emitting rows; then trim to context around changes. let mut rows: Vec = Vec::new(); let mut new_no: u32 = 0; if let Some(lines) = added.get(&0) { for l in lines { new_no += 1; rows.push(Line::new(Kind::Add, None, Some(new_no), l.clone())); } } for (i, l) in base_lines.iter().enumerate() { let old_no = i as u32 + 1; if removed.contains(&old_no) { rows.push(Line::new(Kind::Del, Some(old_no), None, l.clone())); } else { new_no += 1; rows.push(Line::new(Kind::Context, Some(old_no), Some(new_no), l.clone())); } if let Some(lines) = added.get(&old_no) { for a in lines { new_no += 1; rows.push(Line::new(Kind::Add, None, Some(new_no), a.clone())); } } } // Any additions anchored past the end (defensive). for (anchor, lines) in &added { if *anchor as usize > base_lines.len() { for a in lines { new_no += 1; rows.push(Line::new(Kind::Add, None, Some(new_no), a.clone())); } } } with_context(refine(rows, opts), context) } /// Pair the removed and added lines of each block of changes, line by /// line. A pair that differs only in whitespace becomes one /// context line when whitespace is ignored; otherwise a similar pair gets /// word-level segments so the page can mark what changed within it. fn refine(rows: Vec, opts: Options) -> Vec { let mut out: Vec = Vec::with_capacity(rows.len()); let mut i = 0; while i < rows.len() { let changed = |k: usize| matches!(rows[k].kind, Kind::Del | Kind::Add); if !changed(i) { out.push(rows[i].clone()); i += 1; continue; } // One block of changed lines, whichever order oxy's hunks put them // in; it is shown removals first, then additions. let end = (i..rows.len()).find(|&k| !changed(k)).unwrap_or(rows.len()); let mut dels: Vec = rows[i..end].iter().filter(|l| l.kind == Kind::Del).cloned().collect(); let mut adds: Vec = rows[i..end].iter().filter(|l| l.kind == Kind::Add).cloned().collect(); let adds_end = end; let pairs = dels.len().min(adds.len()); let mut same_but_space = vec![false; pairs]; for k in 0..pairs { if opts.ignore_whitespace && squash(&dels[k].text) == squash(&adds[k].text) { same_but_space[k] = true; } else if let Some((d, a)) = words(&dels[k].text, &adds[k].text) { dels[k].segments = d; adds[k].segments = a; } } if same_but_space.iter().any(|x| *x) { // Keep the file's order: changed lines before an unchanged pair // come out as removals then additions, then the pair as context. let (mut pd, mut pa): (Vec, Vec) = (Vec::new(), Vec::new()); for k in 0..pairs { if same_but_space[k] { out.append(&mut pd); out.append(&mut pa); out.push(Line::new(Kind::Context, dels[k].old_no, adds[k].new_no, adds[k].text.clone())); } else { pd.push(dels[k].clone()); pa.push(adds[k].clone()); } } out.append(&mut pd); out.extend(dels.drain(pairs..)); out.append(&mut pa); out.extend(adds.drain(pairs..)); } else { out.extend(dels); out.extend(adds); } i = adds_end; } out } /// A line split into identifiers, whitespace runs and single other /// characters, so `42;` changing to `43;` marks only the number. fn tokens(s: &str) -> Vec<&str> { let mut out = Vec::new(); let mut start = 0; let class = |c: char| { if c.is_alphanumeric() || c == '_' { 0 } else if c.is_whitespace() { 1 } else { 2 } }; let mut prev: Option = None; for (i, c) in s.char_indices() { let k = class(c); if prev.is_some_and(|p| p != k || k == 2) { out.push(&s[start..i]); start = i; } prev = Some(k); } if start < s.len() { out.push(&s[start..]); } out } /// Text with every run of whitespace removed, for comparing ignoring it. fn squash(s: &str) -> String { s.split_whitespace().collect() } /// Word-level runs of an old and a new line, or None when the lines share /// too little for marking words to help (then the whole line reads as changed). fn words(old: &str, new: &str) -> Option<(Vec, Vec)> { if old.len() + new.len() > 2_000 { return None; } let (ot, nt) = (tokens(old), tokens(new)); let diff = similar::TextDiff::configure().algorithm(Algorithm::Patience).diff_slices(&ot, &nt); if diff.ratio() < 0.4 { return None; } let (mut d, mut a) = (Vec::new(), Vec::new()); let push = |v: &mut Vec, text: &str, changed: bool| match v.last_mut() { Some(last) if last.changed == changed => last.text.push_str(text), _ => v.push(Segment { text: text.to_string(), changed }), }; for change in diff.iter_all_changes() { match change.tag() { similar::ChangeTag::Equal => { push(&mut d, change.value(), false); push(&mut a, change.value(), false); } similar::ChangeTag::Delete => push(&mut d, change.value(), true), similar::ChangeTag::Insert => push(&mut a, change.value(), true), } } Some((d, a)) } /// Keep `context` rows around every change, replacing longer runs with a gap. fn with_context(rows: Vec, context: usize) -> Vec { let changed: Vec = rows.iter().map(|r| r.kind != Kind::Context).collect(); let mut keep = vec![false; rows.len()]; for (i, c) in changed.iter().enumerate() { if *c { let lo = i.saturating_sub(context); let hi = (i + context + 1).min(rows.len()); for k in keep.iter_mut().take(hi).skip(lo) { *k = true; } } } let mut out = Vec::new(); let mut gap = 0; for (row, k) in rows.into_iter().zip(keep) { if k { if gap > 0 { out.push(Line::new(Kind::Gap, None, None, format!("{gap} unchanged lines"))); gap = 0; } out.push(row); } else { gap += 1; } } if gap > 0 { out.push(Line::new(Kind::Gap, None, None, format!("{gap} unchanged lines"))); } out } /// Hunks between two texts, in `oxy diff`'s shape. pub fn hunks_between(old: &[u8], new: &[u8]) -> Vec { let old_lines = oxy_core::diff::split_lines(old); let new_lines = oxy_core::diff::split_lines(new); let ops = similar::capture_diff_slices(Algorithm::Myers, &old_lines, &new_lines); let mut hunks: Vec = Vec::new(); for op in ops { match op { DiffOp::Equal { .. } => {} DiffOp::Delete { old_index, old_len, .. } => { hunks.push(DiffHunk { base_line: (old_index + old_len) as u32, added: vec![], removed: (old_index..old_index + old_len).map(|i| (i as u32 + 1, old_lines[i].to_vec())).collect(), }); } DiffOp::Insert { old_index, new_index, new_len } => { hunks.push(DiffHunk { base_line: old_index as u32, added: (new_index..new_index + new_len).map(|i| new_lines[i].to_vec()).collect(), removed: vec![], }); } DiffOp::Replace { old_index, old_len, new_index, new_len } => { hunks.push(DiffHunk { base_line: (old_index + old_len) as u32, added: (new_index..new_index + new_len).map(|i| new_lines[i].to_vec()).collect(), removed: (old_index..old_index + old_len).map(|i| (i as u32 + 1, old_lines[i].to_vec())).collect(), }); } } } hunks } /// The per-file diff of one change against its base state (what /// `oxy diff CHANGE` shows), using an already loaded base. pub fn change_files(change: &oxy_core::objects::Change, base: &oxy_core::state::State, base_snapshot: &Snapshot) -> Vec { oxy_core::repo::diff_files_of(change, base, base_snapshot) } /// Every file that differs between two snapshots, with the base content /// of files present in both (for context). pub fn snapshot_diff(base: &Snapshot, head: &Snapshot) -> (Vec, BTreeMap>) { let mut files = Vec::new(); let mut bases = BTreeMap::new(); let text_of = |f: &oxy_core::state::FileState| match &f.content { FileContent::Text { bytes, .. } => Some(bytes.clone()), _ => None, }; for (path, hf) in &head.files { if hf.kind == oxy_core::objects::NodeKind::Dir { continue; } match base.files.get(path) { None => { let new = text_of(hf).unwrap_or_default(); files.push(FileDiff { path: path.clone(), kind: "added".into(), hunks: hunks_between(b"", &new), from: None }); } Some(bf) => { let (old, new) = (text_of(bf), text_of(hf)); if old != new { let old_b = old.clone().unwrap_or_default(); let hunks = hunks_between(&old_b, &new.unwrap_or_default()); if let Some(o) = old { bases.insert(path.clone(), o); } files.push(FileDiff { path: path.clone(), kind: "modified".into(), hunks, from: None }); } else if bf.mode != hf.mode { files.push(FileDiff { path: path.clone(), kind: "mode".into(), hunks: vec![], from: None }); } } } } for (path, bf) in &base.files { if bf.kind != oxy_core::objects::NodeKind::Dir && !head.files.contains_key(path) { let old = text_of(bf).unwrap_or_default(); bases.insert(path.clone(), old.clone()); files.push(FileDiff { path: path.clone(), kind: "removed".into(), hunks: hunks_between(&old, b""), from: None }); } } files.sort_by(|a, b| a.path.cmp(&b.path)); (files, bases) } #[cfg(test)] mod tests { use super::*; #[test] fn unified_rows_with_context() { let base = b"a\nb\nc\nd\ne\nf\ng\nh\n"; let hunks = hunks_between(base, b"a\nb\nc\nX\ne\nf\ng\nh\nZ\n"); let rows = unified(base, &hunks, 1); let s: Vec = rows.iter().map(|r| format!("{}{}", r.kind.sign(), r.text)).collect(); assert_eq!(s, ["…2 unchanged lines", " c", "-d", "+X", " e", "…2 unchanged lines", " h", "+Z"]); assert_eq!(rows[2].old_no, Some(4)); assert_eq!(rows[3].new_no, Some(4)); assert_eq!(rows.last().unwrap().new_no, Some(9)); } #[test] fn added_file() { let hunks = hunks_between(b"", b"one\ntwo\n"); let rows = unified(b"", &hunks, 3); assert_eq!(rows.iter().map(|r| r.kind).collect::>(), [Kind::Add, Kind::Add]); } } #[cfg(test)] mod refine_tests { use super::*; fn rows(base: &str, new: &str, opts: Options) -> Vec { unified_with(base.as_bytes(), &hunks_between(base.as_bytes(), new.as_bytes()), 3, opts) } #[test] fn changed_words_are_marked_within_a_line() { let r = rows("let answer = 42;\n", "let answer = 43;\n", Options::default()); let del = r.iter().find(|l| l.kind == Kind::Del).unwrap(); let add = r.iter().find(|l| l.kind == Kind::Add).unwrap(); assert_eq!(del.segments.iter().filter(|s| s.changed).map(|s| s.text.as_str()).collect::>(), ["42"]); assert_eq!(add.segments.iter().filter(|s| s.changed).map(|s| s.text.as_str()).collect::>(), ["43"]); assert_eq!(add.segments.iter().map(|s| s.text.as_str()).collect::(), add.text, "segments cover the line"); // Lines with nothing in common are not broken into words. let r = rows("alpha beta\n", "gamma delta\n", Options::default()); assert!(r.iter().all(|l| l.segments.is_empty())); } #[test] fn whitespace_changes_can_be_hidden() { let base = "fn main() {\n a();\n b();\n}\n"; let new = "fn main() {\n a();\n c();\n}\n"; let shown = rows(base, new, Options::default()); assert_eq!(shown.iter().filter(|l| l.kind == Kind::Add).count(), 2); let hidden = rows(base, new, Options { ignore_whitespace: true }); let kinds: Vec<(Kind, &str)> = hidden.iter().map(|l| (l.kind, l.text.trim())).collect(); assert_eq!( kinds, [(Kind::Context, "fn main() {"), (Kind::Context, "a();"), (Kind::Del, "b();"), (Kind::Add, "c();"), (Kind::Context, "}")], "only the real change remains, in order" ); let ctx = hidden.iter().find(|l| l.text.trim() == "a();").unwrap(); assert_eq!((ctx.old_no, ctx.new_no), (Some(2), Some(2))); } }