#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DiffDocument {
pub files: Vec<DiffFile>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DiffFile {
pub path: String,
pub old_path: Option<String>,
pub hunks: Vec<Hunk>,
pub binary: bool,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Hunk {
pub old_start: u32,
pub new_start: u32,
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DiffLine {
pub kind: DiffLineKind,
pub text: String,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiffLineKind {
#[default]
Context,
Added,
Removed,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WhitespaceMode {
#[default]
Off,
IgnoreWhitespace,
IgnoreFormatting,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiffViewMode {
Split,
#[default]
Inline,
Hunks,
Files,
}
pub fn parse_unified_diff(input: &str) -> DiffDocument {
let mut doc = DiffDocument::default();
let mut iter = input.lines().peekable();
while let Some(line) = iter.next() {
if let Some(rest) = line.strip_prefix("diff --git ")
&& let Some(file) = parse_one_file(&mut iter, rest)
{
doc.files.push(file);
}
}
doc
}
pub fn filter_whitespace(doc: &DiffDocument, mode: WhitespaceMode) -> DiffDocument {
if matches!(mode, WhitespaceMode::Off) {
return doc.clone();
}
let mut out = DiffDocument::default();
for file in &doc.files {
let mut new_file = DiffFile {
path: file.path.clone(),
old_path: file.old_path.clone(),
binary: file.binary,
hunks: Vec::with_capacity(file.hunks.len()),
};
for hunk in &file.hunks {
if hunk_should_demote(hunk, file.path.as_str(), mode) {
let demoted: Vec<DiffLine> = hunk
.lines
.iter()
.map(|l| DiffLine {
kind: DiffLineKind::Context,
text: l.text.clone(),
})
.collect();
new_file.hunks.push(Hunk {
old_start: hunk.old_start,
new_start: hunk.new_start,
lines: demoted,
});
} else {
new_file.hunks.push(hunk.clone());
}
}
out.files.push(new_file);
}
out
}
fn parse_one_file<'a, I: Iterator<Item = &'a str>>(
iter: &mut std::iter::Peekable<I>,
header_rest: &str,
) -> Option<DiffFile> {
let (_a_path, b_path) = parse_diff_git_paths(header_rest);
let mut file = DiffFile {
path: b_path,
old_path: None,
hunks: Vec::new(),
binary: false,
};
let mut pending_rename_from: Option<String> = None;
loop {
match iter.peek().copied() {
None => return Some(file),
Some(next) if next.starts_with("diff --git ") => return Some(file),
Some(next) if next.starts_with("Binary files ") && next.contains(" differ") => {
file.binary = true;
iter.next();
continue;
}
Some(next) if next.starts_with("rename from ") => {
let p = next.trim_start_matches("rename from ").to_string();
pending_rename_from = Some(p);
iter.next();
continue;
}
Some(next) if next.starts_with("rename to ") => {
let p = next.trim_start_matches("rename to ").to_string();
if let Some(from) = pending_rename_from.take() {
file.old_path = Some(from);
} else {
file.old_path = Some(p.clone());
}
file.path = p;
iter.next();
continue;
}
Some(next) if next.starts_with("new file mode") => {
iter.next();
continue;
}
Some(next) if next.starts_with("deleted file mode") => {
iter.next();
continue;
}
Some(next) if next.starts_with("similarity index") => {
iter.next();
continue;
}
Some(next) if next.starts_with("index ") => {
iter.next();
continue;
}
Some(next) if next.starts_with("--- ") || next.starts_with("+++ ") => {
iter.next();
continue;
}
Some(next) if next.starts_with("@@ ") => {
file.hunks = parse_hunks(iter);
return Some(file);
}
Some(_) => {
iter.next();
}
}
}
}
fn parse_diff_git_paths(rest: &str) -> (String, String) {
let mut parts = rest.splitn(2, ' ');
let a_raw = parts.next().unwrap_or("");
let b_raw = parts.next().unwrap_or("");
(strip_prefix_path(a_raw), strip_prefix_path(b_raw))
}
fn strip_prefix_path(p: &str) -> String {
if let Some(stripped) = p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")) {
stripped.to_string()
} else {
p.to_string()
}
}
fn parse_hunks<'a, I: Iterator<Item = &'a str>>(iter: &mut std::iter::Peekable<I>) -> Vec<Hunk> {
let mut hunks = Vec::new();
while let Some(line) = iter.peek().copied() {
if !line.starts_with("@@ ") {
break;
}
let header = line;
iter.next();
let Some((old_start, new_start)) = parse_hunk_header(header) else {
break;
};
let mut hunk = Hunk {
old_start,
new_start,
lines: Vec::new(),
};
while let Some(body) = iter.peek().copied() {
if body.starts_with("@@ ")
|| body.starts_with("diff --git ")
|| body.starts_with("Binary files ")
{
break;
}
if body.starts_with("--- ") || body.starts_with("+++ ") {
iter.next();
continue;
}
iter.next();
let Some(parsed) = parse_diff_body_line(body) else {
continue;
};
hunk.lines.push(parsed);
}
hunks.push(hunk);
}
hunks
}
fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
let after_at = line.strip_prefix("@@ ")?;
let middle = after_at.split(" @@ ").next()?;
let mut sides = middle.split(' ');
let old_part = sides.next()?;
let new_part = sides.next()?;
Some((parse_side_start(old_part)?, parse_side_start(new_part)?))
}
fn parse_side_start(part: &str) -> Option<u32> {
let trimmed = part.trim_start_matches('-').trim_start_matches('+');
let count_or_start = trimmed.split(',').next()?;
if count_or_start.is_empty() {
Some(0)
} else {
count_or_start.parse::<u32>().ok()
}
}
fn parse_diff_body_line(line: &str) -> Option<DiffLine> {
let mut chars = line.chars();
let prefix = chars.next()?;
let kind = match prefix {
'+' => DiffLineKind::Added,
'-' => DiffLineKind::Removed,
' ' => DiffLineKind::Context,
'\\' => return None,
_ => return None,
};
Some(DiffLine {
kind,
text: chars.collect::<String>(),
})
}
fn hunk_should_demote(hunk: &Hunk, path: &str, mode: WhitespaceMode) -> bool {
let has_any_change = hunk
.lines
.iter()
.any(|l| !matches!(l.kind, DiffLineKind::Context));
if !has_any_change {
return false;
}
let stripped = (mode == WhitespaceMode::IgnoreFormatting).then(|| {
let mut added = Vec::new();
let mut removed = Vec::new();
for l in &hunk.lines {
match l.kind {
DiffLineKind::Context => {}
DiffLineKind::Added => added.push(l.text.trim_start().to_string()),
DiffLineKind::Removed => removed.push(l.text.trim_start().to_string()),
}
}
(added, removed)
});
hunk.lines.iter().all(|l| match l.kind {
DiffLineKind::Context => true,
DiffLineKind::Added | DiffLineKind::Removed => {
if line_is_whitespace_only(&l.text) {
return true;
}
if mode == WhitespaceMode::IgnoreFormatting
&& let Some((added, removed)) = stripped.as_ref()
{
if line_is_indent_only(&l.text, added, removed) {
return true;
}
if line_is_import_only(&l.text, path) {
return true;
}
}
false
}
})
}
fn line_is_whitespace_only(s: &str) -> bool {
s.chars().all(|c| c.is_whitespace())
}
fn line_is_indent_only(text: &str, added: &[String], removed: &[String]) -> bool {
let stripped = text.trim_start();
if stripped.is_empty() {
return false; }
added.iter().any(|s| s == stripped) && removed.iter().any(|s| s == stripped)
}
fn line_is_import_only(text: &str, path: &str) -> bool {
let ext = path.rsplit('.').next().unwrap_or("");
let t = text.trim_start();
match ext {
"ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
t.starts_with("import ") || (t.starts_with("export ") && t.contains(" from "))
}
"rs" => t.starts_with("use "),
"go" => t.starts_with("import "),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
const TWO_FILES: &str = "\
diff --git a/foo.txt b/foo.txt
index 1234567..89abcdef 100644
--- a/foo.txt
+++ b/foo.txt
@@ -1,3 +1,4 @@
line one
+inserted
line two
line three
@@ -10,2 +11,3 @@
line ten
-removed
+added
+another
diff --git a/bar.txt b/bar.txt
index 1111111..2222222 100644
--- a/bar.txt
+++ b/bar.txt
@@ -1,1 +1,2 @@
head
+tail
";
const RENAME_AND_BINARY: &str = "\
diff --git a/old/name.txt b/new/name.txt
similarity index 95%
rename from old/name.txt
rename to new/name.txt
index abc..def 100644
--- a/old/name.txt
+++ b/new/name.txt
@@ -1,1 +1,1 @@
-same
+same
diff --git a/img.png b/img.png
index 111..222 100644
Binary files a/img.png and b/img.png differ
";
const WHITESPACE_FIXTURE: &str = "\
diff --git a/ws.txt b/ws.txt
--- a/ws.txt
+++ b/ws.txt
@@ -1,2 +1,2 @@
context
-
+
@@ -10,2 +10,2 @@
context
-real
+RIPPED
";
const FORMATTING_FIXTURE: &str = "\
diff --git a/a.ts b/a.ts
--- a/a.ts
+++ b/a.ts
@@ -1,3 +1,3 @@
import { a } from 'a';
import { b } from 'b';
-import { c } from 'c';
+import { z } from 'z';
diff --git a/b.rs b/b.rs
--- a/b.rs
+++ b/b.rs
@@ -1,3 +1,3 @@
fn f() {
- let x = 1;
+ let x = 1;
}
diff --git a/c.go b/c.go
--- a/c.go
+++ b/c.go
@@ -1,3 +1,3 @@
package x
-func old() {}
+func NEW() {}
";
#[test]
fn parse_unified_diff_basic() {
let doc = parse_unified_diff(TWO_FILES);
assert_eq!(doc.files.len(), 2, "expected 2 files");
let foo = &doc.files[0];
assert_eq!(foo.path, "foo.txt");
assert!(foo.old_path.is_none());
assert!(!foo.binary);
assert_eq!(foo.hunks.len(), 2);
let h1 = &foo.hunks[0];
assert_eq!(h1.old_start, 1);
assert_eq!(h1.new_start, 1);
assert_eq!(h1.lines.len(), 4);
assert_eq!(h1.lines[0].kind, DiffLineKind::Context);
assert_eq!(h1.lines[0].text, "line one");
assert_eq!(h1.lines[1].kind, DiffLineKind::Added);
assert_eq!(h1.lines[1].text, "inserted");
assert_eq!(h1.lines[2].kind, DiffLineKind::Context);
assert_eq!(h1.lines[2].text, "line two");
assert_eq!(h1.lines[3].kind, DiffLineKind::Context);
assert_eq!(h1.lines[3].text, "line three");
let h2 = &foo.hunks[1];
assert_eq!(h2.old_start, 10);
assert_eq!(h2.new_start, 11);
assert_eq!(h2.lines.len(), 4);
assert_eq!(h2.lines[0].kind, DiffLineKind::Context);
assert_eq!(h2.lines[0].text, "line ten");
assert_eq!(h2.lines[1].kind, DiffLineKind::Removed);
assert_eq!(h2.lines[1].text, "removed");
assert_eq!(h2.lines[2].kind, DiffLineKind::Added);
assert_eq!(h2.lines[2].text, "added");
assert_eq!(h2.lines[3].kind, DiffLineKind::Added);
assert_eq!(h2.lines[3].text, "another");
let bar = &doc.files[1];
assert_eq!(bar.path, "bar.txt");
assert_eq!(bar.hunks.len(), 1);
assert_eq!(bar.hunks[0].old_start, 1);
assert_eq!(bar.hunks[0].new_start, 1);
assert_eq!(bar.hunks[0].lines.len(), 2);
assert_eq!(bar.hunks[0].lines[1].kind, DiffLineKind::Added);
assert_eq!(bar.hunks[0].lines[1].text, "tail");
}
#[test]
fn hunk_boundaries_correct() {
let doc = parse_unified_diff(TWO_FILES);
let foo = &doc.files[0];
assert_eq!(foo.hunks[0].old_start, 1);
assert_eq!(foo.hunks[0].new_start, 1);
assert_eq!(foo.hunks[1].old_start, 10);
assert_eq!(foo.hunks[1].new_start, 11);
assert_eq!(foo.hunks[0].lines.last().unwrap().text, "line three");
assert_eq!(foo.hunks[1].lines.first().unwrap().text, "line ten");
assert_eq!(foo.hunks[0].lines.len(), 4);
assert_eq!(foo.hunks[1].lines.len(), 4);
}
#[test]
fn rename_and_binary_files_parsed() {
let doc = parse_unified_diff(RENAME_AND_BINARY);
assert_eq!(doc.files.len(), 2);
let renamed = &doc.files[0];
assert_eq!(renamed.path, "new/name.txt");
assert_eq!(renamed.old_path.as_deref(), Some("old/name.txt"));
assert!(!renamed.binary);
assert_eq!(renamed.hunks.len(), 1);
let binary = &doc.files[1];
assert_eq!(binary.path, "img.png");
assert!(binary.binary);
assert!(binary.hunks.is_empty());
}
#[test]
fn ignore_whitespace_drops_ws_only_hunks() {
let doc = parse_unified_diff(WHITESPACE_FIXTURE);
let filtered = filter_whitespace(&doc, WhitespaceMode::IgnoreWhitespace);
let file = &filtered.files[0];
assert_eq!(file.hunks.len(), 2);
let h1 = &file.hunks[0];
assert!(
h1.lines
.iter()
.all(|l| matches!(l.kind, DiffLineKind::Context))
);
assert_eq!(h1.lines[1].text, "");
assert_eq!(h1.lines[2].text, " ");
let h2 = &file.hunks[1];
assert_eq!(h2.lines[1].kind, DiffLineKind::Removed);
assert_eq!(h2.lines[1].text, "real");
assert_eq!(h2.lines[2].kind, DiffLineKind::Added);
assert_eq!(h2.lines[2].text, "RIPPED");
}
#[test]
fn ignore_formatting_drops_import_and_indent_hunks() {
let doc = parse_unified_diff(FORMATTING_FIXTURE);
let filtered = filter_whitespace(&doc, WhitespaceMode::IgnoreFormatting);
assert_eq!(filtered.files.len(), 3);
let ts = &filtered.files[0];
assert_eq!(ts.path, "a.ts");
assert!(
ts.hunks[0]
.lines
.iter()
.all(|l| matches!(l.kind, DiffLineKind::Context))
);
let rs = &filtered.files[1];
assert_eq!(rs.path, "b.rs");
assert!(
rs.hunks[0]
.lines
.iter()
.all(|l| matches!(l.kind, DiffLineKind::Context))
);
let go = &filtered.files[2];
assert_eq!(go.path, "c.go");
assert_eq!(go.hunks[0].lines[1].kind, DiffLineKind::Removed);
assert_eq!(go.hunks[0].lines[1].text, "func old() {}");
assert_eq!(go.hunks[0].lines[2].kind, DiffLineKind::Added);
assert_eq!(go.hunks[0].lines[2].text, "func NEW() {}");
}
#[test]
fn view_mode_is_orthogonal_to_filter() {
let doc = parse_unified_diff(TWO_FILES);
for mode in [
DiffViewMode::Split,
DiffViewMode::Inline,
DiffViewMode::Hunks,
DiffViewMode::Files,
] {
let m2 = mode;
assert_eq!(mode, m2);
}
let filtered = filter_whitespace(&doc, WhitespaceMode::Off);
assert_eq!(filtered, doc, "Off mode must return an identical document");
}
}