//! 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, } 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 { 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 { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: 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 { kind: Kind::Del, old_no: Some(old_no), new_no: None, text: l.clone() }); } else { new_no += 1; rows.push(Line { kind: Kind::Context, old_no: Some(old_no), new_no: Some(new_no), text: l.clone() }); } if let Some(lines) = added.get(&old_no) { for a in lines { new_no += 1; rows.push(Line { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: 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 { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: a.clone() }); } } } with_context(rows, context) } /// 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 { kind: Kind::Gap, old_no: None, new_no: None, text: format!("{gap} unchanged lines") }); gap = 0; } out.push(row); } else { gap += 1; } } if gap > 0 { out.push(Line { kind: Kind::Gap, old_no: None, new_no: None, text: 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 } /// 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]); } }