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::path::PathBuf;
24
25/// One line inside a hunk, tagged by what the diff said about it.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum HunkLine {
28    /// Unchanged line, present in both old and new file.
29    Context(String),
30    /// Line present only in the new file.
31    Added(String),
32    /// Line present only in the old file. Has no new-file line number.
33    Removed(String),
34}
35
36impl HunkLine {
37    /// The gutter marker for this line kind, matching unified-diff notation.
38    ///
39    /// Lives on the type rather than in the renderer so that "which character
40    /// means removed" is answered once. The renderer pairs it with
41    /// [`Hunk::numbered_lines`], and the two together are the whole gutter.
42    pub const fn marker(&self) -> char {
43        match self {
44            HunkLine::Context(_) => ' ',
45            HunkLine::Added(_) => '+',
46            HunkLine::Removed(_) => '-',
47        }
48    }
49
50    /// The line's text, with the diff's leading marker already stripped.
51    pub fn content(&self) -> &str {
52        match self {
53            HunkLine::Context(s) | HunkLine::Added(s) | HunkLine::Removed(s) => s,
54        }
55    }
56}
57
58/// One `@@` hunk from a unified diff, with its file.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Hunk {
61    pub file_path: PathBuf,
62    pub old_start: u32,
63    pub old_count: u32,
64    pub new_start: u32,
65    pub new_count: u32,
66    pub lines: Vec<HunkLine>,
67}
68
69impl Hunk {
70    /// Every line in the hunk paired with its new-file line number, or `None`
71    /// for a `Removed` line.
72    ///
73    /// **This is the single implementation of the line-numbering rule**, and
74    /// everything that needs a line number goes through it. Numbering starts
75    /// at `new_start` and advances for each `Context` or `Added` line;
76    /// `Removed` lines do not advance it, because they do not exist in the new
77    /// file at all.
78    ///
79    /// That rule is what `Payload::valid_lines` rests on, and therefore what
80    /// decides whether an LLM finding gets attributed to the right code. It
81    /// previously existed twice — once here and once inline in the renderer —
82    /// which meant hardening one did nothing for the other.
83    pub fn numbered_lines(&self) -> impl Iterator<Item = (Option<u32>, &HunkLine)> {
84        let mut next = self.new_start;
85        self.lines.iter().map(move |line| match line {
86            HunkLine::Removed(_) => (None, line),
87            HunkLine::Context(_) | HunkLine::Added(_) => {
88                let number = next;
89                next = next.saturating_add(1);
90                (Some(number), line)
91            }
92        })
93    }
94
95    /// Just the lines that exist in the new file, with their line numbers.
96    ///
97    /// A projection of [`Self::numbered_lines`], not a second walk: callers
98    /// that only care about real file lines (checking a parse against the file
99    /// on disk, say) get them without restating how numbering works.
100    pub fn numbered_new_lines(&self) -> impl Iterator<Item = (u32, &str)> {
101        self.numbered_lines()
102            .filter_map(|(number, line)| number.map(|n| (n, line.content())))
103    }
104
105    /// Build a synthetic hunk covering an entire file's content, for
106    /// `drep check PATHS` where there is no diff to consult.
107    ///
108    /// Every line is `Context` and numbering starts at 1, so the renderer
109    /// walks it with the same `numbered_lines` iterator it uses for a real
110    /// hunk. It also selects the whole-file scope sentence, because no line is
111    /// added or removed — an inference that holds because **git never emits a
112    /// hunk with no changed line**: hunks exist only around changes. That
113    /// property is load-bearing and is stated here because it belongs to an
114    /// external tool rather than to this type.
115    pub fn whole_file(file_path: PathBuf, content: &str) -> Hunk {
116        let lines: Vec<HunkLine> = content
117            .lines()
118            .map(|line| HunkLine::Context(line.to_owned()))
119            .collect();
120        let new_count = lines.len() as u32;
121        Hunk {
122            file_path,
123            old_start: 0,
124            old_count: 0,
125            new_start: 1,
126            new_count,
127            lines,
128        }
129    }
130}
131
132/// Parse the output of `git diff --unified=N` into hunks.
133///
134/// Tolerant by design: anything it does not recognise is skipped rather than
135/// erroring, because a diff that cannot be parsed must not take the gate down.
136/// The intentional quirks:
137///
138/// - **The file path comes from `+++ b/…`, never from `diff --git a/… b/…`.**
139///   The git header carries two paths on one line with no unambiguous
140///   separator, so any "find `b/`" rule captures the wrong span for a
141///   repository path that itself contains `b/` (`src/b/mod.rs`). The Python
142///   `diff_parser.py` this replaces had exactly that bug.
143/// - `+++ /dev/null` marks a deletion; there is nothing to analyze, so the
144///   file's hunks are dropped.
145/// - **Inside a hunk body the first byte alone decides the line kind.** Lines
146///   starting `---` or `+++` are *not* additionally skipped: those headers
147///   appear only before the first `@@` of a file, and a removed source line
148///   whose own text begins with `--` arrives as `---…`. Skipping it silently
149///   drops real removed code — the second bug in the Python.
150/// - `\ No newline at end of file` refers to the preceding line and never
151///   becomes a `HunkLine`.
152/// - A malformed `@@` terminates the current hunk without its body being
153///   attributed to the previous one.
154pub fn parse_unified_diff(diff_text: &str) -> Vec<Hunk> {
155    if diff_text.trim().is_empty() {
156        return Vec::new();
157    }
158
159    let mut hunks: Vec<Hunk> = Vec::new();
160    // `None` means "no file to attribute a hunk to" — either we have not
161    // reached this file's `+++` line yet, or it was a deletion. A `@@` seen
162    // while it is `None` produces no hunk, which is what drops a deleted
163    // file's body without a separate flag to track.
164    let mut current_file: Option<PathBuf> = None;
165    let mut pending: Option<Hunk> = None;
166
167    for line in diff_text.lines() {
168        if line.starts_with("diff --git ") {
169            hunks.extend(pending.take());
170            current_file = None;
171            continue;
172        }
173
174        if let Some(path) = line.strip_prefix("+++ b/") {
175            current_file = Some(PathBuf::from(path));
176            continue;
177        }
178
179        if line.starts_with("+++ /dev/null") {
180            hunks.extend(pending.take());
181            current_file = None;
182            continue;
183        }
184
185        if let Some(after_marker) = line.strip_prefix("@@") {
186            // Terminates the body regardless of whether the header parses:
187            // that is what stops a malformed `@@`'s lines being appended to
188            // the hunk before it.
189            hunks.extend(pending.take());
190            pending = parse_hunk_header(after_marker).and_then(|(os, oc, ns, nc)| {
191                current_file.clone().map(|file_path| Hunk {
192                    file_path,
193                    old_start: os,
194                    old_count: oc,
195                    new_start: ns,
196                    new_count: nc,
197                    lines: Vec::new(),
198                })
199            });
200            continue;
201        }
202
203        let Some(hunk) = pending.as_mut() else {
204            continue;
205        };
206
207        match line.as_bytes().first() {
208            Some(b'+') => hunk.lines.push(HunkLine::Added(line[1..].to_owned())),
209            Some(b'-') => hunk.lines.push(HunkLine::Removed(line[1..].to_owned())),
210            Some(b' ') => hunk.lines.push(HunkLine::Context(line[1..].to_owned())),
211            // `\ No newline at end of file`, a blank line, or anything else a
212            // well-formed body cannot contain. Never an error.
213            _ => {}
214        }
215    }
216
217    hunks.extend(pending.take());
218    hunks
219}
220
221/// Parse the middle of a `@@` line: `-old[,oc] +new[,nc]`, followed by the
222/// closing `@@` and optionally git's guessed function signature.
223///
224/// The closing `@@` is required: `-1,3 +1,4` with nothing after it is not a
225/// hunk header, and accepting it would let junk pass as a start-of-hunk.
226/// `split_once` takes the *first* `@@`, so a function signature that itself
227/// contains `@@` cannot extend the range span.
228fn parse_hunk_header(after_marker: &str) -> Option<(u32, u32, u32, u32)> {
229    let (ranges, _signature) = after_marker.trim_start().split_once("@@")?;
230    let mut parts = ranges.split_whitespace();
231    let (old_start, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
232    let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
233    // A third range is not a hunk header; refusing it keeps the malformed-`@@`
234    // path reachable rather than silently accepting junk.
235    if parts.next().is_some() {
236        return None;
237    }
238    Some((old_start, old_count, new_start, new_count))
239}
240
241/// Parse `<start>[,<count>]`.
242///
243/// An omitted count is 1, matching git's convention for a single-line hunk
244/// (`@@ -5 +5 @@`). A count of 0 is legal and is what appears for new and
245/// emptied files.
246fn parse_range(s: &str) -> Option<(u32, u32)> {
247    match s.split_once(',') {
248        Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
249        None => Some((s.parse().ok()?, 1)),
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    //! Tests for items private to this module, which the sibling
256    //! `diff::tests::hunks` cannot reach. Everything exercised through the
257    //! public API lives there instead.
258
259    use super::*;
260
261    #[test]
262    fn hunk_header_with_full_counts() {
263        assert_eq!(parse_hunk_header(" -1,3 +1,4 @@"), Some((1, 3, 1, 4)));
264    }
265
266    #[test]
267    fn hunk_header_with_omitted_counts_defaults_to_one() {
268        assert_eq!(parse_hunk_header(" -5 +5 @@"), Some((5, 1, 5, 1)));
269    }
270
271    #[test]
272    fn hunk_header_with_zero_counts_is_legal() {
273        assert_eq!(parse_hunk_header(" -0,0 +1,3 @@"), Some((0, 0, 1, 3)));
274    }
275
276    #[test]
277    fn hunk_header_allows_a_trailing_function_signature() {
278        assert_eq!(
279            parse_hunk_header(" -1,3 +1,4 @@ fn compute()"),
280            Some((1, 3, 1, 4))
281        );
282    }
283
284    #[test]
285    fn hunk_header_signature_containing_at_at_does_not_extend_the_ranges() {
286        // `split_once` must take the first `@@`, not the last.
287        assert_eq!(
288            parse_hunk_header(" -1,3 +1,4 @@ fn f() { \"@@\" }"),
289            Some((1, 3, 1, 4))
290        );
291    }
292
293    #[test]
294    fn malformed_hunk_headers_are_rejected() {
295        assert!(parse_hunk_header("garbage").is_none());
296        // No closing `@@`.
297        assert!(parse_hunk_header(" -1,3 +1,4").is_none());
298        // Non-numeric start.
299        assert!(parse_hunk_header(" -a +1 @@").is_none());
300        // A third range.
301        assert!(parse_hunk_header(" -1,3 +1,4 +9,9 @@").is_none());
302        // A count that is not a number.
303        assert!(parse_hunk_header(" -1,3,5 +1,4 @@").is_none());
304    }
305
306    #[test]
307    fn range_without_a_comma_has_an_implicit_count_of_one() {
308        assert_eq!(parse_range("42"), Some((42, 1)));
309        assert_eq!(parse_range("42,7"), Some((42, 7)));
310        assert!(parse_range("").is_none());
311    }
312}