1use std::io::BufRead;
2
3use anyhow::{Context, Result, bail};
4
5pub fn visit_files(
20 mut input: impl BufRead,
21 mut visit: impl FnMut(u64, &[u8]) -> Result<()>,
22) -> Result<u64> {
23 let mut splitter = Splitter::default();
24 let mut line = Vec::new();
25 loop {
26 line.clear();
27 if input.read_until(b'\n', &mut line)? == 0 {
28 break;
29 }
30 splitter.line(&line, &mut visit)?;
31 }
32 splitter.finish(&mut visit)
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36enum State {
37 Outside { old_file_header: bool },
39 Header,
41 Hunk(u64, u64),
43 Binary { hunks: u8, data: bool },
45 Ended,
47}
48
49impl Default for State {
50 fn default() -> Self {
51 Self::Outside {
52 old_file_header: false,
53 }
54 }
55}
56
57#[derive(Default)]
58struct Splitter {
59 state: State,
60 unit: Vec<u8>,
61 unit_start: u64,
62 offset: u64,
63 units: usize,
64 text_outside: bool,
65}
66
67const NO_NEWLINE: &[u8] = b"\\ No newline at end of file";
68
69const HEADER_LINES: &[&[u8]] = &[
71 b"--- ",
72 b"+++ ",
73 b"old mode ",
74 b"new mode ",
75 b"deleted file mode ",
76 b"new file mode ",
77 b"copy from ",
78 b"copy to ",
79 b"rename old ",
80 b"rename new ",
81 b"rename from ",
82 b"rename to ",
83 b"similarity index ",
84 b"dissimilarity index ",
85 b"index ",
86 b"Binary files ",
87];
88
89impl Splitter {
90 fn line(
91 &mut self,
92 line: &[u8],
93 visit: &mut impl FnMut(u64, &[u8]) -> Result<()>,
94 ) -> Result<()> {
95 if line.starts_with(b"diff --cc ") || line.starts_with(b"diff --combined ") {
96 bail!("Combined merge diffs are not supported. Supply a two-sided patch.");
97 }
98 let file_header = line.starts_with(b"diff --git ");
99 let content = trim_eol(line);
100 loop {
101 match self.state {
102 State::Outside { old_file_header } => {
103 if file_header {
104 self.begin(line);
105 return Ok(());
106 }
107 if old_file_header && line.starts_with(b"+++ ") {
108 bail!(
109 "Cannot parse this patch: file headers without a `diff --git` line. Supply Git patch output."
110 );
111 }
112 if line.starts_with(b"@@ -") && hunk_counts(line).is_ok() {
113 bail!("Cannot parse this patch: hunk header outside a file patch");
114 }
115 self.text_outside |= !line.iter().all(u8::is_ascii_whitespace);
116 self.state = State::Outside {
117 old_file_header: line.starts_with(b"--- "),
118 };
119 self.offset += line.len() as u64;
120 return Ok(());
121 }
122 State::Header => {
123 if file_header {
124 self.end(visit)?;
125 self.begin(line);
126 return Ok(());
127 }
128 if line.starts_with(b"@@ ") {
129 let (old, new) = hunk_counts(line)?;
130 self.state = State::Hunk(old, new);
131 } else if content == b"GIT binary patch" {
132 self.state = State::Binary {
133 hunks: 0,
134 data: false,
135 };
136 } else if !HEADER_LINES.iter().any(|prefix| line.starts_with(prefix)) {
137 self.end(visit)?;
138 continue;
139 }
140 self.push(line);
141 return Ok(());
142 }
143 State::Hunk(0, 0) => {
144 if file_header {
145 self.end(visit)?;
146 self.begin(line);
147 return Ok(());
148 }
149 if line.starts_with(b"@@ ") {
150 let (old, new) = hunk_counts(line)?;
151 self.state = State::Hunk(old, new);
152 } else if content == NO_NEWLINE {
153 } else if content == b"-- " {
155 self.end(visit)?;
157 continue;
158 } else if matches!(line.first(), Some(b' ' | b'+' | b'-')) {
159 bail!("Unexpected source record or incorrect hunk counts");
160 } else {
161 self.end(visit)?;
162 continue;
163 }
164 self.push(line);
165 return Ok(());
166 }
167 State::Hunk(ref mut old, ref mut new) => {
168 if file_header || line.starts_with(b"@@ ") {
169 bail!("Hunk counts do not agree with its source lines");
170 }
171 if content != NO_NEWLINE {
172 match line.first() {
173 Some(b' ') if *old > 0 && *new > 0 => {
174 *old -= 1;
175 *new -= 1;
176 }
177 Some(b'-') if *old > 0 => *old -= 1,
178 Some(b'+') if *new > 0 => *new -= 1,
179 _ => bail!("Unexpected source record or incorrect hunk counts"),
180 }
181 }
182 self.push(line);
183 return Ok(());
184 }
185 State::Binary { hunks, data } => {
186 let blank = content.is_empty();
187 let hunk_header = line.starts_with(b"literal ") || line.starts_with(b"delta ");
188 if !data && hunks > 0 && !hunk_header {
189 self.state = State::Ended;
191 continue;
192 }
193 self.state = match (data, blank) {
194 (true, true) if hunks > 0 => State::Ended,
195 (true, true) => State::Binary {
196 hunks: hunks + 1,
197 data: false,
198 },
199 _ => State::Binary { hunks, data: true },
201 };
202 self.push(line);
203 return Ok(());
204 }
205 State::Ended => {
206 if file_header {
207 self.end(visit)?;
208 self.begin(line);
209 return Ok(());
210 }
211 self.end(visit)?;
212 }
213 }
214 }
215 }
216
217 fn begin(&mut self, line: &[u8]) {
218 self.unit_start = self.offset;
219 self.state = State::Header;
220 self.push(line);
221 }
222
223 fn push(&mut self, line: &[u8]) {
224 self.unit.extend_from_slice(line);
225 self.offset += line.len() as u64;
226 }
227
228 fn end(&mut self, visit: &mut impl FnMut(u64, &[u8]) -> Result<()>) -> Result<()> {
230 if let State::Hunk(old, new) = self.state
231 && (old, new) != (0, 0)
232 {
233 bail!("Hunk counts do not agree with its source lines");
234 }
235 self.state = State::default();
236 if !self.unit.is_empty() {
237 visit(self.unit_start, &self.unit)?;
238 self.unit.clear();
239 self.units += 1;
240 }
241 Ok(())
242 }
243
244 fn finish(mut self, visit: &mut impl FnMut(u64, &[u8]) -> Result<()>) -> Result<u64> {
245 self.end(visit)?;
246 if self.units == 0 && self.text_outside {
247 bail!(
248 "Cannot parse this patch: no `diff --git` file header found. Supply Git patch output."
249 );
250 }
251 Ok(self.offset)
252 }
253}
254
255fn trim_eol(line: &[u8]) -> &[u8] {
256 let line = line.strip_suffix(b"\n").unwrap_or(line);
257 line.strip_suffix(b"\r").unwrap_or(line)
258}
259
260fn hunk_counts(header: &[u8]) -> Result<(u64, u64)> {
261 let header = header.strip_suffix(b"\n").unwrap_or(header);
262 let mut parts = header.split(|b| *b == b' ');
263 anyhow::ensure!(
264 parts.next() == Some(b"@@".as_slice()),
265 "Invalid hunk header"
266 );
267 let count = |range: Option<&[u8]>, sign: u8| -> Result<u64> {
268 let range = range.context("Missing hunk range")?;
269 anyhow::ensure!(range.first() == Some(&sign), "Invalid hunk range");
270 let mut values = range[1..].split(|b| *b == b',');
271 let start = values.next().context("Missing hunk start")?;
272 std::str::from_utf8(start)?.parse::<u64>()?;
273 let count = match values.next() {
274 Some(count) => std::str::from_utf8(count)?.parse()?,
275 None => 1,
276 };
277 anyhow::ensure!(values.next().is_none(), "Invalid hunk range");
278 Ok(count)
279 };
280 let old = count(parts.next(), b'-')?;
281 let new = count(parts.next(), b'+')?;
282 anyhow::ensure!(
283 parts.next() == Some(b"@@".as_slice()),
284 "Invalid hunk header"
285 );
286 Ok((old, new))
287}
288
289#[cfg(test)]
290mod tests;