Skip to main content

gpui_component/diff/
conflict.rs

1//! Files outside a patch: a whole source shown for reference, and a working
2//! file containing Git conflict markers.
3use std::ops::Range;
4
5use gpui::SharedString;
6
7use super::{
8    DiffFile, DiffFileStatus, DiffParseError,
9    document::{FileSide, LinePair},
10};
11
12/// How the user resolved a conflict.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14#[non_exhaustive]
15pub enum DiffConflictResolution {
16    /// Keep the current change, between `<<<<<<<` and `=======`.
17    Current,
18    /// Keep the incoming change, between `=======` and `>>>>>>>`.
19    Incoming,
20    /// Keep the current change followed by the incoming one.
21    Both,
22}
23
24/// Which part of a conflict a row introduces.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub(crate) enum ConflictPart {
27    Current,
28    Base,
29    Incoming,
30}
31
32/// A parsed merge conflict in a working file.
33///
34/// Source coordinates are one-based, half-open ranges, allowing empty parts.
35/// Returned by [`DiffFile::conflicts`] in source order; its slice index is the
36/// index accepted by [`super::DiffState::resolve_conflict`]. Fields are private
37/// and only the parser constructs these snapshots.
38#[derive(Clone, Debug)]
39pub struct DiffConflict {
40    lines: Range<usize>,
41    current_lines: Range<usize>,
42    base_lines: Option<Range<usize>>,
43    incoming_lines: Range<usize>,
44    current: Range<usize>,
45    base: Option<Range<usize>>,
46    incoming: Range<usize>,
47    current_label: SharedString,
48    base_label: SharedString,
49    incoming_label: SharedString,
50}
51
52impl DiffConflict {
53    /// One-based working-file lines, including markers, with an exclusive end.
54    pub fn lines(&self) -> Range<usize> {
55        self.lines.clone()
56    }
57    /// Current-side source lines, with an exclusive end; may be empty.
58    pub fn current_lines(&self) -> Range<usize> {
59        self.current_lines.clone()
60    }
61    /// Base source lines for diff3, with an exclusive end; may be empty.
62    pub fn base_lines(&self) -> Option<Range<usize>> {
63        self.base_lines.clone()
64    }
65    /// Incoming-side source lines, with an exclusive end; may be empty.
66    pub fn incoming_lines(&self) -> Range<usize> {
67        self.incoming_lines.clone()
68    }
69    /// The label from the opening marker.
70    pub fn current_label(&self) -> &SharedString {
71        &self.current_label
72    }
73    /// The base marker label, or None for a two-way conflict.
74    pub fn base_label(&self) -> Option<&SharedString> {
75        self.base.as_ref().map(|_| &self.base_label)
76    }
77    /// The label from the closing marker.
78    pub fn incoming_label(&self) -> &SharedString {
79        &self.incoming_label
80    }
81    /// Every line of the region, in file order.
82    pub(crate) fn source_lines(&self) -> Range<usize> {
83        self.current.start..self.incoming.end
84    }
85    pub(crate) fn part(&self, part: ConflictPart) -> Option<Range<usize>> {
86        match part {
87            ConflictPart::Current => Some(self.current.clone()),
88            ConflictPart::Base => self.base.clone(),
89            ConflictPart::Incoming => Some(self.incoming.clone()),
90        }
91    }
92    pub(crate) fn label(&self, part: ConflictPart) -> &SharedString {
93        match part {
94            ConflictPart::Current => &self.current_label,
95            ConflictPart::Base => &self.base_label,
96            ConflictPart::Incoming => &self.incoming_label,
97        }
98    }
99    /// The part a line of the region belongs to.
100    pub(crate) fn part_of(&self, ix: usize) -> Option<ConflictPart> {
101        [
102            ConflictPart::Current,
103            ConflictPart::Base,
104            ConflictPart::Incoming,
105        ]
106        .into_iter()
107        .find(|part| self.part(*part).is_some_and(|range| range.contains(&ix)))
108    }
109    /// The lines a resolution keeps.
110    pub(crate) fn kept(&self, resolution: DiffConflictResolution) -> Vec<Range<usize>> {
111        match resolution {
112            DiffConflictResolution::Current => vec![self.current.clone()],
113            DiffConflictResolution::Incoming => vec![self.incoming.clone()],
114            DiffConflictResolution::Both => vec![self.current.clone(), self.incoming.clone()],
115        }
116    }
117}
118
119/// Splits `text` into lines and their byte ranges, keeping line endings.
120fn line_ranges(text: &str) -> Vec<Range<usize>> {
121    let mut offset = 0;
122    text.split_inclusive('\n')
123        .map(|line| {
124            let range = offset..offset + line.len();
125            offset = range.end;
126            range
127        })
128        .collect()
129}
130
131impl DiffFile {
132    /// A file shown in full, for reference, without changes. It appears as a
133    /// single column in every display mode.
134    pub fn unchanged(path: impl Into<SharedString>, text: &str) -> Self {
135        let path = path.into();
136        let ranges = line_ranges(text);
137        let pairs = (0..ranges.len())
138            .map(|ix| LinePair::new(None, Some(ix), false))
139            .collect();
140        let lines = ranges
141            .into_iter()
142            .enumerate()
143            .map(|(ix, range)| (ix + 1, range));
144        DiffFile::new(
145            DiffFileStatus::Unchanged,
146            FileSide::new(Some(path.clone()), String::new(), []),
147            FileSide::new(Some(path), text.to_owned(), lines),
148            pairs,
149            Vec::new(),
150            Vec::new(),
151            false,
152        )
153    }
154
155    /// Parses a working file containing Git conflict markers, including the
156    /// `|||||||` base section of `diff3` style. Lines keep their numbers in
157    /// the working file; marker lines are shown as conflict headings instead.
158    pub fn parse_conflicts(
159        path: impl Into<SharedString>,
160        text: &str,
161    ) -> Result<Self, DiffParseError> {
162        let path = path.into();
163        let mut source = String::new();
164        let mut lines = Vec::new();
165        let mut pairs = Vec::new();
166        let mut conflicts = Vec::new();
167        // The marker-delimited part being read, with the open region's ranges.
168        let mut open: Option<(ConflictPart, DiffConflict)> = None;
169        for (ix, range) in line_ranges(text).into_iter().enumerate() {
170            let line = &text[range.clone()];
171            let content = line.trim_end_matches(['\n', '\r']);
172            let marker = |prefix: &str| {
173                content
174                    .strip_prefix(prefix)
175                    .filter(|rest| rest.is_empty() || rest.starts_with(' '))
176                    .map(|rest| SharedString::from(rest.trim().to_owned()))
177            };
178            let next = lines.len();
179            let (opening, base, separator, closing) = (
180                marker("<<<<<<<"),
181                marker("|||||||"),
182                marker("======="),
183                marker(">>>>>>>"),
184            );
185            match open.as_mut() {
186                None => {
187                    if let Some(label) = opening {
188                        open = Some((
189                            ConflictPart::Current,
190                            DiffConflict {
191                                lines: ix + 1..ix + 1,
192                                current_lines: ix + 2..ix + 2,
193                                base_lines: None,
194                                incoming_lines: ix + 2..ix + 2,
195                                current: next..next,
196                                base: None,
197                                incoming: next..next,
198                                current_label: label,
199                                base_label: SharedString::default(),
200                                incoming_label: SharedString::default(),
201                            },
202                        ));
203                        continue;
204                    }
205                    if base.is_some() || closing.is_some() {
206                        return Err(DiffParseError::new(
207                            ix + 1,
208                            "conflict marker outside a conflict",
209                        ));
210                    }
211                }
212                Some((part, conflict)) => {
213                    if opening.is_some() {
214                        return Err(DiffParseError::new(ix + 1, "nested conflict marker"));
215                    }
216                    match (*part, base, separator, closing) {
217                        (ConflictPart::Current, Some(label), _, _) => {
218                            conflict.current.end = next;
219                            conflict.current_lines.end = ix + 1;
220                            conflict.base_lines = Some(ix + 2..ix + 2);
221                            conflict.base = Some(next..next);
222                            conflict.base_label = label;
223                            *part = ConflictPart::Base;
224                            continue;
225                        }
226                        (ConflictPart::Current | ConflictPart::Base, _, Some(_), _) => {
227                            if *part == ConflictPart::Current {
228                                conflict.current.end = next;
229                                conflict.current_lines.end = ix + 1;
230                            } else if let Some(base) = &mut conflict.base {
231                                base.end = next;
232                                conflict.base_lines.as_mut().unwrap().end = ix + 1;
233                            }
234                            conflict.incoming = next..next;
235                            conflict.incoming_lines = ix + 2..ix + 2;
236                            *part = ConflictPart::Incoming;
237                            continue;
238                        }
239                        (ConflictPart::Incoming, _, _, Some(label)) => {
240                            conflict.incoming.end = next;
241                            conflict.incoming_lines.end = ix + 1;
242                            conflict.lines.end = ix + 2;
243                            conflict.incoming_label = label;
244                            if let Some((_, conflict)) = open.take() {
245                                conflicts.push(conflict);
246                            }
247                            continue;
248                        }
249                        _ => {}
250                    }
251                }
252            }
253            let start = source.len();
254            source.push_str(line);
255            lines.push((ix + 1, start..source.len()));
256            pairs.push(LinePair::new(None, Some(next), open.is_some()));
257        }
258        if open.is_some() {
259            return Err(DiffParseError::new(
260                text.lines().count(),
261                "unterminated conflict",
262            ));
263        }
264        Ok(DiffFile::new(
265            DiffFileStatus::Conflicted,
266            FileSide::new(Some(path.clone()), String::new(), []),
267            FileSide::new(Some(path), source, lines),
268            pairs,
269            Vec::new(),
270            Vec::new(),
271            false,
272        )
273        .with_conflicts(conflicts))
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::diff::DiffSide;
281
282    const MERGE: &str = "fn retry() {\n<<<<<<< HEAD\n    3\n=======\n    5\n>>>>>>> feature\n}\n";
283
284    #[test]
285    fn unchanged_files_show_every_line_once() {
286        let file = DiffFile::unchanged("src/lib.rs", "one\r\ntwo\nlast");
287        assert_eq!(file.status(), DiffFileStatus::Unchanged);
288        assert!(!file.has_changes());
289        assert_eq!(file.lines_count(DiffSide::Modified), 3);
290        assert_eq!(file.lines_count(DiffSide::Original), 0);
291        assert_eq!(
292            file.text_for_lines(DiffSide::Modified, 1, 3),
293            "one\r\ntwo\nlast"
294        );
295        assert!(DiffFile::unchanged("empty", "").pairs().is_empty());
296    }
297
298    #[test]
299    fn conflicts_keep_working_file_line_numbers() {
300        let file = DiffFile::parse_conflicts("src/retry.rs", MERGE).unwrap();
301        assert_eq!(file.status(), DiffFileStatus::Conflicted);
302        let numbers = file
303            .lines(DiffSide::Modified)
304            .iter()
305            .map(|line| line.line_number())
306            .collect::<Vec<_>>();
307        assert_eq!(numbers, [1, 3, 5, 7]);
308        let conflict = &file.conflicts()[0];
309        assert_eq!(conflict.lines(), 2..7);
310        assert_eq!(conflict.current_lines(), 3..4);
311        assert_eq!(conflict.incoming_lines(), 5..6);
312        assert_eq!(conflict.base_lines(), None);
313        assert_eq!(conflict.base_label(), None);
314        assert_eq!(conflict.current_label().as_str(), "HEAD");
315        assert_eq!(conflict.incoming_label().as_str(), "feature");
316        assert_eq!(conflict.part(ConflictPart::Current), Some(1..2));
317        assert_eq!(conflict.part(ConflictPart::Incoming), Some(2..3));
318        assert_eq!(conflict.label(ConflictPart::Current).as_str(), "HEAD");
319        assert_eq!(conflict.label(ConflictPart::Incoming).as_str(), "feature");
320        assert_eq!(conflict.part_of(2), Some(ConflictPart::Incoming));
321        assert!(file.pairs()[1].is_changed() && !file.pairs()[0].is_changed());
322    }
323
324    #[test]
325    fn diff3_bases_and_malformed_markers() {
326        let file = DiffFile::parse_conflicts(
327            "a.txt",
328            "<<<<<<< ours\na\n||||||| base\nb\n=======\nc\n>>>>>>> theirs\n",
329        )
330        .unwrap();
331        let conflict = &file.conflicts()[0];
332        assert_eq!(conflict.lines(), 1..8);
333        assert_eq!(conflict.current_lines(), 2..3);
334        assert_eq!(conflict.base_lines(), Some(4..5));
335        assert_eq!(conflict.incoming_lines(), 6..7);
336        assert_eq!(conflict.base_label().unwrap().as_str(), "base");
337        assert_eq!(conflict.part(ConflictPart::Base), Some(1..2));
338        assert_eq!(conflict.label(ConflictPart::Base).as_str(), "base");
339        assert_eq!(conflict.kept(DiffConflictResolution::Both), [0..1, 2..3]);
340        for text in [
341            "<<<<<<< a\nx\n",
342            "<<<<<<< a\n<<<<<<< b\n",
343            ">>>>>>> stray\n",
344        ] {
345            let error = DiffFile::parse_conflicts("a.txt", text).err().unwrap();
346            assert!(error.line() > 0);
347        }
348        let empty = DiffFile::parse_conflicts(
349            "empty.txt",
350            "<<<<<<< ours\n||||||| base\n=======\n>>>>>>> theirs\n",
351        )
352        .unwrap();
353        let conflict = &empty.conflicts()[0];
354        assert_eq!(conflict.lines(), 1..5);
355        assert_eq!(conflict.current_lines(), 2..2);
356        assert_eq!(conflict.base_lines(), Some(3..3));
357        assert_eq!(conflict.incoming_lines(), 4..4);
358        // A separator outside a conflict is ordinary text.
359        assert!(DiffFile::parse_conflicts("a.md", "title\n=======\n").is_ok());
360    }
361}