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