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        if end < start {
185            return false;
186        }
187        let Some(added) = self.added_lines.get(path) else {
188            return false;
189        };
190        let lo = start.max(1);
191        added.iter().any(|&line| line >= lo && line <= end)
192    }
193
194    /// Whether the 1-based `line` in `path` was added by this diff.
195    #[must_use]
196    pub fn line_is_added(&self, path: &str, line: u64) -> bool {
197        self.added_lines
198            .get(path)
199            .is_some_and(|lines| lines.contains(&line))
200    }
201
202    /// Whether `line` sits within `radius` lines of any added line in `path`.
203    #[must_use]
204    pub fn line_within_added_context(&self, path: &str, line: u64, radius: u64) -> bool {
205        self.added_lines
206            .get(path)
207            .is_some_and(|lines| lines.iter().any(|added| line.abs_diff(*added) <= radius))
208    }
209
210    /// Set of 1-based added line numbers for `path`, when the diff added any.
211    #[must_use]
212    pub fn added_lines_in(&self, path: &str) -> Option<&FxHashSet<u64>> {
213        self.added_lines.get(path)
214    }
215
216    /// Declare the directory this diff's paths are relative to (the git
217    /// toplevel for `git diff` output).
218    #[must_use]
219    pub fn with_base(mut self, base: impl Into<PathBuf>) -> Self {
220        self.base = Some(base.into());
221        self
222    }
223
224    /// Declare where the analysis root sits below [`DiffIndex::base`], as a
225    /// forward-slashed relative path (empty when they are the same directory).
226    ///
227    /// Findings are addressed relative to the analysis root; this diff's keys
228    /// are relative to its base. Everything that looks a finding up in this
229    /// index has to cross that gap, so the index carries the offset rather than
230    /// making each caller rediscover it.
231    #[must_use]
232    pub fn with_root_offset(mut self, offset: impl Into<String>) -> Self {
233        let mut offset = offset.into();
234        // A trailing separator would make `strip_path_component_prefix` demand a
235        // second one and never match. Normalize rather than trust the caller.
236        offset.truncate(offset.trim_end_matches('/').len());
237        self.root_offset = offset;
238        self
239    }
240
241    /// Relative path of the analysis root below [`DiffIndex::base`]; empty
242    /// when they coincide.
243    #[must_use]
244    pub fn root_offset(&self) -> &str {
245        &self.root_offset
246    }
247
248    /// Lift an analysis-root-relative path into this diff's key namespace.
249    #[must_use]
250    pub fn key_for_root_relative<'a>(&self, rel: &'a str) -> Cow<'a, str> {
251        if self.root_offset.is_empty() {
252            return Cow::Borrowed(rel);
253        }
254        Cow::Owned(format!("{}/{rel}", self.root_offset))
255    }
256
257    /// Lower one of this diff's keys back to an analysis-root-relative path.
258    /// `None` when the key names a file outside the analysis root.
259    #[must_use]
260    pub fn root_relative_from_key<'a>(&self, key: &'a str) -> Option<Cow<'a, str>> {
261        if self.root_offset.is_empty() {
262            return Some(Cow::Borrowed(key));
263        }
264        strip_path_component_prefix(key, &self.root_offset).map(Cow::Borrowed)
265    }
266
267    /// The pre-rename path of an analysis-root-relative path, itself
268    /// analysis-root-relative. Crosses into the diff's key namespace and back,
269    /// so a monorepo package below the diff's base resolves its renames.
270    #[must_use]
271    pub fn old_path_for_root_relative<'a>(&'a self, rel: &str) -> Option<Cow<'a, str>> {
272        let old = self.old_path_for(&self.key_for_root_relative(rel))?;
273        self.root_relative_from_key(old)
274    }
275
276    /// Directory this diff's keys are relative to, when declared via
277    /// [`DiffIndex::with_base`].
278    #[must_use]
279    pub fn base(&self) -> Option<&Path> {
280        self.base.as_deref()
281    }
282
283    /// Analyzable head-tree paths this diff touches, in the diff's key
284    /// namespace.
285    pub fn touched_files(&self) -> impl Iterator<Item = &str> {
286        self.touched_files.iter().map(String::as_str)
287    }
288
289    /// Map a finding's path into this diff's key namespace.
290    ///
291    /// Relativizes against [`DiffIndex::base`] when one was declared, else
292    /// against `fallback_root`. When base == `fallback_root` (the analysis
293    /// root is the repository toplevel) both agree, so behavior is unchanged.
294    #[must_use]
295    pub fn key_for(&self, path: &Path, fallback_root: &Path) -> Option<String> {
296        relative_to_diff_path(path, self.base.as_deref().unwrap_or(fallback_root))
297    }
298}
299
300/// Relativize `path` against `root` with forward slashes, passing an already
301/// relative path through unchanged. `None` for absolute paths outside `root`.
302#[must_use]
303pub fn relative_to_diff_path(path: &Path, root: &Path) -> Option<String> {
304    if let Ok(stripped) = path.strip_prefix(root) {
305        return Some(stripped.to_string_lossy().replace('\\', "/"));
306    }
307    if fallow_types::path_util::is_absolute_path_any_platform(path) {
308        return None;
309    }
310    Some(path.to_string_lossy().replace('\\', "/"))
311}
312
313/// Strip `prefix` and its trailing separator, only on a path-component
314/// boundary, so `packages/pkg-extra/a.ts` is never read as `packages/pkg`
315/// plus `-extra/a.ts`.
316#[must_use]
317pub fn strip_path_component_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
318    path.strip_prefix(prefix)?.strip_prefix('/')
319}
320
321/// Parse the new-file start line out of a `@@ -a,b +c,d @@` hunk header.
322pub fn parse_new_hunk_start(header: &str) -> Option<u64> {
323    let plus = header.find('+')?;
324    let rest = &header[plus + 1..];
325    let end = rest
326        .find(|c: char| c == ',' || c.is_ascii_whitespace())
327        .unwrap_or(rest.len());
328    rest[..end].parse().ok()
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn from_unified_diff_caps_added_lines_at_threshold() {
337        let header =
338            "diff --git a/big.txt b/big.txt\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,100 @@\n";
339        let mut body = String::with_capacity(MAX_ADDED_LINES * 16);
340        for _ in 0..(MAX_ADDED_LINES + 100) {
341            body.push_str("+x\n");
342        }
343        let mut diff = String::with_capacity(header.len() + body.len());
344        diff.push_str(header);
345        diff.push_str(&body);
346
347        let index = DiffIndex::from_unified_diff(&diff);
348        assert!(
349            index.added_line_count() <= MAX_ADDED_LINES,
350            "indexed {} lines, cap is {MAX_ADDED_LINES}",
351            index.added_line_count()
352        );
353        assert_eq!(index.net_lines(), (MAX_ADDED_LINES + 100) as i64);
354        assert_eq!(index.hunk_count(), 1);
355    }
356
357    #[test]
358    fn from_unified_diff_counts_additions_removals_and_hunks() {
359        let diff = "\
360diff --git a/src/a.ts b/src/a.ts
361--- a/src/a.ts
362+++ b/src/a.ts
363@@ -1,3 +1,4 @@
364-old
365+new
366+extra
367+++flag
368 context
369@@ -10,2 +11,1 @@
370-removed
371---flag
372 kept
373";
374        let index = DiffIndex::from_unified_diff(diff);
375
376        assert_eq!(index.hunk_count(), 2);
377        assert_eq!(index.net_lines(), 0);
378        assert!(index.changes_path("src/a.ts"));
379        assert!(index.touches_file("src/a.ts"));
380    }
381
382    #[test]
383    fn rename_only_diff_has_no_line_or_hunk_changes() {
384        let diff = "\
385diff --git a/src/old.ts b/src/new.ts
386similarity index 100%
387rename from src/old.ts
388rename to src/new.ts
389";
390        let index = DiffIndex::from_unified_diff(diff);
391
392        assert_eq!(index.hunk_count(), 0);
393        assert_eq!(index.net_lines(), 0);
394        assert!(index.changes_path("src/new.ts"));
395        assert!(index.touches_file("src/new.ts"));
396    }
397
398    #[test]
399    fn range_overlaps_added_hotspot_starting_before_diff_touches_inside() {
400        let diff = "\
401diff --git a/src/big.ts b/src/big.ts
402--- a/src/big.ts
403+++ b/src/big.ts
404@@ -114,1 +114,2 @@
405 ctx
406+touched
407";
408        let index = DiffIndex::from_unified_diff(diff);
409        assert!(index.range_overlaps_added("src/big.ts", 10, 120));
410        assert!(!index.range_overlaps_added("src/other.ts", 10, 120));
411        assert!(!index.range_overlaps_added("src/big.ts", 10, 100));
412        assert!(!index.range_overlaps_added("src/big.ts", 200, 100));
413    }
414
415    #[test]
416    fn rename_header_records_old_path() {
417        let diff = "\
418diff --git a/src/old.ts b/src/new.ts
419similarity index 90%
420rename from src/old.ts
421rename to src/new.ts
422--- a/src/old.ts
423+++ b/src/new.ts
424@@ -1,1 +1,1 @@
425-old
426+new
427";
428        let index = DiffIndex::from_unified_diff(diff);
429        assert_eq!(index.old_path_for("src/new.ts"), Some("src/old.ts"));
430        assert!(index.touches_file("src/new.ts"));
431    }
432
433    #[test]
434    fn empty_diff_has_zero_added_lines_and_no_touched_files() {
435        let index = DiffIndex::from_unified_diff("");
436        assert_eq!(index.added_line_count(), 0);
437        assert!(!index.touches_file("src/a.ts"));
438    }
439
440    #[test]
441    fn delete_only_diff_records_removal_without_touching_head_file() {
442        let diff = "\
443diff --git a/src/a.ts b/src/a.ts
444--- a/src/a.ts
445+++ /dev/null
446@@ -1,1 +0,0 @@
447-old
448";
449        let index = DiffIndex::from_unified_diff(diff);
450        assert_eq!(index.added_line_count(), 0);
451        assert_eq!(index.hunk_count(), 1);
452        assert_eq!(index.net_lines(), -1);
453        assert!(index.changes_path("src/a.ts"));
454        assert!(!index.touches_file("src/a.ts"));
455    }
456
457    #[test]
458    fn relative_to_diff_path_strips_absolute_root() {
459        let root = Path::new("/project");
460        let path = Path::new("/project/src/a.ts");
461        assert_eq!(
462            relative_to_diff_path(path, root).as_deref(),
463            Some("src/a.ts")
464        );
465    }
466
467    #[test]
468    fn relative_to_diff_path_passes_through_relative() {
469        let root = Path::new("/project");
470        let path = Path::new("src/a.ts");
471        assert_eq!(
472            relative_to_diff_path(path, root).as_deref(),
473            Some("src/a.ts")
474        );
475    }
476
477    #[test]
478    fn relative_to_diff_path_returns_none_for_path_outside_root() {
479        let root = Path::new("/project");
480        let path = Path::new("/elsewhere/src/a.ts");
481        assert!(relative_to_diff_path(path, root).is_none());
482    }
483
484    #[test]
485    fn key_for_without_base_relativizes_against_the_fallback_root() {
486        let index = DiffIndex::default();
487        assert_eq!(
488            index
489                .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
490                .as_deref(),
491            Some("src/a.ts")
492        );
493    }
494
495    #[test]
496    fn key_for_with_base_equal_to_root_is_unchanged() {
497        let index = DiffIndex::default().with_base("/repo");
498        assert_eq!(
499            index
500                .key_for(Path::new("/repo/src/a.ts"), Path::new("/repo"))
501                .as_deref(),
502            Some("src/a.ts")
503        );
504    }
505
506    /// The regression: an analysis root below the repo toplevel must still
507    /// produce the toplevel-relative key `git diff` writes.
508    #[test]
509    fn key_for_with_base_above_root_yields_repo_root_relative_key() {
510        let index = DiffIndex::default().with_base("/repo");
511        assert_eq!(
512            index
513                .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
514                .as_deref(),
515            Some("pkg/src/a.ts")
516        );
517    }
518
519    #[test]
520    fn key_for_with_base_above_root_matches_a_repo_root_relative_diff() {
521        let diff = "\
522diff --git a/pkg/src/a.ts b/pkg/src/a.ts
523--- a/pkg/src/a.ts
524+++ b/pkg/src/a.ts
525@@ -1,0 +2,1 @@
526+added
527";
528        let index = DiffIndex::from_unified_diff(diff).with_base("/repo");
529        let key = index
530            .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
531            .expect("finding path is under the base");
532
533        assert!(index.touches_file(&key));
534        assert!(index.line_is_added(&key, 2));
535
536        // Without the base, the same finding keys as `src/a.ts` and misses.
537        let unbased = DiffIndex::from_unified_diff(diff);
538        let missed = unbased
539            .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
540            .expect("still relativizable");
541        assert_eq!(missed, "src/a.ts");
542        assert!(!unbased.touches_file(&missed));
543    }
544
545    #[test]
546    fn key_for_returns_none_for_path_outside_the_base() {
547        let index = DiffIndex::default().with_base("/repo");
548        assert!(
549            index
550                .key_for(Path::new("/elsewhere/a.ts"), Path::new("/repo/pkg"))
551                .is_none()
552        );
553    }
554
555    #[test]
556    fn old_path_for_root_relative_crosses_the_namespace_and_back() {
557        let diff = "\
558diff --git a/pkg/src/old.ts b/pkg/src/new.ts
559similarity index 90%
560rename from pkg/src/old.ts
561rename to pkg/src/new.ts
562--- a/pkg/src/old.ts
563+++ b/pkg/src/new.ts
564@@ -1,1 +1,1 @@
565-old
566+new
567";
568        let index = DiffIndex::from_unified_diff(diff)
569            .with_base("/repo")
570            .with_root_offset("pkg");
571
572        // The finding is addressed `src/new.ts`; the diff says `pkg/src/new.ts`.
573        assert_eq!(
574            index.old_path_for_root_relative("src/new.ts").as_deref(),
575            Some("src/old.ts")
576        );
577        // The raw lookup, in the diff's own namespace, still works.
578        assert_eq!(index.old_path_for("pkg/src/new.ts"), Some("pkg/src/old.ts"));
579        assert_eq!(index.old_path_for_root_relative("src/absent.ts"), None);
580    }
581
582    #[test]
583    fn root_relative_key_round_trips() {
584        let index = DiffIndex::default().with_root_offset("packages/pkg");
585        assert_eq!(
586            index.key_for_root_relative("src/a.ts"),
587            "packages/pkg/src/a.ts"
588        );
589        assert_eq!(
590            index
591                .root_relative_from_key("packages/pkg/src/a.ts")
592                .as_deref(),
593            Some("src/a.ts")
594        );
595        // A key outside the analysis root has no root-relative form.
596        assert_eq!(index.root_relative_from_key("other/src/a.ts"), None);
597        // Sibling directory sharing a name prefix is not a match.
598        assert_eq!(
599            index.root_relative_from_key("packages/pkg-extra/a.ts"),
600            None
601        );
602    }
603
604    #[test]
605    fn empty_root_offset_is_identity() {
606        let index = DiffIndex::default();
607        assert_eq!(index.key_for_root_relative("src/a.ts"), "src/a.ts");
608        assert_eq!(
609            index.root_relative_from_key("src/a.ts").as_deref(),
610            Some("src/a.ts")
611        );
612    }
613
614    #[test]
615    fn touched_files_enumerates_diff_header_paths() {
616        let diff = "\
617diff --git a/pkg/a.ts b/pkg/a.ts
618--- a/pkg/a.ts
619+++ b/pkg/a.ts
620@@ -0,0 +1,1 @@
621+x
622";
623        let index = DiffIndex::from_unified_diff(diff);
624        assert_eq!(index.touched_files().collect::<Vec<_>>(), vec!["pkg/a.ts"]);
625    }
626}