Skip to main content

fallow_output/
diff.rs

1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3
4use rustc_hash::{FxHashMap, FxHashSet};
5
6/// Refuse to parse a unified diff larger than this.
7pub const MAX_DIFF_BYTES: u64 = 10 * 1024 * 1024;
8
9/// Stop indexing added lines past this count.
10pub const MAX_ADDED_LINES: usize = 1_000_000;
11
12/// Parsed, command-neutral index of files and added lines in a unified diff.
13///
14/// Keys are exactly the paths the diff names in its `+++ b/<path>` headers,
15/// so they live in whatever namespace produced the diff — for `git diff`,
16/// relative to the repository toplevel. [`DiffIndex::base`] records the
17/// directory those keys are relative to, so a finding's absolute path can be
18/// mapped into the same namespace before lookup. Without it, an analysis root
19/// below the toplevel silently misses every key.
20#[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/// Mutable cursor state threaded through unified-diff parsing.
31#[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    /// Declare the directory this diff's paths are relative to (the git
153    /// toplevel for `git diff` output).
154    #[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    /// Declare where the analysis root sits below [`DiffIndex::base`], as a
161    /// forward-slashed relative path (empty when they are the same directory).
162    ///
163    /// Findings are addressed relative to the analysis root; this diff's keys
164    /// are relative to its base. Everything that looks a finding up in this
165    /// index has to cross that gap, so the index carries the offset rather than
166    /// making each caller rediscover it.
167    #[must_use]
168    pub fn with_root_offset(mut self, offset: impl Into<String>) -> Self {
169        let mut offset = offset.into();
170        // A trailing separator would make `strip_path_component_prefix` demand a
171        // second one and never match. Normalize rather than trust the caller.
172        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    /// Lift an analysis-root-relative path into this diff's key namespace.
183    #[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    /// Lower one of this diff's keys back to an analysis-root-relative path.
192    /// `None` when the key names a file outside the analysis root.
193    #[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    /// The pre-rename path of an analysis-root-relative path, itself
202    /// analysis-root-relative. Crosses into the diff's key namespace and back,
203    /// so a monorepo package below the diff's base resolves its renames.
204    #[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    /// Map a finding's path into this diff's key namespace.
220    ///
221    /// Relativizes against [`DiffIndex::base`] when one was declared, else
222    /// against `fallback_root`. When base == `fallback_root` (the analysis
223    /// root is the repository toplevel) both agree, so behavior is unchanged.
224    #[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/// Strip `prefix` and its trailing separator, only on a path-component
242/// boundary, so `packages/pkg-extra/a.ts` is never read as `packages/pkg`
243/// plus `-extra/a.ts`.
244#[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    /// The regression: an analysis root below the repo toplevel must still
388    /// produce the toplevel-relative key `git diff` writes.
389    #[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        // Without the base, the same finding keys as `src/a.ts` and misses.
418        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        // The finding is addressed `src/new.ts`; the diff says `pkg/src/new.ts`.
454        assert_eq!(
455            index.old_path_for_root_relative("src/new.ts").as_deref(),
456            Some("src/old.ts")
457        );
458        // The raw lookup, in the diff's own namespace, still works.
459        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        // A key outside the analysis root has no root-relative form.
477        assert_eq!(index.root_relative_from_key("other/src/a.ts"), None);
478        // Sibling directory sharing a name prefix is not a match.
479        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}