Skip to main content

gpui_component/diff/
parser.rs

1use std::{fmt, ops::Range};
2
3use gpui::SharedString;
4
5use super::document::{DiffFile, DiffFileStatus, FileSide, Hunk, LinePair};
6
7/// A malformed or unsupported unified diff, with its one-based patch line.
8#[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/// Relaxations for [`DiffFile::parse_with`]. Every relaxation is off by
38/// default, which parses exactly as [`DiffFile::parse`] does.
39#[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    /// Accept hunks that end before their declared line counts, as in a patch
50    /// cut at a size limit, and count each from the lines present. Declared
51    /// counts still bound a hunk, so a line beyond them remains an error.
52    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
62/// Source prefixes Git writes on each side: the default `a/` and `b/`, the
63/// `diff.mnemonicPrefix` pairs, `--no-index` and `diff.noprefix`.
64const 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/// One file side while parsing: patch source is appended once, and lines are
75/// recorded as ranges until the file is complete.
76#[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    /// Prefixes detected from `diff --git`, stripped from `---` and `+++` paths.
108    git_prefixes: Option<(&'static str, &'static str)>,
109    original: SideBuilder,
110    modified: SideBuilder,
111    pairs: Vec<LinePair>,
112    hunks: Vec<Hunk>,
113    /// Empty truncated hunks still constrain the next hunk's position.
114    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    // Git preserves CR in source lines but emits LF headers. CRLF transport
165    // instead has CR on its header records as well; strip that framing CR only.
166    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        // `git format-patch` ends each message with a `-- ` signature line,
210        // followed by the Git version. Mail clients may strip its trailing space.
211        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                // Without a Git header, strip `a/` and `b/` only when the
234                // present sides agree on that convention.
235                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        // Binary payload is opaque to the UI. Keep its marker, not thousands
259        // of encoded records or a fabricated text source.
260        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
311/// Parses the hunk whose header is `lines[ix]` and returns the next line index.
312fn 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    // A zero-count side names the line before its insertion point.
321    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        // Editors and mail transports may strip the space from an empty
382        // context line; `git apply` accepts that, so accept it here too.
383        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
460/// Finds a unified file header inside a short hunk. A header-looking pair
461/// can also be source text, so a complete hunk interpretation wins. Scan
462/// once before parsing rather than looking ahead again at every such pair.
463fn 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
540/// Decodes a `---` or `+++` path, or `None` for `/dev/null`.
541fn 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
550/// Splits the paths of a `diff --git` header and detects their prefixes.
551fn 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            // Git does not quote spaces. Without a rename both sides name the
579            // same path, which identifies the separating space unambiguously.
580            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            // Otherwise this is a rename or copy, and `rename from`/`rename to`
590            // records supply the exact paths.
591            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}