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