1use std::{fmt, ops::Range};
2
3use gpui::SharedString;
4
5use super::document::{DiffFile, DiffFileStatus, FileSide, Hunk, LinePair};
6
7#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct DiffParseError {
10 line: usize,
11 message: SharedString,
12}
13
14impl DiffParseError {
15 pub fn line(&self) -> usize {
16 self.line
17 }
18 pub fn message(&self) -> &SharedString {
19 &self.message
20 }
21 pub(crate) fn new(line: usize, message: &'static str) -> Self {
22 Self {
23 line,
24 message: SharedString::new_static(message),
25 }
26 }
27}
28impl fmt::Display for DiffParseError {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 write!(f, "patch line {}: {}", self.line, self.message)
31 }
32}
33impl std::error::Error for DiffParseError {}
34
35type Result<T> = std::result::Result<T, DiffParseError>;
36
37const GIT_PREFIXES: [(&str, &str); 7] = [
40 ("a/", "b/"),
41 ("i/", "w/"),
42 ("c/", "w/"),
43 ("c/", "i/"),
44 ("o/", "w/"),
45 ("1/", "2/"),
46 ("", ""),
47];
48
49#[derive(Default)]
52struct SideBuilder {
53 source: String,
54 lines: Vec<(usize, Range<usize>)>,
55 no_newline: bool,
56}
57
58impl SideBuilder {
59 fn push(&mut self, line_number: usize, content: &str) -> usize {
60 let start = self.source.len();
61 self.source.push_str(content);
62 self.source.push('\n');
63 self.lines.push((line_number, start..self.source.len()));
64 self.lines.len() - 1
65 }
66 fn remove_final_newline(&mut self) {
67 if let Some((_, range)) = self.lines.last_mut() {
68 self.source.pop();
69 range.end -= 1;
70 }
71 self.no_newline = true;
72 }
73 fn finish(self, path: Option<String>) -> FileSide {
74 FileSide::new(path.map(SharedString::from), self.source, self.lines)
75 }
76}
77
78#[derive(Default)]
79struct File {
80 old_path: Option<String>,
81 new_path: Option<String>,
82 git_prefixes: Option<(&'static str, &'static str)>,
84 original: SideBuilder,
85 modified: SideBuilder,
86 pairs: Vec<LinePair>,
87 hunks: Vec<Hunk>,
88 extended_headers: Vec<SharedString>,
89 binary: bool,
90 headers: bool,
91 renamed: bool,
92 copied: bool,
93}
94
95impl File {
96 fn finish(self) -> DiffFile {
97 let status = if self.old_path.is_none() {
98 DiffFileStatus::Added
99 } else if self.new_path.is_none() {
100 DiffFileStatus::Deleted
101 } else if self.copied {
102 DiffFileStatus::Copied
103 } else if self.renamed {
104 DiffFileStatus::Renamed
105 } else {
106 DiffFileStatus::Modified
107 };
108 DiffFile::new(
109 status,
110 self.original.finish(self.old_path),
111 self.modified.finish(self.new_path),
112 self.pairs,
113 self.hunks,
114 self.extended_headers,
115 self.binary,
116 )
117 }
118
119 fn flush_changes(&mut self, deleted: &mut Vec<usize>, added: &mut Vec<usize>) {
120 for ix in 0..deleted.len().max(added.len()) {
121 self.pairs.push(LinePair::new(
122 deleted.get(ix).copied(),
123 added.get(ix).copied(),
124 true,
125 ));
126 }
127 deleted.clear();
128 added.clear();
129 }
130}
131
132pub(crate) fn parse(patch: &str) -> Result<Vec<DiffFile>> {
133 let raw: Vec<&str> = patch
134 .split_inclusive('\n')
135 .map(|line| line.strip_suffix('\n').unwrap_or(line))
136 .collect();
137 let transport_crlf = raw
140 .iter()
141 .find(|line| line.starts_with("diff --git ") || line.starts_with("--- "))
142 .is_some_and(|line| line.ends_with('\r'));
143 let lines: Vec<&str> = raw
144 .iter()
145 .map(|line| {
146 if transport_crlf {
147 line.strip_suffix('\r').unwrap_or(line)
148 } else {
149 line
150 }
151 })
152 .collect();
153 let mut files = Vec::new();
154 let mut current: Option<File> = None;
155 let mut ix = 0;
156 while ix < lines.len() {
157 let line = lines[ix];
158 if line.starts_with("diff --cc ")
159 || line.starts_with("diff --combined ")
160 || line.starts_with("@@@")
161 {
162 return Err(DiffParseError::new(
163 ix + 1,
164 "combined diffs are not supported",
165 ));
166 }
167 if let Some(paths) = line.strip_prefix("diff --git ") {
168 if let Some(file) = current.take() {
169 files.push(file.finish());
170 }
171 let (old, new, prefixes) = git_paths(paths)
172 .ok_or_else(|| DiffParseError::new(ix + 1, "invalid Git file header"))?;
173 current = Some(File {
174 old_path: Some(old),
175 new_path: Some(new),
176 git_prefixes: Some(prefixes),
177 ..File::default()
178 });
179 ix += 1;
180 continue;
181 }
182 if line == "-- " || line == "--" {
185 if let Some(file) = current.take() {
186 files.push(file.finish());
187 }
188 ix += 1;
189 continue;
190 }
191 if let Some(path) = line.strip_prefix("--- ") {
192 if current.as_ref().is_some_and(|file| file.headers) {
193 files.push(current.take().unwrap().finish());
194 }
195 let file = current.get_or_insert_with(File::default);
196 let old = header_path(path, ix + 1)?;
197 ix += 1;
198 let Some(path) = lines.get(ix).and_then(|line| line.strip_prefix("+++ ")) else {
199 return Err(DiffParseError::new(ix + 1, "expected modified file header"));
200 };
201 let new = header_path(path, ix + 1)?;
202 if old.is_none() && new.is_none() {
203 return Err(DiffParseError::new(ix + 1, "both file sides are missing"));
204 }
205 let (old_prefix, new_prefix) = file.git_prefixes.unwrap_or_else(|| {
206 let conventional = old.as_deref().is_none_or(|path| path.starts_with("a/"))
209 && new.as_deref().is_none_or(|path| path.starts_with("b/"));
210 if conventional { ("a/", "b/") } else { ("", "") }
211 });
212 file.old_path = old.map(|path| strip_prefix(path, old_prefix));
213 file.new_path = new.map(|path| strip_prefix(path, new_prefix));
214 file.headers = true;
215 ix += 1;
216 continue;
217 }
218 if line.starts_with("@@ ") {
219 let file = current
220 .as_mut()
221 .ok_or_else(|| DiffParseError::new(ix + 1, "hunk has no file header"))?;
222 if !file.headers {
223 return Err(DiffParseError::new(
224 ix + 1,
225 "hunk requires original and modified file headers",
226 ));
227 }
228 ix = parse_hunk(file, &lines, ix)?;
229 continue;
230 }
231 if current.as_ref().is_some_and(|file| file.binary) {
234 ix += 1;
235 continue;
236 }
237 if (line.starts_with('+')
238 || line.starts_with('-')
239 || line.starts_with(' ')
240 || line.starts_with("\\ No newline"))
241 && current.as_ref().is_some_and(|file| file.headers)
242 {
243 return Err(DiffParseError::new(ix + 1, "source line outside a hunk"));
244 }
245 if let Some(file) = &mut current {
246 if line.starts_with("Binary files ") || line == "GIT binary patch" {
247 file.binary = true;
248 }
249 if let Some(path) = line.strip_prefix("rename from ") {
250 file.old_path = Some(decode_path(path, ix + 1)?);
251 file.renamed = true;
252 }
253 if let Some(path) = line.strip_prefix("copy from ") {
254 file.old_path = Some(decode_path(path, ix + 1)?);
255 file.copied = true;
256 }
257 if let Some(path) = line
258 .strip_prefix("rename to ")
259 .or_else(|| line.strip_prefix("copy to "))
260 {
261 file.new_path = Some(decode_path(path, ix + 1)?);
262 }
263 if line.starts_with("new file mode ") {
264 file.old_path = None;
265 }
266 if line.starts_with("deleted file mode ") {
267 file.new_path = None;
268 }
269 if !line.is_empty() {
270 file.extended_headers.push(line.to_owned().into());
271 }
272 }
273 ix += 1;
274 }
275 if let Some(file) = current {
276 files.push(file.finish());
277 }
278 if files.is_empty() && !patch.trim().is_empty() {
279 return Err(DiffParseError::new(1, "expected a unified or Git diff"));
280 }
281 Ok(files)
282}
283
284fn parse_hunk(file: &mut File, lines: &[&str], mut ix: usize) -> Result<usize> {
286 let header = lines[ix];
287 let (old_start, old_count, new_start, new_count) = hunk_header(header, ix + 1)?;
288 let last_line = |side: &SideBuilder| side.lines.last().map(|(number, _)| *number);
289 if last_line(&file.original).is_some_and(|number| number >= old_start) && old_count > 0
290 || last_line(&file.modified).is_some_and(|number| number >= new_start) && new_count > 0
291 {
292 return Err(DiffParseError::new(
293 ix + 1,
294 "overlapping or unordered hunks",
295 ));
296 }
297 if file.old_path.is_none() && old_count != 0 || file.new_path.is_none() && new_count != 0 {
298 return Err(DiffParseError::new(
299 ix + 1,
300 "missing file side has source lines",
301 ));
302 }
303 let pairs_start = file.pairs.len();
304 let original_start = file.original.lines.len();
305 let modified_start = file.modified.lines.len();
306 ix += 1;
307 let (mut old_used, mut new_used) = (0, 0);
308 let (mut deleted, mut added) = (Vec::new(), Vec::new());
309 let mut last = None;
310 while ix < lines.len() {
311 let content = lines[ix];
312 if content == "\\ No newline at end of file" {
313 match last {
314 Some(b'-') => file.original.remove_final_newline(),
315 Some(b'+') => file.modified.remove_final_newline(),
316 Some(b' ') => {
317 file.original.remove_final_newline();
318 file.modified.remove_final_newline();
319 }
320 _ => {
321 return Err(DiffParseError::new(
322 ix + 1,
323 "newline marker has no preceding source line",
324 ));
325 }
326 }
327 last = None;
328 ix += 1;
329 continue;
330 }
331 if old_used == old_count && new_used == new_count {
332 break;
333 }
334 let (tag, text) = match content.as_bytes().first() {
337 None => (b' ', ""),
338 Some(&tag) => (tag, &content[1..]),
339 };
340 if !matches!(tag, b' ' | b'+' | b'-') {
341 return Err(DiffParseError::new(
342 ix + 1,
343 "hunk ended before its declared line counts",
344 ));
345 }
346 if tag != b'+' && file.original.no_newline || tag != b'-' && file.modified.no_newline {
347 return Err(DiffParseError::new(
348 ix + 1,
349 "source continues after an unterminated final line",
350 ));
351 }
352 match tag {
353 b' ' => {
354 file.flush_changes(&mut deleted, &mut added);
355 if old_used >= old_count || new_used >= new_count {
356 return Err(DiffParseError::new(
357 ix + 1,
358 "hunk exceeds declared line counts",
359 ));
360 }
361 let original = file.original.push(old_start + old_used, text);
362 let modified = file.modified.push(new_start + new_used, text);
363 file.pairs
364 .push(LinePair::new(Some(original), Some(modified), false));
365 old_used += 1;
366 new_used += 1;
367 }
368 b'-' => {
369 if old_used >= old_count {
370 return Err(DiffParseError::new(
371 ix + 1,
372 "hunk exceeds original line count",
373 ));
374 }
375 deleted.push(file.original.push(old_start + old_used, text));
376 old_used += 1;
377 }
378 _ => {
379 if new_used >= new_count {
380 return Err(DiffParseError::new(
381 ix + 1,
382 "hunk exceeds modified line count",
383 ));
384 }
385 added.push(file.modified.push(new_start + new_used, text));
386 new_used += 1;
387 }
388 }
389 last = Some(tag);
390 ix += 1;
391 }
392 if old_used != old_count || new_used != new_count {
393 return Err(DiffParseError::new(
394 ix + 1,
395 "hunk ended before its declared line counts",
396 ));
397 }
398 file.flush_changes(&mut deleted, &mut added);
399 let first_original = old_start + usize::from(old_count == 0);
401 file.hunks.push(Hunk::new(
402 pairs_start..file.pairs.len(),
403 original_start..file.original.lines.len(),
404 modified_start..file.modified.lines.len(),
405 header.to_owned().into(),
406 first_original..first_original + old_count,
407 ));
408 Ok(ix)
409}
410
411fn hunk_header(line: &str, number: usize) -> Result<(usize, usize, usize, usize)> {
412 let invalid = || DiffParseError::new(number, "invalid hunk header");
413 let body = line.strip_prefix("@@ ").ok_or_else(invalid)?;
414 let (ranges, _) = body.split_once(" @@").ok_or_else(invalid)?;
415 let mut fields = ranges.split_whitespace();
416 let old = fields
417 .next()
418 .and_then(|value| value.strip_prefix('-'))
419 .ok_or_else(invalid)?;
420 let new = fields
421 .next()
422 .and_then(|value| value.strip_prefix('+'))
423 .ok_or_else(invalid)?;
424 if fields.next().is_some() {
425 return Err(invalid());
426 }
427 fn range(value: &str) -> Option<(usize, usize)> {
428 let (start, count) = value.split_once(',').unwrap_or((value, "1"));
429 let start = start.parse::<usize>().ok()?;
430 let count = count.parse::<usize>().ok()?;
431 if count > 0 && start == 0 || start.checked_add(count).is_none() {
432 return None;
433 }
434 Some((start, count))
435 }
436 let (old_start, old_count) = range(old).ok_or_else(invalid)?;
437 let (new_start, new_count) = range(new).ok_or_else(invalid)?;
438 Ok((old_start, old_count, new_start, new_count))
439}
440
441fn strip_prefix(path: String, prefix: &str) -> String {
442 match path.strip_prefix(prefix) {
443 Some(stripped) if !prefix.is_empty() => stripped.to_owned(),
444 _ => path,
445 }
446}
447
448fn header_path(value: &str, number: usize) -> Result<Option<String>> {
450 let value = value.split('\t').next().unwrap_or(value);
451 if value == "/dev/null" {
452 Ok(None)
453 } else {
454 decode_path(value, number).map(Some)
455 }
456}
457
458fn git_paths(value: &str) -> Option<(String, String, (&'static str, &'static str))> {
460 let detect = |old: &str, new: &str| {
461 GIT_PREFIXES
462 .iter()
463 .copied()
464 .find(|(old_prefix, new_prefix)| {
465 old.starts_with(old_prefix) && new.starts_with(new_prefix)
466 })
467 .unwrap_or(("", ""))
468 };
469 let (old, new, prefixes) =
470 if value.starts_with('"') {
471 let end = quoted_end(value)?;
472 let old = decode_path(&value[..end], 1).ok()?;
473 let new = decode_path(value[end..].trim_start(), 1).ok()?;
474 let prefixes = detect(&old, &new);
475 (old, new, prefixes)
476 } else if value.ends_with('"') {
477 let start = value
478 .match_indices(" \"")
479 .map(|(ix, _)| ix + 1)
480 .find(|ix| quoted_end(&value[*ix..]) == Some(value.len() - ix))?;
481 let old = value[..start - 1].to_owned();
482 let new = decode_path(&value[start..], 1).ok()?;
483 let prefixes = detect(&old, &new);
484 (old, new, prefixes)
485 } else {
486 let same =
489 GIT_PREFIXES.iter().find_map(|&(old_prefix, new_prefix)| {
490 value.match_indices(' ').find_map(|(ix, _)| {
491 let (old, new) = (&value[..ix], &value[ix + 1..]);
492 let path = old.strip_prefix(old_prefix)?;
493 (!path.is_empty() && new.strip_prefix(new_prefix) == Some(path))
494 .then_some((old, new, (old_prefix, new_prefix)))
495 })
496 });
497 let (old, new, prefixes) = same
500 .or_else(|| {
501 GIT_PREFIXES
502 .iter()
503 .filter(|(old_prefix, _)| !old_prefix.is_empty())
504 .find_map(|&(old_prefix, new_prefix)| {
505 let ix = value.rfind(&format!(" {new_prefix}"))?;
506 value.starts_with(old_prefix).then_some((
507 &value[..ix],
508 &value[ix + 1..],
509 (old_prefix, new_prefix),
510 ))
511 })
512 })
513 .or_else(|| {
514 let (old, new) = value.split_once(' ')?;
515 Some((old, new, ("", "")))
516 })?;
517 (old.to_owned(), new.to_owned(), prefixes)
518 };
519 Some((
520 strip_prefix(old, prefixes.0),
521 strip_prefix(new, prefixes.1),
522 prefixes,
523 ))
524}
525
526fn quoted_end(value: &str) -> Option<usize> {
527 let mut escaped = false;
528 for (ix, character) in value.char_indices().skip(1) {
529 if escaped {
530 escaped = false;
531 continue;
532 }
533 if character == '\\' {
534 escaped = true;
535 } else if character == '"' {
536 return Some(ix + 1);
537 }
538 }
539 None
540}
541
542fn decode_path(value: &str, number: usize) -> Result<String> {
543 if !value.starts_with('"') {
544 return Ok(value.to_owned());
545 }
546 if quoted_end(value) != Some(value.len()) {
547 return Err(DiffParseError::new(number, "invalid quoted file path"));
548 }
549 let mut output = Vec::new();
550 let bytes = &value.as_bytes()[1..value.len() - 1];
551 let mut ix = 0;
552 while ix < bytes.len() {
553 if bytes[ix] != b'\\' {
554 output.push(bytes[ix]);
555 ix += 1;
556 continue;
557 }
558 ix += 1;
559 let Some(&escaped) = bytes.get(ix) else {
560 return Err(DiffParseError::new(number, "invalid path escape"));
561 };
562 match escaped {
563 b'0'..=b'7' => {
564 let mut byte = 0u16;
565 let mut count = 0;
566 while count < 3
567 && bytes
568 .get(ix)
569 .is_some_and(|byte| (b'0'..=b'7').contains(byte))
570 {
571 byte = byte * 8 + u16::from(bytes[ix] - b'0');
572 ix += 1;
573 count += 1;
574 }
575 if byte > 255 {
576 return Err(DiffParseError::new(number, "invalid octal path escape"));
577 }
578 output.push(byte as u8);
579 }
580 b'n' => {
581 output.push(b'\n');
582 ix += 1;
583 }
584 b't' => {
585 output.push(b'\t');
586 ix += 1;
587 }
588 b'r' => {
589 output.push(b'\r');
590 ix += 1;
591 }
592 b'a' | b'b' | b'f' | b'v' => {
593 output.push(match escaped {
594 b'a' => 7,
595 b'b' => 8,
596 b'f' => 12,
597 _ => 11,
598 });
599 ix += 1;
600 }
601 b'"' | b'\\' => {
602 output.push(escaped);
603 ix += 1;
604 }
605 _ => return Err(DiffParseError::new(number, "unsupported path escape")),
606 }
607 }
608 String::from_utf8(output)
609 .map_err(|_| DiffParseError::new(number, "file path is not valid UTF-8"))
610}