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    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/// Mutable cursor state threaded through unified-diff parsing.
35#[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    /// Number of parsed unified-diff hunk headers.
142    #[must_use]
143    pub fn hunk_count(&self) -> usize {
144        self.hunk_count
145    }
146
147    /// Total added lines minus total removed lines, independent of the added
148    /// line indexing cap.
149    #[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    /// Whether this diff changes the path, including when the path is deleted.
157    #[must_use]
158    pub fn changes_path(&self, path: &str) -> bool {
159        self.changed_paths.contains(path)
160    }
161
162    /// Paths changed by the diff, using the old path for deleted files.
163    pub fn changed_paths(&self) -> impl Iterator<Item = &str> {
164        self.changed_paths.iter().map(String::as_str)
165    }
166
167    /// Whether this diff touches an analyzable path in the head tree.
168    #[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    /// Declare the directory this diff's paths are relative to (the git
205    /// toplevel for `git diff` output).
206    #[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    /// Declare where the analysis root sits below [`DiffIndex::base`], as a
213    /// forward-slashed relative path (empty when they are the same directory).
214    ///
215    /// Findings are addressed relative to the analysis root; this diff's keys
216    /// are relative to its base. Everything that looks a finding up in this
217    /// index has to cross that gap, so the index carries the offset rather than
218    /// making each caller rediscover it.
219    #[must_use]
220    pub fn with_root_offset(mut self, offset: impl Into<String>) -> Self {
221        let mut offset = offset.into();
222        // A trailing separator would make `strip_path_component_prefix` demand a
223        // second one and never match. Normalize rather than trust the caller.
224        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    /// Lift an analysis-root-relative path into this diff's key namespace.
235    #[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    /// Lower one of this diff's keys back to an analysis-root-relative path.
244    /// `None` when the key names a file outside the analysis root.
245    #[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    /// The pre-rename path of an analysis-root-relative path, itself
254    /// analysis-root-relative. Crosses into the diff's key namespace and back,
255    /// so a monorepo package below the diff's base resolves its renames.
256    #[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    /// Map a finding's path into this diff's key namespace.
272    ///
273    /// Relativizes against [`DiffIndex::base`] when one was declared, else
274    /// against `fallback_root`. When base == `fallback_root` (the analysis
275    /// root is the repository toplevel) both agree, so behavior is unchanged.
276    #[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/// Strip `prefix` and its trailing separator, only on a path-component
294/// boundary, so `packages/pkg-extra/a.ts` is never read as `packages/pkg`
295/// plus `-extra/a.ts`.
296#[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    /// The regression: an analysis root below the repo toplevel must still
486    /// produce the toplevel-relative key `git diff` writes.
487    #[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        // Without the base, the same finding keys as `src/a.ts` and misses.
516        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        // The finding is addressed `src/new.ts`; the diff says `pkg/src/new.ts`.
552        assert_eq!(
553            index.old_path_for_root_relative("src/new.ts").as_deref(),
554            Some("src/old.ts")
555        );
556        // The raw lookup, in the diff's own namespace, still works.
557        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        // A key outside the analysis root has no root-relative form.
575        assert_eq!(index.root_relative_from_key("other/src/a.ts"), None);
576        // Sibling directory sharing a name prefix is not a match.
577        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}