Skip to main content

drep/diff/
hunks.rs

1//! Diff hunks: the structured form of a unified diff, and the parser that
2//! produces it.
3//!
4//! A single `git diff --unified=N` output is just text, but everything that
5//! consumes it wants the same shape: a list of hunks, each tagged with its
6//! file and the line ranges it touches, and each line tagged with whether it
7//! was added, removed, or context. Parsing this once, here, means the callers
8//! (`mod.rs` queries, the payload renderer) never re-parse git's output and
9//! never have to ask "what does `+++ /dev/null` mean again".
10//!
11//! The parser is deliberately tolerant. A diff that cannot be parsed must not
12//! take the gate down — it is much better to ship a partial answer than no
13//! answer — so anything not recognised is skipped rather than erroring.
14//!
15//! Deliberately **free of drep policy**: this module answers "what does this
16//! diff say", not "which files does drep review". Whether a path is worth
17//! analyzing is a product decision, and it lives with the other git-semantics
18//! decisions in `mod.rs` beside `filter_paths`. Keeping it out of here
19//! is what lets the parser serve a future caller with a different scope (an
20//! `include`/`exclude` config, `lint-docs`) without threading a predicate
21//! through it.
22
23use std::collections::BTreeMap;
24use std::path::{Path, PathBuf};
25
26use super::quoting::decode;
27
28/// One line inside a hunk, tagged by what the diff said about it.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum HunkLine {
31    /// Unchanged line, present in both old and new file.
32    Context(String),
33    /// Line present only in the new file.
34    Added(String),
35    /// Line present only in the old file. Has no new-file line number.
36    Removed(String),
37}
38
39impl HunkLine {
40    /// The gutter marker for this line kind, matching unified-diff notation.
41    ///
42    /// Lives on the type rather than in the renderer so that "which character
43    /// means removed" is answered once. The renderer pairs it with
44    /// [`Hunk::numbered_lines`], and the two together are the whole gutter.
45    pub const fn marker(&self) -> char {
46        match self {
47            HunkLine::Context(_) => ' ',
48            HunkLine::Added(_) => '+',
49            HunkLine::Removed(_) => '-',
50        }
51    }
52
53    /// The line's text, with the diff's leading marker already stripped.
54    pub fn content(&self) -> &str {
55        match self {
56            HunkLine::Context(s) | HunkLine::Added(s) | HunkLine::Removed(s) => s,
57        }
58    }
59}
60
61/// One `@@` hunk from a unified diff, with its file.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Hunk {
64    pub file_path: PathBuf,
65    pub old_start: u32,
66    pub old_count: u32,
67    pub new_start: u32,
68    pub new_count: u32,
69    pub lines: Vec<HunkLine>,
70}
71
72/// Group owned hunks by file in deterministic path order.
73///
74/// Shared by input resolution and the analyzer's defensive public boundary so
75/// the normal grouping rule cannot diverge from its release-mode recovery.
76pub(crate) fn group_by_file(hunks: impl IntoIterator<Item = Hunk>) -> Vec<Vec<Hunk>> {
77    let mut by_file: BTreeMap<PathBuf, Vec<Hunk>> = BTreeMap::new();
78    for hunk in hunks {
79        by_file
80            .entry(hunk.file_path.clone())
81            .or_default()
82            .push(hunk);
83    }
84    by_file.into_values().collect()
85}
86
87impl Hunk {
88    /// Every line in the hunk paired with its new-file line number, or `None`
89    /// for a `Removed` line.
90    ///
91    /// **This is the single implementation of the line-numbering rule**, and
92    /// everything that needs a line number goes through it. Numbering starts
93    /// at `new_start` and advances for each `Context` or `Added` line;
94    /// `Removed` lines do not advance it, because they do not exist in the new
95    /// file at all.
96    ///
97    /// That rule is what `Payload::valid_lines` rests on, and therefore what
98    /// decides whether an LLM finding gets attributed to the right code. It
99    /// previously existed twice — once here and once inline in the renderer —
100    /// which meant hardening one did nothing for the other.
101    pub fn numbered_lines(&self) -> impl Iterator<Item = (Option<u32>, &HunkLine)> {
102        let mut next = self.new_start;
103        self.lines.iter().map(move |line| match line {
104            HunkLine::Removed(_) => (None, line),
105            HunkLine::Context(_) | HunkLine::Added(_) => {
106                let number = next;
107                next = next.saturating_add(1);
108                (Some(number), line)
109            }
110        })
111    }
112
113    /// Just the lines that exist in the new file, with their line numbers.
114    ///
115    /// A projection of [`Self::numbered_lines`], not a second walk: callers
116    /// that only care about real file lines (checking a parse against the file
117    /// on disk, say) get them without restating how numbering works.
118    pub fn numbered_new_lines(&self) -> impl Iterator<Item = (u32, &str)> {
119        self.numbered_lines()
120            .filter_map(|(number, line)| number.map(|n| (n, line.content())))
121    }
122
123    /// Build a synthetic hunk covering an entire file's content, for
124    /// `drep check PATHS` where there is no diff to consult.
125    ///
126    /// Every line is `Context` and numbering starts at 1, so the renderer
127    /// walks it with the same `numbered_lines` iterator it uses for a real
128    /// hunk. It also selects the whole-file scope sentence, because no line is
129    /// added or removed — an inference that holds because **git never emits a
130    /// hunk with no changed line**: hunks exist only around changes. That
131    /// property is load-bearing and is stated here because it belongs to an
132    /// external tool rather than to this type.
133    pub fn whole_file(file_path: PathBuf, content: &str) -> Hunk {
134        let lines: Vec<HunkLine> = content
135            .lines()
136            .map(|line| HunkLine::Context(line.to_owned()))
137            .collect();
138        let new_count = lines.len() as u32;
139        Hunk {
140            file_path,
141            old_start: 0,
142            old_count: 0,
143            new_start: 1,
144            new_count,
145            lines,
146        }
147    }
148}
149
150/// Parse the output of `git diff --unified=N` into hunks.
151///
152/// Tolerant by design: anything it does not recognise is skipped rather than
153/// erroring, because a diff that cannot be parsed must not take the gate down.
154/// The rules, which follow git's own reader (`apply.c`):
155///
156/// - **A hunk body is exactly as long as its `@@` counts say.** Context
157///   consumes a line from each side, a removal one old line, an addition one
158///   new line, and `\ No newline at end of file` none; that marker refers to
159///   the preceding line and never becomes a `HunkLine`. Every body line is
160///   prefixed, so no content can end a body early: an added source line that
161///   reads `++ /dev/null` arrives as `+++ /dev/null` and is still an addition,
162///   not a deletion header that hides the rest of the file from review, and a
163///   removed one reading `-- x` arrives as `--- x` and is still a removal.
164/// - An empty body line is an empty context line, as `diff.suppressBlankEmpty`
165///   prints it. Any other unprefixed line, or one the counts leave no room
166///   for, ends the hunk early and is then read as a header.
167/// - **The file path comes from the `+++` label, never from `diff --git`.**
168///   That line carries two paths with no unambiguous separator, so any "find
169///   `b/`" rule captures the wrong span for a repository path that itself
170///   contains `b/` (`src/b/mod.rs`). The label is decoded as git quoted it, and
171///   the tab git appends to a name containing a space is dropped - see
172///   `new_file_path`. `+++ /dev/null` marks a deletion, which has nothing to
173///   analyze, so its hunks are dropped.
174/// - A malformed `@@` produces no hunk, and nothing after it is attributed to
175///   the hunk before it.
176pub fn parse_unified_diff(diff_text: &str) -> Vec<Hunk> {
177    let mut hunks: Vec<Hunk> = Vec::new();
178    // `None` means "no file to attribute a hunk to" - either we have not
179    // reached this file's `+++` line yet, or it was a deletion. A `@@` seen
180    // while it is `None` produces no hunk, which is what drops a deleted
181    // file's body without a separate flag to track.
182    let mut current_file: Option<PathBuf> = None;
183    let mut body: Option<Body> = None;
184
185    for line in diff_text.lines() {
186        // A body whose counts are used up accepts only the no-newline marker,
187        // so the first line after it closes it here and is read as a header.
188        if let Some(open) = body.as_mut() {
189            if open.accept(line) {
190                continue;
191            }
192            hunks.extend(body.take().map(Body::finish));
193        }
194
195        if line.starts_with("diff --git ") {
196            current_file = None;
197        } else if let Some(label) = line.strip_prefix("+++ ") {
198            current_file = new_file_path(label);
199        } else if let Some(after_marker) = line.strip_prefix("@@") {
200            body = parse_hunk_header(after_marker).and_then(|(os, oc, ns, nc)| {
201                current_file.clone().map(|file_path| Body {
202                    hunk: Hunk {
203                        file_path,
204                        old_start: os,
205                        old_count: oc,
206                        new_start: ns,
207                        new_count: nc,
208                        lines: Vec::new(),
209                    },
210                    old_left: oc,
211                    new_left: nc,
212                })
213            });
214        }
215    }
216
217    hunks.extend(body.map(Body::finish));
218    hunks
219}
220
221/// A hunk whose body is still being read, with the lines each side of its
222/// `@@` header has left to account for.
223struct Body {
224    hunk: Hunk,
225    old_left: u32,
226    new_left: u32,
227}
228
229impl Body {
230    /// Take `line` into the body, or `false` when it cannot belong there.
231    fn accept(&mut self, line: &str) -> bool {
232        let (entry, old, new) = match line.as_bytes().first() {
233            Some(b' ') => (HunkLine::Context(line[1..].to_owned()), 1, 1),
234            None => (HunkLine::Context(String::new()), 1, 1),
235            Some(b'-') => (HunkLine::Removed(line[1..].to_owned()), 1, 0),
236            Some(b'+') => (HunkLine::Added(line[1..].to_owned()), 0, 1),
237            Some(b'\\') => return true,
238            Some(_) => return false,
239        };
240        let (Some(old_left), Some(new_left)) = (
241            self.old_left.checked_sub(old),
242            self.new_left.checked_sub(new),
243        ) else {
244            return false;
245        };
246        self.old_left = old_left;
247        self.new_left = new_left;
248        self.hunk.lines.push(entry);
249        true
250    }
251
252    fn finish(self) -> Hunk {
253        self.hunk
254    }
255}
256
257/// The file a `+++ ` header names, or `None` when it names no new file.
258///
259/// Git ends the header with a tab whenever the name holds a space, and an
260/// unquoted name never holds a tab (git quotes one), so a single trailing tab is
261/// always that marker. The `b/` prefix is removed after decoding because a
262/// quoted field carries it inside the quotes. `/dev/null`, a deletion, has no
263/// such prefix, and neither does a header written without one: the parser does
264/// not guess where a prefix it cannot see would have ended.
265fn new_file_path(target: &str) -> Option<PathBuf> {
266    let field = target.strip_suffix('\t').unwrap_or(target);
267    decode(field).strip_prefix("b").ok().map(Path::to_path_buf)
268}
269
270/// Parse the middle of a `@@` line: `-old[,oc] +new[,nc]`, followed by the
271/// closing `@@` and optionally git's guessed function signature.
272///
273/// The closing `@@` is required: `-1,3 +1,4` with nothing after it is not a
274/// hunk header, and accepting it would let junk pass as a start-of-hunk.
275/// `split_once` takes the *first* `@@`, so a function signature that itself
276/// contains `@@` cannot extend the range span.
277fn parse_hunk_header(after_marker: &str) -> Option<(u32, u32, u32, u32)> {
278    let (ranges, _signature) = after_marker.trim_start().split_once("@@")?;
279    let mut parts = ranges.split_whitespace();
280    let (old_start, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
281    let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
282    // A third range is not a hunk header; refusing it keeps the malformed-`@@`
283    // path reachable rather than silently accepting junk.
284    if parts.next().is_some() {
285        return None;
286    }
287    Some((old_start, old_count, new_start, new_count))
288}
289
290/// Parse `<start>[,<count>]`.
291///
292/// An omitted count is 1, matching git's convention for a single-line hunk
293/// (`@@ -5 +5 @@`). A count of 0 is legal and is what appears for new and
294/// emptied files.
295fn parse_range(s: &str) -> Option<(u32, u32)> {
296    match s.split_once(',') {
297        Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
298        None => Some((s.parse().ok()?, 1)),
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    //! Tests for items private to this module, which the sibling
305    //! `diff::tests::hunks` cannot reach. Everything exercised through the
306    //! public API lives there instead.
307
308    use super::*;
309
310    #[test]
311    fn hunk_header_with_full_counts() {
312        assert_eq!(parse_hunk_header(" -1,3 +1,4 @@"), Some((1, 3, 1, 4)));
313    }
314
315    #[test]
316    fn hunk_header_with_omitted_counts_defaults_to_one() {
317        assert_eq!(parse_hunk_header(" -5 +5 @@"), Some((5, 1, 5, 1)));
318    }
319
320    #[test]
321    fn hunk_header_with_zero_counts_is_legal() {
322        assert_eq!(parse_hunk_header(" -0,0 +1,3 @@"), Some((0, 0, 1, 3)));
323    }
324
325    #[test]
326    fn hunk_header_allows_a_trailing_function_signature() {
327        assert_eq!(
328            parse_hunk_header(" -1,3 +1,4 @@ fn compute()"),
329            Some((1, 3, 1, 4))
330        );
331    }
332
333    #[test]
334    fn hunk_header_signature_containing_at_at_does_not_extend_the_ranges() {
335        // `split_once` must take the first `@@`, not the last.
336        assert_eq!(
337            parse_hunk_header(" -1,3 +1,4 @@ fn f() { \"@@\" }"),
338            Some((1, 3, 1, 4))
339        );
340    }
341
342    #[test]
343    fn malformed_hunk_headers_are_rejected() {
344        assert!(parse_hunk_header("garbage").is_none());
345        // No closing `@@`.
346        assert!(parse_hunk_header(" -1,3 +1,4").is_none());
347        // Non-numeric start.
348        assert!(parse_hunk_header(" -a +1 @@").is_none());
349        // A third range.
350        assert!(parse_hunk_header(" -1,3 +1,4 +9,9 @@").is_none());
351        // A count that is not a number.
352        assert!(parse_hunk_header(" -1,3,5 +1,4 @@").is_none());
353    }
354
355    #[test]
356    fn range_without_a_comma_has_an_implicit_count_of_one() {
357        assert_eq!(parse_range("42"), Some((42, 1)));
358        assert_eq!(parse_range("42,7"), Some((42, 7)));
359        assert!(parse_range("").is_none());
360    }
361}