mod myers;
mod patch;
mod words;
use std::ops::Range;
pub use myers::{DEFAULT_MAX_COST, Edit, Op, diff_ops};
pub use patch::{PatchError, PatchErrorKind, parse_patch, write_patch};
pub use words::{MAX_WORD_DIFF_LINE, word_diff};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum LineKind {
Context,
Removed,
Added,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Line {
pub kind: LineKind,
pub old_no: Option<u32>,
pub new_no: Option<u32>,
pub text: String,
pub no_newline: bool,
pub emphasis: Vec<Range<usize>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hunk {
pub old_start: u32,
pub old_len: u32,
pub new_start: u32,
pub new_len: u32,
pub section: String,
pub lines: Vec<Line>,
}
impl Hunk {
pub fn new_range(&self) -> Range<u32> {
let first = if self.new_len == 0 { self.new_start.saturating_add(1) } else { self.new_start };
first..first.saturating_add(self.new_len)
}
pub fn old_range(&self) -> Range<u32> {
let first = if self.old_len == 0 { self.old_start.saturating_add(1) } else { self.old_start };
first..first.saturating_add(self.old_len)
}
pub fn header(&self) -> String {
let range = |start: u32, len: u32| if len == 1 { start.to_string() } else { format!("{start},{len}") };
let mut s = format!("@@ -{} +{} @@", range(self.old_start, self.old_len), range(self.new_start, self.new_len));
if !self.section.is_empty() {
s.push(' ');
s.push_str(&self.section);
}
s
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub enum FileStatus {
#[default]
Modified,
Added,
Deleted,
Renamed,
Copied,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Gap {
pub before_hunk: usize,
pub old_start: u32,
pub new_start: u32,
pub len: u32,
pub expandable: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
struct Source {
text: String,
starts: Vec<usize>,
}
impl Source {
fn new(text: String) -> Source {
let mut starts = vec![0];
starts.extend(text.match_indices('\n').map(|(i, _)| i + 1).filter(|&i| i < text.len()));
if text.is_empty() {
starts.clear();
}
Source { text, starts }
}
fn line(&self, i: usize) -> Option<&str> {
let start = *self.starts.get(i)?;
let end = self.starts.get(i + 1).copied().unwrap_or(self.text.len());
let s = self.text.get(start..end)?;
Some(s.strip_suffix('\n').unwrap_or(s))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DiffOptions {
pub context: u32,
pub max_cost: usize,
pub words: bool,
}
impl Default for DiffOptions {
fn default() -> Self {
DiffOptions { context: 3, max_cost: DEFAULT_MAX_COST, words: true }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct FileDiff {
pub old_path: Option<String>,
pub new_path: Option<String>,
pub status: FileStatus,
pub binary: bool,
pub similarity: Option<u8>,
pub old_mode: Option<String>,
pub new_mode: Option<String>,
pub hunks: Vec<Hunk>,
pub header: Vec<String>,
pub coarse: bool,
source: Option<Source>,
}
impl FileDiff {
pub fn from_texts(old: &str, new: &str) -> FileDiff {
FileDiff::from_texts_with(old, new, DiffOptions::default())
}
pub fn from_texts_with(old: &str, new: &str, opts: DiffOptions) -> FileDiff {
let a: Vec<&str> = old.split_inclusive('\n').collect();
let b: Vec<&str> = new.split_inclusive('\n').collect();
let edit = diff_ops(&a, &b, opts.max_cost);
let mut hunks = build_hunks(&a, &b, &edit, opts.context);
if opts.words {
for h in &mut hunks {
emphasize(h);
}
}
FileDiff {
hunks,
header: vec!["--- a".to_owned(), "+++ b".to_owned()],
coarse: edit.coarse,
source: Some(Source::new(new.to_owned())),
..FileDiff::default()
}
}
pub fn with_paths(mut self, old: &str, new: &str) -> FileDiff {
self.old_path = Some(old.to_owned());
self.new_path = Some(new.to_owned());
self.header = vec![format!("--- a/{old}"), format!("+++ b/{new}")];
if old != new && self.status == FileStatus::Modified {
self.status = FileStatus::Renamed;
}
self
}
pub fn with_source(mut self, new_text: &str) -> FileDiff {
self.source = Some(Source::new(new_text.to_owned()));
self
}
pub fn path(&self) -> &str {
self.new_path.as_deref().or(self.old_path.as_deref()).unwrap_or("")
}
pub fn stats(&self) -> (usize, usize) {
let mut added = 0usize;
let mut removed = 0usize;
for l in self.hunks.iter().flat_map(|h| &h.lines) {
match l.kind {
LineKind::Added => added += 1,
LineKind::Removed => removed += 1,
LineKind::Context => {}
}
}
(added, removed)
}
pub fn source_len(&self) -> Option<u32> {
self.source.as_ref().map(|s| u32::try_from(s.starts.len()).unwrap_or(u32::MAX))
}
pub fn source_line(&self, new_no: u32) -> Option<&str> {
let i = usize::try_from(new_no.checked_sub(1)?).ok()?;
self.source.as_ref()?.line(i)
}
pub fn gaps(&self) -> Vec<Gap> {
let expandable = self.source.is_some();
let mut out = Vec::new();
let (mut old_next, mut new_next) = (1u32, 1u32);
for (i, h) in self.hunks.iter().enumerate() {
let (o, n) = (h.old_range(), h.new_range());
let len = n.start.saturating_sub(new_next);
if len > 0 {
out.push(Gap { before_hunk: i, old_start: old_next, new_start: new_next, len, expandable });
}
old_next = o.end;
new_next = n.end;
}
if let Some(total) = self.source_len() {
let len = total.saturating_add(1).saturating_sub(new_next);
if len > 0 && !self.binary {
out.push(Gap { before_hunk: self.hunks.len(), old_start: old_next, new_start: new_next, len, expandable });
}
}
out
}
pub fn to_patch(&self) -> String {
let mut out = String::new();
patch::write_file(self, &mut out);
out
}
}
fn build_hunks(a: &[&str], b: &[&str], edit: &Edit, context: u32) -> Vec<Hunk> {
let mut rows: Vec<(LineKind, usize, usize)> = Vec::with_capacity(a.len().saturating_add(b.len()));
for op in &edit.ops {
match *op {
Op::Equal { old, new, len } => rows.extend((0..len).map(|i| (LineKind::Context, old + i, new + i))),
Op::Delete { old, len } => rows.extend((0..len).map(|i| (LineKind::Removed, old + i, 0))),
Op::Insert { new, len } => rows.extend((0..len).map(|i| (LineKind::Added, 0, new + i))),
}
}
let ctx = context as usize;
let is_change = |i: usize| rows.get(i).is_some_and(|r| r.0 != LineKind::Context);
let mut hunks = Vec::new();
let mut i = 0usize;
let (mut old_before, mut new_before) = (0usize, 0usize);
let mut hunk_end = 0usize;
while i < rows.len() {
if !is_change(i) {
i += 1;
continue;
}
let first = i;
let mut last_end = i;
let mut j = i;
while j < rows.len() {
if is_change(j) {
last_end = j + 1;
j += 1;
} else if j - last_end < 2 * ctx && j + 1 < rows.len() {
j += 1;
} else {
break;
}
}
let lo = first.saturating_sub(ctx).max(hunk_end);
let hi = last_end.saturating_add(ctx).min(rows.len());
for r in rows.get(hunk_end..lo).unwrap_or(&[]) {
count(r.0, &mut old_before, &mut new_before);
}
let mut lines = Vec::with_capacity(hi - lo);
let (mut o, mut n) = (old_before, new_before);
for &(kind, oi, ni) in rows.get(lo..hi).unwrap_or(&[]) {
let raw = match kind {
LineKind::Added => b.get(ni),
_ => a.get(oi),
}
.copied()
.unwrap_or("");
let no_newline = !raw.ends_with('\n');
let text = raw.strip_suffix('\n').unwrap_or(raw).to_owned();
let num = |x: usize| u32::try_from(x + 1).ok();
let (old_no, new_no) = match kind {
LineKind::Context => (num(o), num(n)),
LineKind::Removed => (num(o), None),
LineKind::Added => (None, num(n)),
};
count(kind, &mut o, &mut n);
lines.push(Line { kind, old_no, new_no, text, no_newline, emphasis: Vec::new() });
}
let old_len = o - old_before;
let new_len = n - new_before;
let start = |before: usize, len: usize| u32::try_from(if len == 0 { before } else { before + 1 }).unwrap_or(u32::MAX);
hunks.push(Hunk {
old_start: start(old_before, old_len),
old_len: u32::try_from(old_len).unwrap_or(u32::MAX),
new_start: start(new_before, new_len),
new_len: u32::try_from(new_len).unwrap_or(u32::MAX),
section: String::new(),
lines,
});
old_before = o;
new_before = n;
hunk_end = hi;
i = hi;
}
hunks
}
fn count(kind: LineKind, old: &mut usize, new: &mut usize) {
match kind {
LineKind::Context => {
*old += 1;
*new += 1;
}
LineKind::Removed => *old += 1,
LineKind::Added => *new += 1,
}
}
pub(crate) fn emphasize(h: &mut Hunk) {
let mut i = 0usize;
let len = h.lines.len();
while i < len {
let removed = h.lines.iter().skip(i).take_while(|l| l.kind == LineKind::Removed).count();
if removed == 0 {
i += 1;
continue;
}
let added = h.lines.iter().skip(i + removed).take_while(|l| l.kind == LineKind::Added).count();
for k in 0..removed.min(added) {
let (ri, ai) = (i + k, i + removed + k);
let pair = match (h.lines.get(ri), h.lines.get(ai)) {
(Some(r), Some(a)) => word_diff(&r.text, &a.text),
_ => None,
};
if let Some((er, ea)) = pair {
if let Some(r) = h.lines.get_mut(ri) {
r.emphasis = er;
}
if let Some(a) = h.lines.get_mut(ai) {
a.emphasis = ea;
}
}
}
i += removed + added;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hunks_merge_close_changes_and_split_far_ones() {
let old: String = (1..=30).map(|i| format!("{i}\n")).collect();
let new: String = (1..=30)
.map(|i| match i {
5 => "five\n".to_owned(),
8 => "eight\n".to_owned(),
25 => "x\n".to_owned(),
_ => format!("{i}\n"),
})
.collect();
let d = FileDiff::from_texts(&old, &new);
assert_eq!(d.hunks.len(), 2);
let h = &d.hunks[0];
assert_eq!((h.old_start, h.old_len, h.new_start, h.new_len), (2, 10, 2, 10));
let h = &d.hunks[1];
assert_eq!((h.old_start, h.old_len), (22, 7));
let gaps = d.gaps();
assert_eq!(gaps.len(), 3);
assert_eq!((gaps[0].new_start, gaps[0].len), (1, 1));
assert_eq!((gaps[1].new_start, gaps[1].len), (12, 10));
assert_eq!((gaps[2].new_start, gaps[2].len, gaps[2].before_hunk), (29, 2, 2));
assert_eq!(d.source_line(12), Some("12"));
}
#[test]
fn identical_texts_are_one_gap() {
let d = FileDiff::from_texts("a\nb\n", "a\nb\n");
assert!(d.hunks.is_empty());
assert_eq!(d.gaps(), vec![Gap { before_hunk: 0, old_start: 1, new_start: 1, len: 2, expandable: true }]);
assert!(FileDiff::from_texts("", "").gaps().is_empty());
}
#[test]
fn missing_final_newline() {
let d = FileDiff::from_texts("a\nb", "a\nb\n");
let p = d.to_patch();
assert_eq!(p, "--- a\n+++ b\n@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+b\n");
let back = parse_patch(&p).unwrap();
assert_eq!(back[0].hunks, d.hunks);
}
#[test]
fn added_and_deleted_whole_files() {
let d = FileDiff::from_texts("", "x\ny\n");
assert_eq!(d.hunks[0].header(), "@@ -0,0 +1,2 @@");
let d = FileDiff::from_texts("x\n", "");
assert_eq!(d.hunks[0].header(), "@@ -1 +0,0 @@");
}
}