1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3
4use rustc_hash::{FxHashMap, FxHashSet};
5
6pub const MAX_DIFF_BYTES: u64 = 10 * 1024 * 1024;
8
9pub const MAX_ADDED_LINES: usize = 1_000_000;
11
12#[derive(Debug, Default, Clone)]
21pub struct DiffIndex {
22 added_lines: FxHashMap<String, FxHashSet<u64>>,
23 changed_paths: FxHashSet<String>,
24 touched_files: FxHashSet<String>,
25 added_line_count: usize,
26 total_added_lines: usize,
27 total_removed_lines: usize,
28 hunk_count: usize,
29 rename_pairs: FxHashMap<String, String>,
30 base: Option<PathBuf>,
31 root_offset: String,
32}
33
34#[derive(Default)]
36struct DiffParseState {
37 current_file: Option<String>,
38 new_line: u64,
39 pending_old_path: Option<String>,
40 pending_rename_from: Option<String>,
41}
42
43impl DiffIndex {
44 #[must_use]
49 pub fn from_unified_diff(diff: &str) -> Self {
50 let mut index = Self::default();
51 let mut state = DiffParseState::default();
52
53 for line in diff.lines() {
54 if index.handle_diff_header_line(line, &mut state) {
55 continue;
56 }
57 index.handle_diff_content_line(line, &mut state);
58 }
59
60 index
61 }
62
63 fn handle_diff_header_line(&mut self, line: &str, state: &mut DiffParseState) -> bool {
64 if line.starts_with("diff --git ") {
65 state.current_file = None;
66 state.pending_old_path = None;
67 state.pending_rename_from = None;
68 return true;
69 }
70 if let Some(rest) = line.strip_prefix("rename from ") {
71 state.pending_rename_from = Some(rest.to_owned());
72 return true;
73 }
74 if let Some(rest) = line.strip_prefix("rename to ") {
75 if let Some(from) = state.pending_rename_from.take() {
76 self.rename_pairs.insert(rest.to_owned(), from);
77 self.changed_paths.insert(rest.to_owned());
78 self.touched_files.insert(rest.to_owned());
79 }
80 return true;
81 }
82 if let Some(path) = line.strip_prefix("--- a/") {
83 state.pending_old_path = Some(path.to_string());
84 return true;
85 }
86 if line.starts_with("--- /dev/null") {
87 state.pending_old_path = None;
88 return true;
89 }
90 if let Some(path) = line.strip_prefix("+++ b/") {
91 state.pending_old_path = None;
92 state.current_file = Some(path.to_string());
93 self.changed_paths.insert(path.to_string());
94 self.touched_files.insert(path.to_string());
95 return true;
96 }
97 if line.starts_with("+++ /dev/null") {
98 state.current_file = state.pending_old_path.take();
99 if let Some(path) = state.current_file.as_ref() {
100 self.changed_paths.insert(path.clone());
101 }
102 return true;
103 }
104 if let Some(header) = line.strip_prefix("@@ ") {
105 if let Some(start) = parse_new_hunk_start(header) {
106 state.new_line = start;
107 self.hunk_count += 1;
108 }
109 return true;
110 }
111 false
112 }
113
114 fn handle_diff_content_line(&mut self, line: &str, state: &mut DiffParseState) {
115 let Some(path) = state.current_file.as_ref() else {
116 return;
117 };
118 if line.starts_with('+') {
119 self.total_added_lines += 1;
120 if !line.starts_with("+++") && self.added_line_count < MAX_ADDED_LINES {
121 self.added_lines
122 .entry(path.clone())
123 .or_default()
124 .insert(state.new_line);
125 self.added_line_count += 1;
126 }
127 state.new_line += 1;
128 } else if line.starts_with('-') {
129 self.total_removed_lines += 1;
130 } else {
131 state.new_line += 1;
132 }
133 }
134
135 #[must_use]
138 pub fn old_path_for(&self, head_path: &str) -> Option<&str> {
139 self.rename_pairs.get(head_path).map(String::as_str)
140 }
141
142 #[must_use]
144 pub fn added_line_count(&self) -> usize {
145 self.added_line_count
146 }
147
148 #[must_use]
150 pub fn hunk_count(&self) -> usize {
151 self.hunk_count
152 }
153
154 #[must_use]
157 pub fn net_lines(&self) -> i64 {
158 let added = i64::try_from(self.total_added_lines).unwrap_or(i64::MAX);
159 let removed = i64::try_from(self.total_removed_lines).unwrap_or(i64::MAX);
160 added.saturating_sub(removed)
161 }
162
163 #[must_use]
165 pub fn changes_path(&self, path: &str) -> bool {
166 self.changed_paths.contains(path)
167 }
168
169 pub fn changed_paths(&self) -> impl Iterator<Item = &str> {
171 self.changed_paths.iter().map(String::as_str)
172 }
173
174 #[must_use]
176 pub fn touches_file(&self, path: &str) -> bool {
177 self.touched_files.contains(path)
178 }
179
180 #[must_use]
183 pub fn range_overlaps_added(&self, path: &str, start: u64, end: u64) -> bool {
184 self.first_added_line_in_range(path, start, end).is_some()
185 }
186
187 #[must_use]
190 pub fn first_added_line_in_range(&self, path: &str, start: u64, end: u64) -> Option<u64> {
191 if end < start {
192 return None;
193 }
194 let added = self.added_lines.get(path)?;
195 let lo = start.max(1);
196 added
197 .iter()
198 .copied()
199 .filter(|line| *line >= lo && *line <= end)
200 .min()
201 }
202
203 #[must_use]
205 pub fn line_is_added(&self, path: &str, line: u64) -> bool {
206 self.added_lines
207 .get(path)
208 .is_some_and(|lines| lines.contains(&line))
209 }
210
211 #[must_use]
213 pub fn line_within_added_context(&self, path: &str, line: u64, radius: u64) -> bool {
214 self.added_lines
215 .get(path)
216 .is_some_and(|lines| lines.iter().any(|added| line.abs_diff(*added) <= radius))
217 }
218
219 #[must_use]
221 pub fn added_lines_in(&self, path: &str) -> Option<&FxHashSet<u64>> {
222 self.added_lines.get(path)
223 }
224
225 #[must_use]
228 pub fn with_base(mut self, base: impl Into<PathBuf>) -> Self {
229 self.base = Some(base.into());
230 self
231 }
232
233 #[must_use]
241 pub fn with_root_offset(mut self, offset: impl Into<String>) -> Self {
242 let mut offset = offset.into();
243 offset.truncate(offset.trim_end_matches('/').len());
246 self.root_offset = offset;
247 self
248 }
249
250 #[must_use]
253 pub fn root_offset(&self) -> &str {
254 &self.root_offset
255 }
256
257 #[must_use]
259 pub fn key_for_root_relative<'a>(&self, rel: &'a str) -> Cow<'a, str> {
260 if self.root_offset.is_empty() {
261 return Cow::Borrowed(rel);
262 }
263 Cow::Owned(format!("{}/{rel}", self.root_offset))
264 }
265
266 #[must_use]
269 pub fn root_relative_from_key<'a>(&self, key: &'a str) -> Option<Cow<'a, str>> {
270 if self.root_offset.is_empty() {
271 return Some(Cow::Borrowed(key));
272 }
273 strip_path_component_prefix(key, &self.root_offset).map(Cow::Borrowed)
274 }
275
276 #[must_use]
280 pub fn old_path_for_root_relative<'a>(&'a self, rel: &str) -> Option<Cow<'a, str>> {
281 let old = self.old_path_for(&self.key_for_root_relative(rel))?;
282 self.root_relative_from_key(old)
283 }
284
285 #[must_use]
288 pub fn base(&self) -> Option<&Path> {
289 self.base.as_deref()
290 }
291
292 pub fn touched_files(&self) -> impl Iterator<Item = &str> {
295 self.touched_files.iter().map(String::as_str)
296 }
297
298 #[must_use]
304 pub fn key_for(&self, path: &Path, fallback_root: &Path) -> Option<String> {
305 relative_to_diff_path(path, self.base.as_deref().unwrap_or(fallback_root))
306 }
307}
308
309#[must_use]
312pub fn relative_to_diff_path(path: &Path, root: &Path) -> Option<String> {
313 if let Ok(stripped) = path.strip_prefix(root) {
314 return Some(stripped.to_string_lossy().replace('\\', "/"));
315 }
316 if fallow_types::path_util::is_absolute_path_any_platform(path) {
317 return None;
318 }
319 Some(path.to_string_lossy().replace('\\', "/"))
320}
321
322#[must_use]
326pub fn strip_path_component_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
327 path.strip_prefix(prefix)?.strip_prefix('/')
328}
329
330pub fn parse_new_hunk_start(header: &str) -> Option<u64> {
332 let plus = header.find('+')?;
333 let rest = &header[plus + 1..];
334 let end = rest
335 .find(|c: char| c == ',' || c.is_ascii_whitespace())
336 .unwrap_or(rest.len());
337 rest[..end].parse().ok()
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn from_unified_diff_caps_added_lines_at_threshold() {
346 let header =
347 "diff --git a/big.txt b/big.txt\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,100 @@\n";
348 let mut body = String::with_capacity(MAX_ADDED_LINES * 16);
349 for _ in 0..(MAX_ADDED_LINES + 100) {
350 body.push_str("+x\n");
351 }
352 let mut diff = String::with_capacity(header.len() + body.len());
353 diff.push_str(header);
354 diff.push_str(&body);
355
356 let index = DiffIndex::from_unified_diff(&diff);
357 assert!(
358 index.added_line_count() <= MAX_ADDED_LINES,
359 "indexed {} lines, cap is {MAX_ADDED_LINES}",
360 index.added_line_count()
361 );
362 assert_eq!(
363 Some(index.net_lines()),
364 i64::try_from(MAX_ADDED_LINES + 100).ok()
365 );
366 assert_eq!(index.hunk_count(), 1);
367 }
368
369 #[test]
370 fn from_unified_diff_counts_additions_removals_and_hunks() {
371 let diff = "\
372diff --git a/src/a.ts b/src/a.ts
373--- a/src/a.ts
374+++ b/src/a.ts
375@@ -1,3 +1,4 @@
376-old
377+new
378+extra
379+++flag
380 context
381@@ -10,2 +11,1 @@
382-removed
383---flag
384 kept
385";
386 let index = DiffIndex::from_unified_diff(diff);
387
388 assert_eq!(index.hunk_count(), 2);
389 assert_eq!(index.net_lines(), 0);
390 assert!(index.changes_path("src/a.ts"));
391 assert!(index.touches_file("src/a.ts"));
392 }
393
394 #[test]
395 fn rename_only_diff_has_no_line_or_hunk_changes() {
396 let diff = "\
397diff --git a/src/old.ts b/src/new.ts
398similarity index 100%
399rename from src/old.ts
400rename to src/new.ts
401";
402 let index = DiffIndex::from_unified_diff(diff);
403
404 assert_eq!(index.hunk_count(), 0);
405 assert_eq!(index.net_lines(), 0);
406 assert!(index.changes_path("src/new.ts"));
407 assert!(index.touches_file("src/new.ts"));
408 }
409
410 #[test]
411 fn range_overlaps_added_hotspot_starting_before_diff_touches_inside() {
412 let diff = "\
413diff --git a/src/big.ts b/src/big.ts
414--- a/src/big.ts
415+++ b/src/big.ts
416@@ -114,1 +114,2 @@
417 ctx
418+touched
419";
420 let index = DiffIndex::from_unified_diff(diff);
421 assert!(index.range_overlaps_added("src/big.ts", 10, 120));
422 assert!(!index.range_overlaps_added("src/other.ts", 10, 120));
423 assert!(!index.range_overlaps_added("src/big.ts", 10, 100));
424 assert!(!index.range_overlaps_added("src/big.ts", 200, 100));
425 }
426
427 #[test]
428 fn rename_header_records_old_path() {
429 let diff = "\
430diff --git a/src/old.ts b/src/new.ts
431similarity index 90%
432rename from src/old.ts
433rename to src/new.ts
434--- a/src/old.ts
435+++ b/src/new.ts
436@@ -1,1 +1,1 @@
437-old
438+new
439";
440 let index = DiffIndex::from_unified_diff(diff);
441 assert_eq!(index.old_path_for("src/new.ts"), Some("src/old.ts"));
442 assert!(index.touches_file("src/new.ts"));
443 }
444
445 #[test]
446 fn empty_diff_has_zero_added_lines_and_no_touched_files() {
447 let index = DiffIndex::from_unified_diff("");
448 assert_eq!(index.added_line_count(), 0);
449 assert!(!index.touches_file("src/a.ts"));
450 }
451
452 #[test]
453 fn delete_only_diff_records_removal_without_touching_head_file() {
454 let diff = "\
455diff --git a/src/a.ts b/src/a.ts
456--- a/src/a.ts
457+++ /dev/null
458@@ -1,1 +0,0 @@
459-old
460";
461 let index = DiffIndex::from_unified_diff(diff);
462 assert_eq!(index.added_line_count(), 0);
463 assert_eq!(index.hunk_count(), 1);
464 assert_eq!(index.net_lines(), -1);
465 assert!(index.changes_path("src/a.ts"));
466 assert!(!index.touches_file("src/a.ts"));
467 }
468
469 #[test]
470 fn relative_to_diff_path_strips_absolute_root() {
471 let root = Path::new("/project");
472 let path = Path::new("/project/src/a.ts");
473 assert_eq!(
474 relative_to_diff_path(path, root).as_deref(),
475 Some("src/a.ts")
476 );
477 }
478
479 #[test]
480 fn relative_to_diff_path_passes_through_relative() {
481 let root = Path::new("/project");
482 let path = Path::new("src/a.ts");
483 assert_eq!(
484 relative_to_diff_path(path, root).as_deref(),
485 Some("src/a.ts")
486 );
487 }
488
489 #[test]
490 fn relative_to_diff_path_returns_none_for_path_outside_root() {
491 let root = Path::new("/project");
492 let path = Path::new("/elsewhere/src/a.ts");
493 assert!(relative_to_diff_path(path, root).is_none());
494 }
495
496 #[test]
497 fn key_for_without_base_relativizes_against_the_fallback_root() {
498 let index = DiffIndex::default();
499 assert_eq!(
500 index
501 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
502 .as_deref(),
503 Some("src/a.ts")
504 );
505 }
506
507 #[test]
508 fn key_for_with_base_equal_to_root_is_unchanged() {
509 let index = DiffIndex::default().with_base("/repo");
510 assert_eq!(
511 index
512 .key_for(Path::new("/repo/src/a.ts"), Path::new("/repo"))
513 .as_deref(),
514 Some("src/a.ts")
515 );
516 }
517
518 #[test]
521 fn key_for_with_base_above_root_yields_repo_root_relative_key() {
522 let index = DiffIndex::default().with_base("/repo");
523 assert_eq!(
524 index
525 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
526 .as_deref(),
527 Some("pkg/src/a.ts")
528 );
529 }
530
531 #[test]
532 fn key_for_with_base_above_root_matches_a_repo_root_relative_diff() {
533 let diff = "\
534diff --git a/pkg/src/a.ts b/pkg/src/a.ts
535--- a/pkg/src/a.ts
536+++ b/pkg/src/a.ts
537@@ -1,0 +2,1 @@
538+added
539";
540 let index = DiffIndex::from_unified_diff(diff).with_base("/repo");
541 let key = index
542 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
543 .expect("finding path is under the base");
544
545 assert!(index.touches_file(&key));
546 assert!(index.line_is_added(&key, 2));
547
548 let unbased = DiffIndex::from_unified_diff(diff);
550 let missed = unbased
551 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
552 .expect("still relativizable");
553 assert_eq!(missed, "src/a.ts");
554 assert!(!unbased.touches_file(&missed));
555 }
556
557 #[test]
558 fn key_for_returns_none_for_path_outside_the_base() {
559 let index = DiffIndex::default().with_base("/repo");
560 assert!(
561 index
562 .key_for(Path::new("/elsewhere/a.ts"), Path::new("/repo/pkg"))
563 .is_none()
564 );
565 }
566
567 #[test]
568 fn old_path_for_root_relative_crosses_the_namespace_and_back() {
569 let diff = "\
570diff --git a/pkg/src/old.ts b/pkg/src/new.ts
571similarity index 90%
572rename from pkg/src/old.ts
573rename to pkg/src/new.ts
574--- a/pkg/src/old.ts
575+++ b/pkg/src/new.ts
576@@ -1,1 +1,1 @@
577-old
578+new
579";
580 let index = DiffIndex::from_unified_diff(diff)
581 .with_base("/repo")
582 .with_root_offset("pkg");
583
584 assert_eq!(
586 index.old_path_for_root_relative("src/new.ts").as_deref(),
587 Some("src/old.ts")
588 );
589 assert_eq!(index.old_path_for("pkg/src/new.ts"), Some("pkg/src/old.ts"));
591 assert_eq!(index.old_path_for_root_relative("src/absent.ts"), None);
592 }
593
594 #[test]
595 fn root_relative_key_round_trips() {
596 let index = DiffIndex::default().with_root_offset("packages/pkg");
597 assert_eq!(
598 index.key_for_root_relative("src/a.ts"),
599 "packages/pkg/src/a.ts"
600 );
601 assert_eq!(
602 index
603 .root_relative_from_key("packages/pkg/src/a.ts")
604 .as_deref(),
605 Some("src/a.ts")
606 );
607 assert_eq!(index.root_relative_from_key("other/src/a.ts"), None);
609 assert_eq!(
611 index.root_relative_from_key("packages/pkg-extra/a.ts"),
612 None
613 );
614 }
615
616 #[test]
617 fn empty_root_offset_is_identity() {
618 let index = DiffIndex::default();
619 assert_eq!(index.key_for_root_relative("src/a.ts"), "src/a.ts");
620 assert_eq!(
621 index.root_relative_from_key("src/a.ts").as_deref(),
622 Some("src/a.ts")
623 );
624 }
625
626 #[test]
627 fn touched_files_enumerates_diff_header_paths() {
628 let diff = "\
629diff --git a/pkg/a.ts b/pkg/a.ts
630--- a/pkg/a.ts
631+++ b/pkg/a.ts
632@@ -0,0 +1,1 @@
633+x
634";
635 let index = DiffIndex::from_unified_diff(diff);
636 assert_eq!(index.touched_files().collect::<Vec<_>>(), vec!["pkg/a.ts"]);
637 }
638}