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/// Source prefixes Git writes on each side: the default `a/` and `b/`, the
38/// `diff.mnemonicPrefix` pairs, `--no-index` and `diff.noprefix`.
39const 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/// One file side while parsing: patch source is appended once, and lines are
50/// recorded as ranges until the file is complete.
51#[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    /// Prefixes detected from `diff --git`, stripped from `---` and `+++` paths.
83    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    // Git preserves CR in source lines but emits LF headers. CRLF transport
138    // instead has CR on its header records as well; strip that framing CR only.
139    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        // `git format-patch` ends each message with a `-- ` signature line,
183        // followed by the Git version. Mail clients may strip its trailing space.
184        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                // Without a Git header, strip `a/` and `b/` only when the
207                // present sides agree on that convention.
208                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        // Binary payload is opaque to the UI. Keep its marker, not thousands
232        // of encoded records or a fabricated text source.
233        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
284/// Parses the hunk whose header is `lines[ix]` and returns the next line index.
285fn 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        // Editors and mail transports may strip the space from an empty
335        // context line; `git apply` accepts that, so accept it here too.
336        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    // A zero-count side names the line before its insertion point.
400    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
448/// Decodes a `---` or `+++` path, or `None` for `/dev/null`.
449fn 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
458/// Splits the paths of a `diff --git` header and detects their prefixes.
459fn 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            // Git does not quote spaces. Without a rename both sides name the
487            // same path, which identifies the separating space unambiguously.
488            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            // Otherwise this is a rename or copy, and `rename from`/`rename to`
498            // records supply the exact paths.
499            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}