1#[derive(Debug, Default, Clone, PartialEq, Eq)]
12pub struct DiffDocument {
13 pub files: Vec<DiffFile>,
15}
16
17#[derive(Debug, Default, Clone, PartialEq, Eq)]
19pub struct DiffFile {
20 pub path: String,
22 pub old_path: Option<String>,
24 pub hunks: Vec<Hunk>,
26 pub binary: bool,
29}
30
31#[derive(Debug, Default, Clone, PartialEq, Eq)]
33pub struct Hunk {
34 pub old_start: u32,
36 pub new_start: u32,
38 pub lines: Vec<DiffLine>,
41}
42
43#[derive(Debug, Default, Clone, PartialEq, Eq)]
46pub struct DiffLine {
47 pub kind: DiffLineKind,
49 pub text: String,
51}
52
53#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum DiffLineKind {
56 #[default]
58 Context,
59 Added,
61 Removed,
63}
64
65#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum WhitespaceMode {
68 #[default]
70 Off,
71 IgnoreWhitespace,
73 IgnoreFormatting,
76}
77
78#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
81pub enum DiffViewMode {
82 Split,
84 #[default]
86 Inline,
87 Hunks,
89 Files,
91}
92
93pub fn parse_unified_diff(input: &str) -> DiffDocument {
113 let mut doc = DiffDocument::default();
114 let mut iter = input.lines().peekable();
115 while let Some(line) = iter.next() {
116 if let Some(rest) = line.strip_prefix("diff --git ")
117 && let Some(file) = parse_one_file(&mut iter, rest)
118 {
119 doc.files.push(file);
120 }
121 }
124
125 doc
126}
127
128pub fn filter_whitespace(doc: &DiffDocument, mode: WhitespaceMode) -> DiffDocument {
133 if matches!(mode, WhitespaceMode::Off) {
134 return doc.clone();
135 }
136
137 let mut out = DiffDocument::default();
138 for file in &doc.files {
139 let mut new_file = DiffFile {
140 path: file.path.clone(),
141 old_path: file.old_path.clone(),
142 binary: file.binary,
143 hunks: Vec::with_capacity(file.hunks.len()),
144 };
145
146 for hunk in &file.hunks {
147 if hunk_should_demote(hunk, file.path.as_str(), mode) {
148 let demoted: Vec<DiffLine> = hunk
149 .lines
150 .iter()
151 .map(|l| DiffLine {
152 kind: DiffLineKind::Context,
153 text: l.text.clone(),
154 })
155 .collect();
156 new_file.hunks.push(Hunk {
157 old_start: hunk.old_start,
158 new_start: hunk.new_start,
159 lines: demoted,
160 });
161 } else {
162 new_file.hunks.push(hunk.clone());
163 }
164 }
165
166 out.files.push(new_file);
167 }
168
169 out
170}
171
172fn parse_one_file<'a, I: Iterator<Item = &'a str>>(
177 iter: &mut std::iter::Peekable<I>,
178 header_rest: &str,
179) -> Option<DiffFile> {
180 let (_a_path, b_path) = parse_diff_git_paths(header_rest);
183 let mut file = DiffFile {
184 path: b_path,
185 old_path: None,
186 hunks: Vec::new(),
187 binary: false,
188 };
189
190 let mut pending_rename_from: Option<String> = None;
193
194 loop {
195 match iter.peek().copied() {
196 None => return Some(file),
197 Some(next) if next.starts_with("diff --git ") => return Some(file),
198 Some(next) if next.starts_with("Binary files ") && next.contains(" differ") => {
199 file.binary = true;
201 iter.next();
202 continue;
204 }
205 Some(next) if next.starts_with("rename from ") => {
206 let p = next.trim_start_matches("rename from ").to_string();
207 pending_rename_from = Some(p);
208 iter.next();
209 continue;
210 }
211 Some(next) if next.starts_with("rename to ") => {
212 let p = next.trim_start_matches("rename to ").to_string();
213 if let Some(from) = pending_rename_from.take() {
216 file.old_path = Some(from);
217 } else {
218 file.old_path = Some(p.clone());
219 }
220 file.path = p;
223 iter.next();
224 continue;
225 }
226 Some(next) if next.starts_with("new file mode") => {
227 iter.next();
228 continue;
229 }
230 Some(next) if next.starts_with("deleted file mode") => {
231 iter.next();
232 continue;
233 }
234 Some(next) if next.starts_with("similarity index") => {
235 iter.next();
236 continue;
237 }
238 Some(next) if next.starts_with("index ") => {
239 iter.next();
240 continue;
241 }
242 Some(next) if next.starts_with("--- ") || next.starts_with("+++ ") => {
243 iter.next();
244 continue;
245 }
246 Some(next) if next.starts_with("@@ ") => {
247 file.hunks = parse_hunks(iter);
249 return Some(file);
250 }
251 Some(_) => {
252 iter.next();
254 }
255 }
256 }
257}
258
259fn parse_diff_git_paths(rest: &str) -> (String, String) {
260 let mut parts = rest.splitn(2, ' ');
262 let a_raw = parts.next().unwrap_or("");
263 let b_raw = parts.next().unwrap_or("");
264 (strip_prefix_path(a_raw), strip_prefix_path(b_raw))
265}
266
267fn strip_prefix_path(p: &str) -> String {
268 if let Some(stripped) = p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")) {
269 stripped.to_string()
270 } else {
271 p.to_string()
272 }
273}
274
275fn parse_hunks<'a, I: Iterator<Item = &'a str>>(iter: &mut std::iter::Peekable<I>) -> Vec<Hunk> {
276 let mut hunks = Vec::new();
277 while let Some(line) = iter.peek().copied() {
278 if !line.starts_with("@@ ") {
279 break;
280 }
281 let header = line;
282 iter.next();
283 let Some((old_start, new_start)) = parse_hunk_header(header) else {
284 break;
286 };
287
288 let mut hunk = Hunk {
289 old_start,
290 new_start,
291 lines: Vec::new(),
292 };
293
294 while let Some(body) = iter.peek().copied() {
296 if body.starts_with("@@ ")
297 || body.starts_with("diff --git ")
298 || body.starts_with("Binary files ")
299 {
300 break;
301 }
302 if body.starts_with("--- ") || body.starts_with("+++ ") {
304 iter.next();
305 continue;
306 }
307 iter.next();
308 let Some(parsed) = parse_diff_body_line(body) else {
309 continue;
310 };
311 hunk.lines.push(parsed);
312 }
313
314 hunks.push(hunk);
315 }
316
317 hunks
318}
319
320fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
321 let after_at = line.strip_prefix("@@ ")?;
324 let middle = after_at.split(" @@ ").next()?;
325 let mut sides = middle.split(' ');
326 let old_part = sides.next()?;
327 let new_part = sides.next()?;
328 Some((parse_side_start(old_part)?, parse_side_start(new_part)?))
329}
330
331fn parse_side_start(part: &str) -> Option<u32> {
332 let trimmed = part.trim_start_matches('-').trim_start_matches('+');
334 let count_or_start = trimmed.split(',').next()?;
335 if count_or_start.is_empty() {
336 Some(0)
337 } else {
338 count_or_start.parse::<u32>().ok()
339 }
340}
341
342fn parse_diff_body_line(line: &str) -> Option<DiffLine> {
343 let mut chars = line.chars();
344 let prefix = chars.next()?;
345 let kind = match prefix {
346 '+' => DiffLineKind::Added,
347 '-' => DiffLineKind::Removed,
348 ' ' => DiffLineKind::Context,
349 '\\' => return None,
351 _ => return None,
352 };
353 Some(DiffLine {
354 kind,
355 text: chars.collect::<String>(),
356 })
357}
358
359fn hunk_should_demote(hunk: &Hunk, path: &str, mode: WhitespaceMode) -> bool {
364 let has_any_change = hunk
369 .lines
370 .iter()
371 .any(|l| !matches!(l.kind, DiffLineKind::Context));
372 if !has_any_change {
373 return false;
374 }
375
376 let stripped = (mode == WhitespaceMode::IgnoreFormatting).then(|| {
380 let mut added = Vec::new();
381 let mut removed = Vec::new();
382 for l in &hunk.lines {
383 match l.kind {
384 DiffLineKind::Context => {}
385 DiffLineKind::Added => added.push(l.text.trim_start().to_string()),
386 DiffLineKind::Removed => removed.push(l.text.trim_start().to_string()),
387 }
388 }
389 (added, removed)
390 });
391
392 hunk.lines.iter().all(|l| match l.kind {
393 DiffLineKind::Context => true,
394 DiffLineKind::Added | DiffLineKind::Removed => {
395 if line_is_whitespace_only(&l.text) {
397 return true;
398 }
399 if mode == WhitespaceMode::IgnoreFormatting
400 && let Some((added, removed)) = stripped.as_ref()
401 {
402 if line_is_indent_only(&l.text, added, removed) {
403 return true;
404 }
405 if line_is_import_only(&l.text, path) {
406 return true;
407 }
408 }
409 false
410 }
411 })
412}
413
414fn line_is_whitespace_only(s: &str) -> bool {
415 s.chars().all(|c| c.is_whitespace())
416}
417
418fn line_is_indent_only(text: &str, added: &[String], removed: &[String]) -> bool {
421 let stripped = text.trim_start();
422 if stripped.is_empty() {
423 return false; }
425 added.iter().any(|s| s == stripped) && removed.iter().any(|s| s == stripped)
426}
427
428fn line_is_import_only(text: &str, path: &str) -> bool {
429 let ext = path.rsplit('.').next().unwrap_or("");
430 let t = text.trim_start();
431 match ext {
432 "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
433 t.starts_with("import ") || (t.starts_with("export ") && t.contains(" from "))
434 }
435 "rs" => t.starts_with("use "),
436 "go" => t.starts_with("import "),
437 _ => false,
438 }
439}
440
441#[cfg(test)]
446mod tests {
447 use super::*;
448
449 const TWO_FILES: &str = "\
452diff --git a/foo.txt b/foo.txt
453index 1234567..89abcdef 100644
454--- a/foo.txt
455+++ b/foo.txt
456@@ -1,3 +1,4 @@
457 line one
458+inserted
459 line two
460 line three
461@@ -10,2 +11,3 @@
462 line ten
463-removed
464+added
465+another
466diff --git a/bar.txt b/bar.txt
467index 1111111..2222222 100644
468--- a/bar.txt
469+++ b/bar.txt
470@@ -1,1 +1,2 @@
471 head
472+tail
473";
474
475 const RENAME_AND_BINARY: &str = "\
476diff --git a/old/name.txt b/new/name.txt
477similarity index 95%
478rename from old/name.txt
479rename to new/name.txt
480index abc..def 100644
481--- a/old/name.txt
482+++ b/new/name.txt
483@@ -1,1 +1,1 @@
484-same
485+same
486diff --git a/img.png b/img.png
487index 111..222 100644
488Binary files a/img.png and b/img.png differ
489";
490
491 const WHITESPACE_FIXTURE: &str = "\
500diff --git a/ws.txt b/ws.txt
501--- a/ws.txt
502+++ b/ws.txt
503@@ -1,2 +1,2 @@
504 context
505-
506+
507@@ -10,2 +10,2 @@
508 context
509-real
510+RIPPED
511";
512
513 const FORMATTING_FIXTURE: &str = "\
514diff --git a/a.ts b/a.ts
515--- a/a.ts
516+++ b/a.ts
517@@ -1,3 +1,3 @@
518 import { a } from 'a';
519 import { b } from 'b';
520-import { c } from 'c';
521+import { z } from 'z';
522diff --git a/b.rs b/b.rs
523--- a/b.rs
524+++ b/b.rs
525@@ -1,3 +1,3 @@
526 fn f() {
527- let x = 1;
528+ let x = 1;
529 }
530diff --git a/c.go b/c.go
531--- a/c.go
532+++ b/c.go
533@@ -1,3 +1,3 @@
534 package x
535-func old() {}
536+func NEW() {}
537";
538
539 #[test]
542 fn parse_unified_diff_basic() {
543 let doc = parse_unified_diff(TWO_FILES);
544 assert_eq!(doc.files.len(), 2, "expected 2 files");
545
546 let foo = &doc.files[0];
547 assert_eq!(foo.path, "foo.txt");
548 assert!(foo.old_path.is_none());
549 assert!(!foo.binary);
550 assert_eq!(foo.hunks.len(), 2);
551
552 let h1 = &foo.hunks[0];
553 assert_eq!(h1.old_start, 1);
554 assert_eq!(h1.new_start, 1);
555 assert_eq!(h1.lines.len(), 4);
556 assert_eq!(h1.lines[0].kind, DiffLineKind::Context);
557 assert_eq!(h1.lines[0].text, "line one");
558 assert_eq!(h1.lines[1].kind, DiffLineKind::Added);
559 assert_eq!(h1.lines[1].text, "inserted");
560 assert_eq!(h1.lines[2].kind, DiffLineKind::Context);
561 assert_eq!(h1.lines[2].text, "line two");
562 assert_eq!(h1.lines[3].kind, DiffLineKind::Context);
563 assert_eq!(h1.lines[3].text, "line three");
564
565 let h2 = &foo.hunks[1];
566 assert_eq!(h2.old_start, 10);
567 assert_eq!(h2.new_start, 11);
568 assert_eq!(h2.lines.len(), 4);
569 assert_eq!(h2.lines[0].kind, DiffLineKind::Context);
570 assert_eq!(h2.lines[0].text, "line ten");
571 assert_eq!(h2.lines[1].kind, DiffLineKind::Removed);
572 assert_eq!(h2.lines[1].text, "removed");
573 assert_eq!(h2.lines[2].kind, DiffLineKind::Added);
574 assert_eq!(h2.lines[2].text, "added");
575 assert_eq!(h2.lines[3].kind, DiffLineKind::Added);
576 assert_eq!(h2.lines[3].text, "another");
577
578 let bar = &doc.files[1];
579 assert_eq!(bar.path, "bar.txt");
580 assert_eq!(bar.hunks.len(), 1);
581 assert_eq!(bar.hunks[0].old_start, 1);
582 assert_eq!(bar.hunks[0].new_start, 1);
583 assert_eq!(bar.hunks[0].lines.len(), 2);
584 assert_eq!(bar.hunks[0].lines[1].kind, DiffLineKind::Added);
585 assert_eq!(bar.hunks[0].lines[1].text, "tail");
586 }
587
588 #[test]
589 fn hunk_boundaries_correct() {
590 let doc = parse_unified_diff(TWO_FILES);
591 let foo = &doc.files[0];
592
593 assert_eq!(foo.hunks[0].old_start, 1);
594 assert_eq!(foo.hunks[0].new_start, 1);
595 assert_eq!(foo.hunks[1].old_start, 10);
596 assert_eq!(foo.hunks[1].new_start, 11);
597
598 assert_eq!(foo.hunks[0].lines.last().unwrap().text, "line three");
601 assert_eq!(foo.hunks[1].lines.first().unwrap().text, "line ten");
602 assert_eq!(foo.hunks[0].lines.len(), 4);
603 assert_eq!(foo.hunks[1].lines.len(), 4);
604 }
605
606 #[test]
607 fn rename_and_binary_files_parsed() {
608 let doc = parse_unified_diff(RENAME_AND_BINARY);
609 assert_eq!(doc.files.len(), 2);
610
611 let renamed = &doc.files[0];
612 assert_eq!(renamed.path, "new/name.txt");
613 assert_eq!(renamed.old_path.as_deref(), Some("old/name.txt"));
614 assert!(!renamed.binary);
615 assert_eq!(renamed.hunks.len(), 1);
616
617 let binary = &doc.files[1];
618 assert_eq!(binary.path, "img.png");
619 assert!(binary.binary);
620 assert!(binary.hunks.is_empty());
621 }
622
623 #[test]
624 fn ignore_whitespace_drops_ws_only_hunks() {
625 let doc = parse_unified_diff(WHITESPACE_FIXTURE);
626 let filtered = filter_whitespace(&doc, WhitespaceMode::IgnoreWhitespace);
627
628 let file = &filtered.files[0];
629 assert_eq!(file.hunks.len(), 2);
630
631 let h1 = &file.hunks[0];
633 assert!(
634 h1.lines
635 .iter()
636 .all(|l| matches!(l.kind, DiffLineKind::Context))
637 );
638 assert_eq!(h1.lines[1].text, "");
640 assert_eq!(h1.lines[2].text, " ");
641
642 let h2 = &file.hunks[1];
644 assert_eq!(h2.lines[1].kind, DiffLineKind::Removed);
645 assert_eq!(h2.lines[1].text, "real");
646 assert_eq!(h2.lines[2].kind, DiffLineKind::Added);
647 assert_eq!(h2.lines[2].text, "RIPPED");
648 }
649
650 #[test]
651 fn ignore_formatting_drops_import_and_indent_hunks() {
652 let doc = parse_unified_diff(FORMATTING_FIXTURE);
653 let filtered = filter_whitespace(&doc, WhitespaceMode::IgnoreFormatting);
654 assert_eq!(filtered.files.len(), 3);
655
656 let ts = &filtered.files[0];
658 assert_eq!(ts.path, "a.ts");
659 assert!(
660 ts.hunks[0]
661 .lines
662 .iter()
663 .all(|l| matches!(l.kind, DiffLineKind::Context))
664 );
665
666 let rs = &filtered.files[1];
668 assert_eq!(rs.path, "b.rs");
669 assert!(
670 rs.hunks[0]
671 .lines
672 .iter()
673 .all(|l| matches!(l.kind, DiffLineKind::Context))
674 );
675
676 let go = &filtered.files[2];
678 assert_eq!(go.path, "c.go");
679 assert_eq!(go.hunks[0].lines[1].kind, DiffLineKind::Removed);
680 assert_eq!(go.hunks[0].lines[1].text, "func old() {}");
681 assert_eq!(go.hunks[0].lines[2].kind, DiffLineKind::Added);
682 assert_eq!(go.hunks[0].lines[2].text, "func NEW() {}");
683 }
684
685 #[test]
686 fn view_mode_is_orthogonal_to_filter() {
687 let doc = parse_unified_diff(TWO_FILES);
688 for mode in [
689 DiffViewMode::Split,
690 DiffViewMode::Inline,
691 DiffViewMode::Hunks,
692 DiffViewMode::Files,
693 ] {
694 let m2 = mode;
695 assert_eq!(mode, m2);
696 }
697 let filtered = filter_whitespace(&doc, WhitespaceMode::Off);
698 assert_eq!(filtered, doc, "Off mode must return an identical document");
699 }
700}