//! Line diffs: hunks for merging, unified diffs and counts for people. use std::ops::Range; use imara_diff::intern::InternedInput; use imara_diff::sources::byte_lines_with_terminator; use imara_diff::{Algorithm, diff as imara}; /// Files bigger than this aren't diffed or merged line by line. pub const TEXT_LIMIT: usize = 16 << 20; /// Sealed files (encrypted on their readers' machines) start with this; only those machines merge them. pub const SEALED: &[u8] = b"VCSSEAL1"; pub fn is_binary(bytes: &[u8]) -> bool { bytes.len() > TEXT_LIMIT || bytes.starts_with(SEALED) || bytes[..bytes.len().min(8000)].contains(&0) } /// Lines including their terminators. pub fn lines(bytes: &[u8]) -> Vec<&[u8]> { byte_lines_with_terminator(bytes).collect() } /// A changed region: lines `before` of the old text became lines `after`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Hunk { pub before: Range, pub after: Range, } pub fn hunks(old: &[u8], new: &[u8]) -> Vec { let input = InternedInput::new(byte_lines_with_terminator(old), byte_lines_with_terminator(new)); let mut out = Vec::new(); imara(Algorithm::Histogram, &input, |before: Range, after: Range| { out.push(Hunk { before, after }); }); out } /// Lines added and removed. pub fn counts(old: &[u8], new: &[u8]) -> (usize, usize) { hunks(old, new).iter().fold((0, 0), |(a, r), h| (a + h.after.len(), r + h.before.len())) } /// A unified diff body (the `@@` hunks) with `context` lines around changes. pub fn unified(old: &[u8], new: &[u8], context: u32) -> String { let a = lines(old); let b = lines(new); let hs = hunks(old, new); if hs.is_empty() { return String::new(); } // Group hunks whose context overlaps. let mut groups: Vec> = Vec::new(); for h in &hs { match groups.last_mut() { Some(g) if h.before.start <= g.last().unwrap().before.end + 2 * context => g.push(h), _ => groups.push(vec![h]), } } let mut out = String::new(); for g in groups { let first = g[0]; let last = g[g.len() - 1]; let a_start = first.before.start.saturating_sub(context); let a_end = (last.before.end + context).min(a.len() as u32); let lead = first.before.start - a_start; let b_start = first.after.start - lead; let trail = a_end - last.before.end; let b_end = last.after.end + trail; out.push_str(&format!( "@@ -{} +{} @@\n", range_text(a_start, a_end - a_start), range_text(b_start, b_end - b_start) )); let mut pos = a_start; for h in &g { for line in &a[pos as usize..h.before.start as usize] { push_line(&mut out, ' ', line); } for line in &a[h.before.start as usize..h.before.end as usize] { push_line(&mut out, '-', line); } for line in &b[h.after.start as usize..h.after.end as usize] { push_line(&mut out, '+', line); } pos = h.before.end; } for line in &a[pos as usize..a_end as usize] { push_line(&mut out, ' ', line); } } out } fn range_text(start: u32, len: u32) -> String { match len { 0 => format!("{start},0"), 1 => format!("{}", start + 1), _ => format!("{},{len}", start + 1), } } fn push_line(out: &mut String, sign: char, line: &[u8]) { out.push(sign); let text = String::from_utf8_lossy(line); out.push_str(&text); if !text.ends_with('\n') { out.push_str("\n\\ No newline at end of file\n"); } } #[cfg(test)] mod tests { use super::*; #[test] fn unified_diff() { let old = b"a\nb\nc\nd\ne\nf\ng\n"; let new = b"a\nb\nC\nd\ne\nf\ng\nh\n"; let text = unified(old, new, 1); assert!(text.contains("-c\n+C\n")); assert!(text.contains("+h\n")); assert_eq!(counts(old, new), (2, 1)); } #[test] fn binary() { assert!(is_binary(b"a\0b")); assert!(!is_binary(b"plain text\n")); } }