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    /// Parse a unified diff into an index of changed files and added lines.
45    ///
46    /// Indexes at most [`MAX_ADDED_LINES`] added lines; totals used by
47    /// [`DiffIndex::net_lines`] keep counting past the cap.
48    #[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    /// Pre-rename path for `head_path`, in the diff's key namespace, when the
136    /// diff renamed it.
137    #[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    /// Number of indexed added lines, capped at [`MAX_ADDED_LINES`].
143    #[must_use]
144    pub fn added_line_count(&self) -> usize {
145        self.added_line_count
146    }
147
148    /// Number of parsed unified-diff hunk headers.
149    #[must_use]
150    pub fn hunk_count(&self) -> usize {
151        self.hunk_count
152    }
153
154    /// Total added lines minus total removed lines, independent of the added
155    /// line indexing cap.
156    #[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    /// Whether this diff changes the path, including when the path is deleted.
164    #[must_use]
165    pub fn changes_path(&self, path: &str) -> bool {
166        self.changed_paths.contains(path)
167    }
168
169    /// Paths changed by the diff, using the old path for deleted files.
170    pub fn changed_paths(&self) -> impl Iterator<Item = &str> {
171        self.changed_paths.iter().map(String::as_str)
172    }
173
174    /// Whether this diff touches an analyzable path in the head tree.
175    #[must_use]
176    pub fn touches_file(&self, path: &str) -> bool {
177        self.touched_files.contains(path)
178    }
179
180    /// Whether any added line in `path` falls inside the inclusive 1-based
181    /// `start..=end` range.
182    #[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    /// Lowest 1-based added line inside the inclusive `start..=end` range.
188    /// Returns `None` for an invalid range or when the diff adds no line in it.
189    #[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    /// Whether the 1-based `line` in `path` was added by this diff.
204    #[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    /// Whether `line` sits within `radius` lines of any added line in `path`.
212    #[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    /// Set of 1-based added line numbers for `path`, when the diff added any.
220    #[must_use]
221    pub fn added_lines_in(&self, path: &str) -> Option<&FxHashSet<u64>> {
222        self.added_lines.get(path)
223    }
224
225    /// Declare the directory this diff's paths are relative to (the git
226    /// toplevel for `git diff` output).
227    #[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    /// Declare where the analysis root sits below [`DiffIndex::base`], as a
234    /// forward-slashed relative path (empty when they are the same directory).
235    ///
236    /// Findings are addressed relative to the analysis root; this diff's keys
237    /// are relative to its base. Everything that looks a finding up in this
238    /// index has to cross that gap, so the index carries the offset rather than
239    /// making each caller rediscover it.
240    #[must_use]
241    pub fn with_root_offset(mut self, offset: impl Into<String>) -> Self {
242        let mut offset = offset.into();
243        // A trailing separator would make `strip_path_component_prefix` demand a
244        // second one and never match. Normalize rather than trust the caller.
245        offset.truncate(offset.trim_end_matches('/').len());
246        self.root_offset = offset;
247        self
248    }
249
250    /// Relative path of the analysis root below [`DiffIndex::base`]; empty
251    /// when they coincide.
252    #[must_use]
253    pub fn root_offset(&self) -> &str {
254        &self.root_offset
255    }
256
257    /// Lift an analysis-root-relative path into this diff's key namespace.
258    #[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    /// Lower one of this diff's keys back to an analysis-root-relative path.
267    /// `None` when the key names a file outside the analysis root.
268    #[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    /// The pre-rename path of an analysis-root-relative path, itself
277    /// analysis-root-relative. Crosses into the diff's key namespace and back,
278    /// so a monorepo package below the diff's base resolves its renames.
279    #[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    /// Directory this diff's keys are relative to, when declared via
286    /// [`DiffIndex::with_base`].
287    #[must_use]
288    pub fn base(&self) -> Option<&Path> {
289        self.base.as_deref()
290    }
291
292    /// Analyzable head-tree paths this diff touches, in the diff's key
293    /// namespace.
294    pub fn touched_files(&self) -> impl Iterator<Item = &str> {
295        self.touched_files.iter().map(String::as_str)
296    }
297
298    /// Map a finding's path into this diff's key namespace.
299    ///
300    /// Relativizes against [`DiffIndex::base`] when one was declared, else
301    /// against `fallback_root`. When base == `fallback_root` (the analysis
302    /// root is the repository toplevel) both agree, so behavior is unchanged.
303    #[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/// Relativize `path` against `root` with forward slashes, passing an already
310/// relative path through unchanged. `None` for absolute paths outside `root`.
311#[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/// Strip `prefix` and its trailing separator, only on a path-component
323/// boundary, so `packages/pkg-extra/a.ts` is never read as `packages/pkg`
324/// plus `-extra/a.ts`.
325#[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
330/// Parse the new-file start line out of a `@@ -a,b +c,d @@` hunk header.
331pub 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!(index.net_lines(), (MAX_ADDED_LINES + 100) as i64);
363        assert_eq!(index.hunk_count(), 1);
364    }
365
366    #[test]
367    fn from_unified_diff_counts_additions_removals_and_hunks() {
368        let diff = "\
369diff --git a/src/a.ts b/src/a.ts
370--- a/src/a.ts
371+++ b/src/a.ts
372@@ -1,3 +1,4 @@
373-old
374+new
375+extra
376+++flag
377 context
378@@ -10,2 +11,1 @@
379-removed
380---flag
381 kept
382";
383        let index = DiffIndex::from_unified_diff(diff);
384
385        assert_eq!(index.hunk_count(), 2);
386        assert_eq!(index.net_lines(), 0);
387        assert!(index.changes_path("src/a.ts"));
388        assert!(index.touches_file("src/a.ts"));
389    }
390
391    #[test]
392    fn rename_only_diff_has_no_line_or_hunk_changes() {
393        let diff = "\
394diff --git a/src/old.ts b/src/new.ts
395similarity index 100%
396rename from src/old.ts
397rename to src/new.ts
398";
399        let index = DiffIndex::from_unified_diff(diff);
400
401        assert_eq!(index.hunk_count(), 0);
402        assert_eq!(index.net_lines(), 0);
403        assert!(index.changes_path("src/new.ts"));
404        assert!(index.touches_file("src/new.ts"));
405    }
406
407    #[test]
408    fn range_overlaps_added_hotspot_starting_before_diff_touches_inside() {
409        let diff = "\
410diff --git a/src/big.ts b/src/big.ts
411--- a/src/big.ts
412+++ b/src/big.ts
413@@ -114,1 +114,2 @@
414 ctx
415+touched
416";
417        let index = DiffIndex::from_unified_diff(diff);
418        assert!(index.range_overlaps_added("src/big.ts", 10, 120));
419        assert!(!index.range_overlaps_added("src/other.ts", 10, 120));
420        assert!(!index.range_overlaps_added("src/big.ts", 10, 100));
421        assert!(!index.range_overlaps_added("src/big.ts", 200, 100));
422    }
423
424    #[test]
425    fn rename_header_records_old_path() {
426        let diff = "\
427diff --git a/src/old.ts b/src/new.ts
428similarity index 90%
429rename from src/old.ts
430rename to src/new.ts
431--- a/src/old.ts
432+++ b/src/new.ts
433@@ -1,1 +1,1 @@
434-old
435+new
436";
437        let index = DiffIndex::from_unified_diff(diff);
438        assert_eq!(index.old_path_for("src/new.ts"), Some("src/old.ts"));
439        assert!(index.touches_file("src/new.ts"));
440    }
441
442    #[test]
443    fn empty_diff_has_zero_added_lines_and_no_touched_files() {
444        let index = DiffIndex::from_unified_diff("");
445        assert_eq!(index.added_line_count(), 0);
446        assert!(!index.touches_file("src/a.ts"));
447    }
448
449    #[test]
450    fn delete_only_diff_records_removal_without_touching_head_file() {
451        let diff = "\
452diff --git a/src/a.ts b/src/a.ts
453--- a/src/a.ts
454+++ /dev/null
455@@ -1,1 +0,0 @@
456-old
457";
458        let index = DiffIndex::from_unified_diff(diff);
459        assert_eq!(index.added_line_count(), 0);
460        assert_eq!(index.hunk_count(), 1);
461        assert_eq!(index.net_lines(), -1);
462        assert!(index.changes_path("src/a.ts"));
463        assert!(!index.touches_file("src/a.ts"));
464    }
465
466    #[test]
467    fn relative_to_diff_path_strips_absolute_root() {
468        let root = Path::new("/project");
469        let path = Path::new("/project/src/a.ts");
470        assert_eq!(
471            relative_to_diff_path(path, root).as_deref(),
472            Some("src/a.ts")
473        );
474    }
475
476    #[test]
477    fn relative_to_diff_path_passes_through_relative() {
478        let root = Path::new("/project");
479        let path = Path::new("src/a.ts");
480        assert_eq!(
481            relative_to_diff_path(path, root).as_deref(),
482            Some("src/a.ts")
483        );
484    }
485
486    #[test]
487    fn relative_to_diff_path_returns_none_for_path_outside_root() {
488        let root = Path::new("/project");
489        let path = Path::new("/elsewhere/src/a.ts");
490        assert!(relative_to_diff_path(path, root).is_none());
491    }
492
493    #[test]
494    fn key_for_without_base_relativizes_against_the_fallback_root() {
495        let index = DiffIndex::default();
496        assert_eq!(
497            index
498                .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
499                .as_deref(),
500            Some("src/a.ts")
501        );
502    }
503
504    #[test]
505    fn key_for_with_base_equal_to_root_is_unchanged() {
506        let index = DiffIndex::default().with_base("/repo");
507        assert_eq!(
508            index
509                .key_for(Path::new("/repo/src/a.ts"), Path::new("/repo"))
510                .as_deref(),
511            Some("src/a.ts")
512        );
513    }
514
515    /// The regression: an analysis root below the repo toplevel must still
516    /// produce the toplevel-relative key `git diff` writes.
517    #[test]
518    fn key_for_with_base_above_root_yields_repo_root_relative_key() {
519        let index = DiffIndex::default().with_base("/repo");
520        assert_eq!(
521            index
522                .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
523                .as_deref(),
524            Some("pkg/src/a.ts")
525        );
526    }
527
528    #[test]
529    fn key_for_with_base_above_root_matches_a_repo_root_relative_diff() {
530        let diff = "\
531diff --git a/pkg/src/a.ts b/pkg/src/a.ts
532--- a/pkg/src/a.ts
533+++ b/pkg/src/a.ts
534@@ -1,0 +2,1 @@
535+added
536";
537        let index = DiffIndex::from_unified_diff(diff).with_base("/repo");
538        let key = index
539            .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
540            .expect("finding path is under the base");
541
542        assert!(index.touches_file(&key));
543        assert!(index.line_is_added(&key, 2));
544
545        // Without the base, the same finding keys as `src/a.ts` and misses.
546        let unbased = DiffIndex::from_unified_diff(diff);
547        let missed = unbased
548            .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
549            .expect("still relativizable");
550        assert_eq!(missed, "src/a.ts");
551        assert!(!unbased.touches_file(&missed));
552    }
553
554    #[test]
555    fn key_for_returns_none_for_path_outside_the_base() {
556        let index = DiffIndex::default().with_base("/repo");
557        assert!(
558            index
559                .key_for(Path::new("/elsewhere/a.ts"), Path::new("/repo/pkg"))
560                .is_none()
561        );
562    }
563
564    #[test]
565    fn old_path_for_root_relative_crosses_the_namespace_and_back() {
566        let diff = "\
567diff --git a/pkg/src/old.ts b/pkg/src/new.ts
568similarity index 90%
569rename from pkg/src/old.ts
570rename to pkg/src/new.ts
571--- a/pkg/src/old.ts
572+++ b/pkg/src/new.ts
573@@ -1,1 +1,1 @@
574-old
575+new
576";
577        let index = DiffIndex::from_unified_diff(diff)
578            .with_base("/repo")
579            .with_root_offset("pkg");
580
581        // The finding is addressed `src/new.ts`; the diff says `pkg/src/new.ts`.
582        assert_eq!(
583            index.old_path_for_root_relative("src/new.ts").as_deref(),
584            Some("src/old.ts")
585        );
586        // The raw lookup, in the diff's own namespace, still works.
587        assert_eq!(index.old_path_for("pkg/src/new.ts"), Some("pkg/src/old.ts"));
588        assert_eq!(index.old_path_for_root_relative("src/absent.ts"), None);
589    }
590
591    #[test]
592    fn root_relative_key_round_trips() {
593        let index = DiffIndex::default().with_root_offset("packages/pkg");
594        assert_eq!(
595            index.key_for_root_relative("src/a.ts"),
596            "packages/pkg/src/a.ts"
597        );
598        assert_eq!(
599            index
600                .root_relative_from_key("packages/pkg/src/a.ts")
601                .as_deref(),
602            Some("src/a.ts")
603        );
604        // A key outside the analysis root has no root-relative form.
605        assert_eq!(index.root_relative_from_key("other/src/a.ts"), None);
606        // Sibling directory sharing a name prefix is not a match.
607        assert_eq!(
608            index.root_relative_from_key("packages/pkg-extra/a.ts"),
609            None
610        );
611    }
612
613    #[test]
614    fn empty_root_offset_is_identity() {
615        let index = DiffIndex::default();
616        assert_eq!(index.key_for_root_relative("src/a.ts"), "src/a.ts");
617        assert_eq!(
618            index.root_relative_from_key("src/a.ts").as_deref(),
619            Some("src/a.ts")
620        );
621    }
622
623    #[test]
624    fn touched_files_enumerates_diff_header_paths() {
625        let diff = "\
626diff --git a/pkg/a.ts b/pkg/a.ts
627--- a/pkg/a.ts
628+++ b/pkg/a.ts
629@@ -0,0 +1,1 @@
630+x
631";
632        let index = DiffIndex::from_unified_diff(diff);
633        assert_eq!(index.touched_files().collect::<Vec<_>>(), vec!["pkg/a.ts"]);
634    }
635}