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 touched_files: FxHashSet<String>,
24 added_line_count: usize,
25 rename_pairs: FxHashMap<String, String>,
26 base: Option<PathBuf>,
27 root_offset: String,
28}
29
30#[derive(Default)]
32struct DiffParseState {
33 current_file: Option<String>,
34 new_line: u64,
35 pending_rename_from: Option<String>,
36}
37
38impl DiffIndex {
39 #[must_use]
40 pub fn from_unified_diff(diff: &str) -> Self {
41 let mut index = Self::default();
42 let mut state = DiffParseState::default();
43
44 for line in diff.lines() {
45 if index.handle_diff_header_line(line, &mut state) {
46 continue;
47 }
48 index.handle_diff_content_line(line, &mut state);
49 }
50
51 index
52 }
53
54 fn handle_diff_header_line(&mut self, line: &str, state: &mut DiffParseState) -> bool {
55 if line.starts_with("diff --git ") {
56 state.pending_rename_from = None;
57 return true;
58 }
59 if let Some(rest) = line.strip_prefix("rename from ") {
60 state.pending_rename_from = Some(rest.to_owned());
61 return true;
62 }
63 if let Some(rest) = line.strip_prefix("rename to ") {
64 if let Some(from) = state.pending_rename_from.take() {
65 self.rename_pairs.insert(rest.to_owned(), from);
66 self.touched_files.insert(rest.to_owned());
67 }
68 return true;
69 }
70 if let Some(path) = line.strip_prefix("+++ b/") {
71 state.current_file = Some(path.to_string());
72 self.touched_files.insert(path.to_string());
73 return true;
74 }
75 if line.starts_with("+++ /dev/null") {
76 state.current_file = None;
77 return true;
78 }
79 if let Some(header) = line.strip_prefix("@@ ") {
80 if let Some(start) = parse_new_hunk_start(header) {
81 state.new_line = start;
82 }
83 return true;
84 }
85 false
86 }
87
88 fn handle_diff_content_line(&mut self, line: &str, state: &mut DiffParseState) {
89 let Some(path) = state.current_file.as_ref() else {
90 return;
91 };
92 if line.starts_with('+') && !line.starts_with("+++") {
93 if self.added_line_count < MAX_ADDED_LINES {
94 self.added_lines
95 .entry(path.clone())
96 .or_default()
97 .insert(state.new_line);
98 self.added_line_count += 1;
99 }
100 state.new_line += 1;
101 } else if !line.starts_with('-') {
102 state.new_line += 1;
103 }
104 }
105
106 #[must_use]
107 pub fn old_path_for(&self, head_path: &str) -> Option<&str> {
108 self.rename_pairs.get(head_path).map(String::as_str)
109 }
110
111 #[must_use]
112 pub fn added_line_count(&self) -> usize {
113 self.added_line_count
114 }
115
116 #[must_use]
117 pub fn touches_file(&self, path: &str) -> bool {
118 self.touched_files.contains(path)
119 }
120
121 #[must_use]
122 pub fn range_overlaps_added(&self, path: &str, start: u64, end: u64) -> bool {
123 if end < start {
124 return false;
125 }
126 let Some(added) = self.added_lines.get(path) else {
127 return false;
128 };
129 let lo = start.max(1);
130 added.iter().any(|&line| line >= lo && line <= end)
131 }
132
133 #[must_use]
134 pub fn line_is_added(&self, path: &str, line: u64) -> bool {
135 self.added_lines
136 .get(path)
137 .is_some_and(|lines| lines.contains(&line))
138 }
139
140 #[must_use]
141 pub fn line_within_added_context(&self, path: &str, line: u64, radius: u64) -> bool {
142 self.added_lines
143 .get(path)
144 .is_some_and(|lines| lines.iter().any(|added| line.abs_diff(*added) <= radius))
145 }
146
147 #[must_use]
148 pub fn added_lines_in(&self, path: &str) -> Option<&FxHashSet<u64>> {
149 self.added_lines.get(path)
150 }
151
152 #[must_use]
155 pub fn with_base(mut self, base: impl Into<PathBuf>) -> Self {
156 self.base = Some(base.into());
157 self
158 }
159
160 #[must_use]
168 pub fn with_root_offset(mut self, offset: impl Into<String>) -> Self {
169 let mut offset = offset.into();
170 offset.truncate(offset.trim_end_matches('/').len());
173 self.root_offset = offset;
174 self
175 }
176
177 #[must_use]
178 pub fn root_offset(&self) -> &str {
179 &self.root_offset
180 }
181
182 #[must_use]
184 pub fn key_for_root_relative<'a>(&self, rel: &'a str) -> Cow<'a, str> {
185 if self.root_offset.is_empty() {
186 return Cow::Borrowed(rel);
187 }
188 Cow::Owned(format!("{}/{rel}", self.root_offset))
189 }
190
191 #[must_use]
194 pub fn root_relative_from_key<'a>(&self, key: &'a str) -> Option<Cow<'a, str>> {
195 if self.root_offset.is_empty() {
196 return Some(Cow::Borrowed(key));
197 }
198 strip_path_component_prefix(key, &self.root_offset).map(Cow::Borrowed)
199 }
200
201 #[must_use]
205 pub fn old_path_for_root_relative<'a>(&'a self, rel: &str) -> Option<Cow<'a, str>> {
206 let old = self.old_path_for(&self.key_for_root_relative(rel))?;
207 self.root_relative_from_key(old)
208 }
209
210 #[must_use]
211 pub fn base(&self) -> Option<&Path> {
212 self.base.as_deref()
213 }
214
215 pub fn touched_files(&self) -> impl Iterator<Item = &str> {
216 self.touched_files.iter().map(String::as_str)
217 }
218
219 #[must_use]
225 pub fn key_for(&self, path: &Path, fallback_root: &Path) -> Option<String> {
226 relative_to_diff_path(path, self.base.as_deref().unwrap_or(fallback_root))
227 }
228}
229
230#[must_use]
231pub fn relative_to_diff_path(path: &Path, root: &Path) -> Option<String> {
232 if let Ok(stripped) = path.strip_prefix(root) {
233 return Some(stripped.to_string_lossy().replace('\\', "/"));
234 }
235 if fallow_types::path_util::is_absolute_path_any_platform(path) {
236 return None;
237 }
238 Some(path.to_string_lossy().replace('\\', "/"))
239}
240
241#[must_use]
245pub fn strip_path_component_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
246 path.strip_prefix(prefix)?.strip_prefix('/')
247}
248
249pub fn parse_new_hunk_start(header: &str) -> Option<u64> {
250 let plus = header.find('+')?;
251 let rest = &header[plus + 1..];
252 let end = rest
253 .find(|c: char| c == ',' || c.is_ascii_whitespace())
254 .unwrap_or(rest.len());
255 rest[..end].parse().ok()
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn from_unified_diff_caps_added_lines_at_threshold() {
264 let header =
265 "diff --git a/big.txt b/big.txt\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,100 @@\n";
266 let mut body = String::with_capacity(MAX_ADDED_LINES * 16);
267 for _ in 0..(MAX_ADDED_LINES + 100) {
268 body.push_str("+x\n");
269 }
270 let mut diff = String::with_capacity(header.len() + body.len());
271 diff.push_str(header);
272 diff.push_str(&body);
273
274 let index = DiffIndex::from_unified_diff(&diff);
275 assert!(
276 index.added_line_count() <= MAX_ADDED_LINES,
277 "indexed {} lines, cap is {MAX_ADDED_LINES}",
278 index.added_line_count()
279 );
280 }
281
282 #[test]
283 fn range_overlaps_added_hotspot_starting_before_diff_touches_inside() {
284 let diff = "\
285diff --git a/src/big.ts b/src/big.ts
286--- a/src/big.ts
287+++ b/src/big.ts
288@@ -114,1 +114,2 @@
289 ctx
290+touched
291";
292 let index = DiffIndex::from_unified_diff(diff);
293 assert!(index.range_overlaps_added("src/big.ts", 10, 120));
294 assert!(!index.range_overlaps_added("src/other.ts", 10, 120));
295 assert!(!index.range_overlaps_added("src/big.ts", 10, 100));
296 assert!(!index.range_overlaps_added("src/big.ts", 200, 100));
297 }
298
299 #[test]
300 fn rename_header_records_old_path() {
301 let diff = "\
302diff --git a/src/old.ts b/src/new.ts
303similarity index 90%
304rename from src/old.ts
305rename to src/new.ts
306--- a/src/old.ts
307+++ b/src/new.ts
308@@ -1,1 +1,1 @@
309-old
310+new
311";
312 let index = DiffIndex::from_unified_diff(diff);
313 assert_eq!(index.old_path_for("src/new.ts"), Some("src/old.ts"));
314 assert!(index.touches_file("src/new.ts"));
315 }
316
317 #[test]
318 fn empty_diff_has_zero_added_lines_and_no_touched_files() {
319 let index = DiffIndex::from_unified_diff("");
320 assert_eq!(index.added_line_count(), 0);
321 assert!(!index.touches_file("src/a.ts"));
322 }
323
324 #[test]
325 fn delete_only_diff_records_no_added_lines() {
326 let diff = "\
327diff --git a/src/a.ts b/src/a.ts
328--- a/src/a.ts
329+++ /dev/null
330@@ -1,1 +0,0 @@
331-old
332";
333 let index = DiffIndex::from_unified_diff(diff);
334 assert_eq!(index.added_line_count(), 0);
335 assert!(!index.touches_file("src/a.ts"));
336 }
337
338 #[test]
339 fn relative_to_diff_path_strips_absolute_root() {
340 let root = Path::new("/project");
341 let path = Path::new("/project/src/a.ts");
342 assert_eq!(
343 relative_to_diff_path(path, root).as_deref(),
344 Some("src/a.ts")
345 );
346 }
347
348 #[test]
349 fn relative_to_diff_path_passes_through_relative() {
350 let root = Path::new("/project");
351 let path = Path::new("src/a.ts");
352 assert_eq!(
353 relative_to_diff_path(path, root).as_deref(),
354 Some("src/a.ts")
355 );
356 }
357
358 #[test]
359 fn relative_to_diff_path_returns_none_for_path_outside_root() {
360 let root = Path::new("/project");
361 let path = Path::new("/elsewhere/src/a.ts");
362 assert!(relative_to_diff_path(path, root).is_none());
363 }
364
365 #[test]
366 fn key_for_without_base_relativizes_against_the_fallback_root() {
367 let index = DiffIndex::default();
368 assert_eq!(
369 index
370 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
371 .as_deref(),
372 Some("src/a.ts")
373 );
374 }
375
376 #[test]
377 fn key_for_with_base_equal_to_root_is_unchanged() {
378 let index = DiffIndex::default().with_base("/repo");
379 assert_eq!(
380 index
381 .key_for(Path::new("/repo/src/a.ts"), Path::new("/repo"))
382 .as_deref(),
383 Some("src/a.ts")
384 );
385 }
386
387 #[test]
390 fn key_for_with_base_above_root_yields_repo_root_relative_key() {
391 let index = DiffIndex::default().with_base("/repo");
392 assert_eq!(
393 index
394 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
395 .as_deref(),
396 Some("pkg/src/a.ts")
397 );
398 }
399
400 #[test]
401 fn key_for_with_base_above_root_matches_a_repo_root_relative_diff() {
402 let diff = "\
403diff --git a/pkg/src/a.ts b/pkg/src/a.ts
404--- a/pkg/src/a.ts
405+++ b/pkg/src/a.ts
406@@ -1,0 +2,1 @@
407+added
408";
409 let index = DiffIndex::from_unified_diff(diff).with_base("/repo");
410 let key = index
411 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
412 .expect("finding path is under the base");
413
414 assert!(index.touches_file(&key));
415 assert!(index.line_is_added(&key, 2));
416
417 let unbased = DiffIndex::from_unified_diff(diff);
419 let missed = unbased
420 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
421 .expect("still relativizable");
422 assert_eq!(missed, "src/a.ts");
423 assert!(!unbased.touches_file(&missed));
424 }
425
426 #[test]
427 fn key_for_returns_none_for_path_outside_the_base() {
428 let index = DiffIndex::default().with_base("/repo");
429 assert!(
430 index
431 .key_for(Path::new("/elsewhere/a.ts"), Path::new("/repo/pkg"))
432 .is_none()
433 );
434 }
435
436 #[test]
437 fn old_path_for_root_relative_crosses_the_namespace_and_back() {
438 let diff = "\
439diff --git a/pkg/src/old.ts b/pkg/src/new.ts
440similarity index 90%
441rename from pkg/src/old.ts
442rename to pkg/src/new.ts
443--- a/pkg/src/old.ts
444+++ b/pkg/src/new.ts
445@@ -1,1 +1,1 @@
446-old
447+new
448";
449 let index = DiffIndex::from_unified_diff(diff)
450 .with_base("/repo")
451 .with_root_offset("pkg");
452
453 assert_eq!(
455 index.old_path_for_root_relative("src/new.ts").as_deref(),
456 Some("src/old.ts")
457 );
458 assert_eq!(index.old_path_for("pkg/src/new.ts"), Some("pkg/src/old.ts"));
460 assert_eq!(index.old_path_for_root_relative("src/absent.ts"), None);
461 }
462
463 #[test]
464 fn root_relative_key_round_trips() {
465 let index = DiffIndex::default().with_root_offset("packages/pkg");
466 assert_eq!(
467 index.key_for_root_relative("src/a.ts"),
468 "packages/pkg/src/a.ts"
469 );
470 assert_eq!(
471 index
472 .root_relative_from_key("packages/pkg/src/a.ts")
473 .as_deref(),
474 Some("src/a.ts")
475 );
476 assert_eq!(index.root_relative_from_key("other/src/a.ts"), None);
478 assert_eq!(
480 index.root_relative_from_key("packages/pkg-extra/a.ts"),
481 None
482 );
483 }
484
485 #[test]
486 fn empty_root_offset_is_identity() {
487 let index = DiffIndex::default();
488 assert_eq!(index.key_for_root_relative("src/a.ts"), "src/a.ts");
489 assert_eq!(
490 index.root_relative_from_key("src/a.ts").as_deref(),
491 Some("src/a.ts")
492 );
493 }
494
495 #[test]
496 fn touched_files_enumerates_diff_header_paths() {
497 let diff = "\
498diff --git a/pkg/a.ts b/pkg/a.ts
499--- a/pkg/a.ts
500+++ b/pkg/a.ts
501@@ -0,0 +1,1 @@
502+x
503";
504 let index = DiffIndex::from_unified_diff(diff);
505 assert_eq!(index.touched_files().collect::<Vec<_>>(), vec!["pkg/a.ts"]);
506 }
507}