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