udiff.rs

  1use std::borrow::Cow;
  2use std::fmt::Display;
  3use std::sync::Arc;
  4use std::{
  5    fmt::{Debug, Write},
  6    mem,
  7    ops::Range,
  8    path::Path,
  9};
 10
 11use anyhow::Context as _;
 12use anyhow::Result;
 13use anyhow::anyhow;
 14use collections::HashMap;
 15use gpui::AsyncApp;
 16use gpui::Entity;
 17use language::{Anchor, Buffer, OffsetRangeExt as _, TextBufferSnapshot};
 18use project::{Project, ProjectPath};
 19use util::paths::PathStyle;
 20use util::rel_path::RelPath;
 21
 22#[derive(Clone, Debug)]
 23pub struct OpenedBuffers(#[allow(unused)] HashMap<String, Entity<Buffer>>);
 24
 25#[must_use]
 26pub async fn apply_diff(
 27    diff_str: &str,
 28    project: &Entity<Project>,
 29    cx: &mut AsyncApp,
 30) -> Result<OpenedBuffers> {
 31    let mut included_files = HashMap::default();
 32
 33    let worktree_id = project.read_with(cx, |project, cx| {
 34        anyhow::Ok(
 35            project
 36                .visible_worktrees(cx)
 37                .next()
 38                .context("no worktrees")?
 39                .read(cx)
 40                .id(),
 41        )
 42    })??;
 43
 44    for line in diff_str.lines() {
 45        let diff_line = DiffLine::parse(line);
 46
 47        if let DiffLine::OldPath { path } = diff_line {
 48            let buffer = project
 49                .update(cx, |project, cx| {
 50                    let project_path = ProjectPath {
 51                        worktree_id,
 52                        path: RelPath::new(Path::new(path.as_ref()), PathStyle::Posix)?.into_arc(),
 53                    };
 54                    anyhow::Ok(project.open_buffer(project_path, cx))
 55                })??
 56                .await?;
 57
 58            included_files.insert(path.to_string(), buffer);
 59        }
 60    }
 61
 62    let ranges = [Anchor::MIN..Anchor::MAX];
 63
 64    let mut diff = DiffParser::new(diff_str);
 65    let mut current_file = None;
 66    let mut edits = vec![];
 67
 68    while let Some(event) = diff.next()? {
 69        match event {
 70            DiffEvent::Hunk {
 71                path: file_path,
 72                hunk,
 73            } => {
 74                let (buffer, ranges) = match current_file {
 75                    None => {
 76                        let buffer = included_files
 77                            .get_mut(file_path.as_ref())
 78                            .expect("Opened all files in diff");
 79
 80                        current_file = Some((buffer, ranges.as_slice()));
 81                        current_file.as_ref().unwrap()
 82                    }
 83                    Some(ref current) => current,
 84                };
 85
 86                buffer.read_with(cx, |buffer, _| {
 87                    edits.extend(
 88                        resolve_hunk_edits_in_buffer(hunk, buffer, ranges)
 89                            .with_context(|| format!("Diff:\n{diff_str}"))?,
 90                    );
 91                    anyhow::Ok(())
 92                })??;
 93            }
 94            DiffEvent::FileEnd { renamed_to } => {
 95                let (buffer, _) = current_file
 96                    .take()
 97                    .context("Got a FileEnd event before an Hunk event")?;
 98
 99                if let Some(renamed_to) = renamed_to {
100                    project
101                        .update(cx, |project, cx| {
102                            let new_project_path = project
103                                .find_project_path(Path::new(renamed_to.as_ref()), cx)
104                                .with_context(|| {
105                                    format!("Failed to find worktree for new path: {}", renamed_to)
106                                })?;
107
108                            let project_file = project::File::from_dyn(buffer.read(cx).file())
109                                .expect("Wrong file type");
110
111                            anyhow::Ok(project.rename_entry(
112                                project_file.entry_id.unwrap(),
113                                new_project_path,
114                                cx,
115                            ))
116                        })??
117                        .await?;
118                }
119
120                let edits = mem::take(&mut edits);
121                buffer.update(cx, |buffer, cx| {
122                    buffer.edit(edits, None, cx);
123                })?;
124            }
125        }
126    }
127
128    Ok(OpenedBuffers(included_files))
129}
130
131pub fn apply_diff_to_string(diff_str: &str, text: &str) -> Result<String> {
132    let mut diff = DiffParser::new(diff_str);
133
134    let mut text = text.to_string();
135
136    while let Some(event) = diff.next()? {
137        match event {
138            DiffEvent::Hunk { hunk, .. } => {
139                let hunk_offset = text
140                    .find(&hunk.context)
141                    .ok_or_else(|| anyhow!("couldn't resolve hunk {:?}", hunk.context))?;
142                for edit in hunk.edits.iter().rev() {
143                    let range = (hunk_offset + edit.range.start)..(hunk_offset + edit.range.end);
144                    text.replace_range(range, &edit.text);
145                }
146            }
147            DiffEvent::FileEnd { .. } => {}
148        }
149    }
150
151    Ok(text)
152}
153
154struct PatchFile<'a> {
155    old_path: Cow<'a, str>,
156    new_path: Cow<'a, str>,
157}
158
159struct DiffParser<'a> {
160    current_file: Option<PatchFile<'a>>,
161    current_line: Option<(&'a str, DiffLine<'a>)>,
162    hunk: Hunk,
163    diff: std::str::Lines<'a>,
164}
165
166#[derive(Debug, PartialEq)]
167enum DiffEvent<'a> {
168    Hunk { path: Cow<'a, str>, hunk: Hunk },
169    FileEnd { renamed_to: Option<Cow<'a, str>> },
170}
171
172#[derive(Debug, Default, PartialEq)]
173struct Hunk {
174    context: String,
175    edits: Vec<Edit>,
176}
177
178impl Hunk {
179    fn is_empty(&self) -> bool {
180        self.context.is_empty() && self.edits.is_empty()
181    }
182}
183
184#[derive(Debug, PartialEq)]
185struct Edit {
186    range: Range<usize>,
187    text: String,
188}
189
190impl<'a> DiffParser<'a> {
191    fn new(diff: &'a str) -> Self {
192        let mut diff = diff.lines();
193        let current_line = diff.next().map(|line| (line, DiffLine::parse(line)));
194        DiffParser {
195            current_file: None,
196            hunk: Hunk::default(),
197            current_line,
198            diff,
199        }
200    }
201
202    fn next(&mut self) -> Result<Option<DiffEvent<'a>>> {
203        loop {
204            let (hunk_done, file_done) = match self.current_line.as_ref().map(|e| &e.1) {
205                Some(DiffLine::OldPath { .. }) | Some(DiffLine::Garbage(_)) | None => (true, true),
206                Some(DiffLine::HunkHeader(_)) => (true, false),
207                _ => (false, false),
208            };
209
210            if hunk_done {
211                if let Some(file) = &self.current_file
212                    && !self.hunk.is_empty()
213                {
214                    return Ok(Some(DiffEvent::Hunk {
215                        path: file.old_path.clone(),
216                        hunk: mem::take(&mut self.hunk),
217                    }));
218                }
219            }
220
221            if file_done {
222                if let Some(PatchFile { old_path, new_path }) = self.current_file.take() {
223                    return Ok(Some(DiffEvent::FileEnd {
224                        renamed_to: if old_path != new_path {
225                            Some(new_path)
226                        } else {
227                            None
228                        },
229                    }));
230                }
231            }
232
233            let Some((line, parsed_line)) = self.current_line.take() else {
234                break;
235            };
236
237            util::maybe!({
238                match parsed_line {
239                    DiffLine::OldPath { path } => {
240                        self.current_file = Some(PatchFile {
241                            old_path: path,
242                            new_path: "".into(),
243                        });
244                    }
245                    DiffLine::NewPath { path } => {
246                        if let Some(current_file) = &mut self.current_file {
247                            current_file.new_path = path
248                        }
249                    }
250                    DiffLine::HunkHeader(_) => {}
251                    DiffLine::Context(ctx) => {
252                        if self.current_file.is_some() {
253                            writeln!(&mut self.hunk.context, "{ctx}")?;
254                        }
255                    }
256                    DiffLine::Deletion(del) => {
257                        if self.current_file.is_some() {
258                            let range = self.hunk.context.len()
259                                ..self.hunk.context.len() + del.len() + '\n'.len_utf8();
260                            if let Some(last_edit) = self.hunk.edits.last_mut()
261                                && last_edit.range.end == range.start
262                            {
263                                last_edit.range.end = range.end;
264                            } else {
265                                self.hunk.edits.push(Edit {
266                                    range,
267                                    text: String::new(),
268                                });
269                            }
270                            writeln!(&mut self.hunk.context, "{del}")?;
271                        }
272                    }
273                    DiffLine::Addition(add) => {
274                        if self.current_file.is_some() {
275                            let range = self.hunk.context.len()..self.hunk.context.len();
276                            if let Some(last_edit) = self.hunk.edits.last_mut()
277                                && last_edit.range.end == range.start
278                            {
279                                writeln!(&mut last_edit.text, "{add}").unwrap();
280                            } else {
281                                self.hunk.edits.push(Edit {
282                                    range,
283                                    text: format!("{add}\n"),
284                                });
285                            }
286                        }
287                    }
288                    DiffLine::Garbage(_) => {}
289                }
290
291                anyhow::Ok(())
292            })
293            .with_context(|| format!("on line:\n\n```\n{}```", line))?;
294
295            self.current_line = self.diff.next().map(|line| (line, DiffLine::parse(line)));
296        }
297
298        anyhow::Ok(None)
299    }
300}
301
302fn resolve_hunk_edits_in_buffer(
303    hunk: Hunk,
304    buffer: &TextBufferSnapshot,
305    ranges: &[Range<Anchor>],
306) -> Result<impl Iterator<Item = (Range<Anchor>, Arc<str>)>, anyhow::Error> {
307    let context_offset = if hunk.context.is_empty() {
308        Ok(0)
309    } else {
310        let mut offset = None;
311        for range in ranges {
312            let range = range.to_offset(buffer);
313            let text = buffer.text_for_range(range.clone()).collect::<String>();
314            for (ix, _) in text.match_indices(&hunk.context) {
315                if offset.is_some() {
316                    anyhow::bail!("Context is not unique enough:\n{}", hunk.context);
317                }
318                offset = Some(range.start + ix);
319            }
320        }
321        offset.ok_or_else(|| anyhow!("Failed to match context:\n{}", hunk.context))
322    }?;
323    let iter = hunk.edits.into_iter().flat_map(move |edit| {
324        let old_text = buffer
325            .text_for_range(context_offset + edit.range.start..context_offset + edit.range.end)
326            .collect::<String>();
327        let edits_within_hunk = language::text_diff(&old_text, &edit.text);
328        edits_within_hunk
329            .into_iter()
330            .map(move |(inner_range, inner_text)| {
331                (
332                    buffer.anchor_after(context_offset + edit.range.start + inner_range.start)
333                        ..buffer.anchor_before(context_offset + edit.range.start + inner_range.end),
334                    inner_text,
335                )
336            })
337    });
338    Ok(iter)
339}
340
341#[derive(Debug, PartialEq)]
342pub enum DiffLine<'a> {
343    OldPath { path: Cow<'a, str> },
344    NewPath { path: Cow<'a, str> },
345    HunkHeader(Option<HunkLocation>),
346    Context(&'a str),
347    Deletion(&'a str),
348    Addition(&'a str),
349    Garbage(&'a str),
350}
351
352#[derive(Debug, PartialEq)]
353pub struct HunkLocation {
354    start_line_old: u32,
355    count_old: u32,
356    start_line_new: u32,
357    count_new: u32,
358}
359
360impl<'a> DiffLine<'a> {
361    pub fn parse(line: &'a str) -> Self {
362        Self::try_parse(line).unwrap_or(Self::Garbage(line))
363    }
364
365    fn try_parse(line: &'a str) -> Option<Self> {
366        if let Some(header) = line.strip_prefix("---").and_then(eat_required_whitespace) {
367            let path = parse_header_path("a/", header);
368            Some(Self::OldPath { path })
369        } else if let Some(header) = line.strip_prefix("+++").and_then(eat_required_whitespace) {
370            Some(Self::NewPath {
371                path: parse_header_path("b/", header),
372            })
373        } else if let Some(header) = line.strip_prefix("@@").and_then(eat_required_whitespace) {
374            if header.starts_with("...") {
375                return Some(Self::HunkHeader(None));
376            }
377
378            let mut tokens = header.split_whitespace();
379            let old_range = tokens.next()?.strip_prefix('-')?;
380            let new_range = tokens.next()?.strip_prefix('+')?;
381
382            let (start_line_old, count_old) = old_range.split_once(',').unwrap_or((old_range, "1"));
383            let (start_line_new, count_new) = new_range.split_once(',').unwrap_or((new_range, "1"));
384
385            Some(Self::HunkHeader(Some(HunkLocation {
386                start_line_old: start_line_old.parse::<u32>().ok()?.saturating_sub(1),
387                count_old: count_old.parse().ok()?,
388                start_line_new: start_line_new.parse::<u32>().ok()?.saturating_sub(1),
389                count_new: count_new.parse().ok()?,
390            })))
391        } else if let Some(deleted_header) = line.strip_prefix("-") {
392            Some(Self::Deletion(deleted_header))
393        } else if line.is_empty() {
394            Some(Self::Context(""))
395        } else if let Some(context) = line.strip_prefix(" ") {
396            Some(Self::Context(context))
397        } else {
398            Some(Self::Addition(line.strip_prefix("+")?))
399        }
400    }
401}
402
403impl<'a> Display for DiffLine<'a> {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        match self {
406            DiffLine::OldPath { path } => write!(f, "--- {path}"),
407            DiffLine::NewPath { path } => write!(f, "+++ {path}"),
408            DiffLine::HunkHeader(Some(hunk_location)) => {
409                write!(
410                    f,
411                    "@@ -{},{} +{},{} @@",
412                    hunk_location.start_line_old + 1,
413                    hunk_location.count_old,
414                    hunk_location.start_line_new + 1,
415                    hunk_location.count_new
416                )
417            }
418            DiffLine::HunkHeader(None) => write!(f, "@@ ... @@"),
419            DiffLine::Context(content) => write!(f, " {content}"),
420            DiffLine::Deletion(content) => write!(f, "-{content}"),
421            DiffLine::Addition(content) => write!(f, "+{content}"),
422            DiffLine::Garbage(line) => write!(f, "{line}"),
423        }
424    }
425}
426
427fn parse_header_path<'a>(strip_prefix: &'static str, header: &'a str) -> Cow<'a, str> {
428    if !header.contains(['"', '\\']) {
429        let path = header.split_ascii_whitespace().next().unwrap_or(header);
430        return Cow::Borrowed(path.strip_prefix(strip_prefix).unwrap_or(path));
431    }
432
433    let mut path = String::with_capacity(header.len());
434    let mut in_quote = false;
435    let mut chars = header.chars().peekable();
436    let mut strip_prefix = Some(strip_prefix);
437
438    while let Some(char) = chars.next() {
439        if char == '"' {
440            in_quote = !in_quote;
441        } else if char == '\\' {
442            let Some(&next_char) = chars.peek() else {
443                break;
444            };
445            chars.next();
446            path.push(next_char);
447        } else if char.is_ascii_whitespace() && !in_quote {
448            break;
449        } else {
450            path.push(char);
451        }
452
453        if let Some(prefix) = strip_prefix
454            && path == prefix
455        {
456            strip_prefix.take();
457            path.clear();
458        }
459    }
460
461    Cow::Owned(path)
462}
463
464fn eat_required_whitespace(header: &str) -> Option<&str> {
465    let trimmed = header.trim_ascii_start();
466
467    if trimmed.len() == header.len() {
468        None
469    } else {
470        Some(trimmed)
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use gpui::TestAppContext;
478    use indoc::indoc;
479    use pretty_assertions::assert_eq;
480    use project::{FakeFs, Project};
481    use serde_json::json;
482    use settings::SettingsStore;
483    use util::path;
484
485    #[test]
486    fn parse_lines_simple() {
487        let input = indoc! {"
488            diff --git a/text.txt b/text.txt
489            index 86c770d..a1fd855 100644
490            --- a/file.txt
491            +++ b/file.txt
492            @@ -1,2 +1,3 @@
493             context
494            -deleted
495            +inserted
496            garbage
497
498            --- b/file.txt
499            +++ a/file.txt
500        "};
501
502        let lines = input.lines().map(DiffLine::parse).collect::<Vec<_>>();
503
504        pretty_assertions::assert_eq!(
505            lines,
506            &[
507                DiffLine::Garbage("diff --git a/text.txt b/text.txt"),
508                DiffLine::Garbage("index 86c770d..a1fd855 100644"),
509                DiffLine::OldPath {
510                    path: "file.txt".into()
511                },
512                DiffLine::NewPath {
513                    path: "file.txt".into()
514                },
515                DiffLine::HunkHeader(Some(HunkLocation {
516                    start_line_old: 0,
517                    count_old: 2,
518                    start_line_new: 0,
519                    count_new: 3
520                })),
521                DiffLine::Context("context"),
522                DiffLine::Deletion("deleted"),
523                DiffLine::Addition("inserted"),
524                DiffLine::Garbage("garbage"),
525                DiffLine::Context(""),
526                DiffLine::OldPath {
527                    path: "b/file.txt".into()
528                },
529                DiffLine::NewPath {
530                    path: "a/file.txt".into()
531                },
532            ]
533        );
534    }
535
536    #[test]
537    fn file_header_extra_space() {
538        let options = ["--- file", "---   file", "---\tfile"];
539
540        for option in options {
541            pretty_assertions::assert_eq!(
542                DiffLine::parse(option),
543                DiffLine::OldPath {
544                    path: "file".into()
545                },
546                "{option}",
547            );
548        }
549    }
550
551    #[test]
552    fn hunk_header_extra_space() {
553        let options = [
554            "@@ -1,2 +1,3 @@",
555            "@@  -1,2  +1,3 @@",
556            "@@\t-1,2\t+1,3\t@@",
557            "@@ -1,2  +1,3 @@",
558            "@@ -1,2   +1,3 @@",
559            "@@ -1,2 +1,3   @@",
560            "@@ -1,2 +1,3 @@ garbage",
561        ];
562
563        for option in options {
564            pretty_assertions::assert_eq!(
565                DiffLine::parse(option),
566                DiffLine::HunkHeader(Some(HunkLocation {
567                    start_line_old: 0,
568                    count_old: 2,
569                    start_line_new: 0,
570                    count_new: 3
571                })),
572                "{option}",
573            );
574        }
575    }
576
577    #[test]
578    fn hunk_header_without_location() {
579        pretty_assertions::assert_eq!(DiffLine::parse("@@ ... @@"), DiffLine::HunkHeader(None));
580    }
581
582    #[test]
583    fn test_parse_path() {
584        assert_eq!(parse_header_path("a/", "foo.txt"), "foo.txt");
585        assert_eq!(
586            parse_header_path("a/", "foo/bar/baz.txt"),
587            "foo/bar/baz.txt"
588        );
589        assert_eq!(parse_header_path("a/", "a/foo.txt"), "foo.txt");
590        assert_eq!(
591            parse_header_path("a/", "a/foo/bar/baz.txt"),
592            "foo/bar/baz.txt"
593        );
594
595        // Extra
596        assert_eq!(
597            parse_header_path("a/", "a/foo/bar/baz.txt  2025"),
598            "foo/bar/baz.txt"
599        );
600        assert_eq!(
601            parse_header_path("a/", "a/foo/bar/baz.txt\t2025"),
602            "foo/bar/baz.txt"
603        );
604        assert_eq!(
605            parse_header_path("a/", "a/foo/bar/baz.txt \""),
606            "foo/bar/baz.txt"
607        );
608
609        // Quoted
610        assert_eq!(
611            parse_header_path("a/", "a/foo/bar/\"baz quox.txt\""),
612            "foo/bar/baz quox.txt"
613        );
614        assert_eq!(
615            parse_header_path("a/", "\"a/foo/bar/baz quox.txt\""),
616            "foo/bar/baz quox.txt"
617        );
618        assert_eq!(
619            parse_header_path("a/", "\"foo/bar/baz quox.txt\""),
620            "foo/bar/baz quox.txt"
621        );
622        assert_eq!(parse_header_path("a/", "\"whatever 🤷\""), "whatever 🤷");
623        assert_eq!(
624            parse_header_path("a/", "\"foo/bar/baz quox.txt\"  2025"),
625            "foo/bar/baz quox.txt"
626        );
627        // unescaped quotes are dropped
628        assert_eq!(parse_header_path("a/", "foo/\"bar\""), "foo/bar");
629
630        // Escaped
631        assert_eq!(
632            parse_header_path("a/", "\"foo/\\\"bar\\\"/baz.txt\""),
633            "foo/\"bar\"/baz.txt"
634        );
635        assert_eq!(
636            parse_header_path("a/", "\"C:\\\\Projects\\\\My App\\\\old file.txt\""),
637            "C:\\Projects\\My App\\old file.txt"
638        );
639    }
640
641    #[test]
642    fn test_parse_diff_with_leading_and_trailing_garbage() {
643        let diff = indoc! {"
644            I need to make some changes.
645
646            I'll change the following things:
647            - one
648              - two
649            - three
650
651            ```
652            --- a/file.txt
653            +++ b/file.txt
654             one
655            +AND
656             two
657            ```
658
659            Summary of what I did:
660            - one
661              - two
662            - three
663
664            That's about it.
665        "};
666
667        let mut events = Vec::new();
668        let mut parser = DiffParser::new(diff);
669        while let Some(event) = parser.next().unwrap() {
670            events.push(event);
671        }
672
673        assert_eq!(
674            events,
675            &[
676                DiffEvent::Hunk {
677                    path: "file.txt".into(),
678                    hunk: Hunk {
679                        context: "one\ntwo\n".into(),
680                        edits: vec![Edit {
681                            range: 4..4,
682                            text: "AND\n".into()
683                        }],
684                    }
685                },
686                DiffEvent::FileEnd { renamed_to: None }
687            ],
688        )
689    }
690
691    #[gpui::test]
692    async fn test_apply_diff_successful(cx: &mut TestAppContext) {
693        let fs = init_test(cx);
694
695        let buffer_1_text = indoc! {r#"
696            one
697            two
698            three
699            four
700            five
701        "# };
702
703        let buffer_1_text_final = indoc! {r#"
704            3
705            4
706            5
707        "# };
708
709        let buffer_2_text = indoc! {r#"
710            six
711            seven
712            eight
713            nine
714            ten
715        "# };
716
717        let buffer_2_text_final = indoc! {r#"
718            5
719            six
720            seven
721            7.5
722            eight
723            nine
724            ten
725            11
726        "# };
727
728        fs.insert_tree(
729            path!("/root"),
730            json!({
731                "file1": buffer_1_text,
732                "file2": buffer_2_text,
733            }),
734        )
735        .await;
736
737        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
738
739        let diff = indoc! {r#"
740            --- a/file1
741            +++ b/file1
742             one
743             two
744            -three
745            +3
746             four
747             five
748            --- a/file1
749            +++ b/file1
750             3
751            -four
752            -five
753            +4
754            +5
755            --- a/file1
756            +++ b/file1
757            -one
758            -two
759             3
760             4
761            --- a/file2
762            +++ b/file2
763            +5
764             six
765            --- a/file2
766            +++ b/file2
767             seven
768            +7.5
769             eight
770            --- a/file2
771            +++ b/file2
772             ten
773            +11
774        "#};
775
776        let _buffers = apply_diff(diff, &project, &mut cx.to_async())
777            .await
778            .unwrap();
779        let buffer_1 = project
780            .update(cx, |project, cx| {
781                let project_path = project.find_project_path(path!("/root/file1"), cx).unwrap();
782                project.open_buffer(project_path, cx)
783            })
784            .await
785            .unwrap();
786
787        buffer_1.read_with(cx, |buffer, _cx| {
788            assert_eq!(buffer.text(), buffer_1_text_final);
789        });
790        let buffer_2 = project
791            .update(cx, |project, cx| {
792                let project_path = project.find_project_path(path!("/root/file2"), cx).unwrap();
793                project.open_buffer(project_path, cx)
794            })
795            .await
796            .unwrap();
797
798        buffer_2.read_with(cx, |buffer, _cx| {
799            assert_eq!(buffer.text(), buffer_2_text_final);
800        });
801    }
802
803    #[gpui::test]
804    async fn test_apply_diff_unique_via_previous_context(cx: &mut TestAppContext) {
805        let fs = init_test(cx);
806
807        let start = indoc! {r#"
808            one
809            two
810            three
811            four
812            five
813
814            four
815            five
816        "# };
817
818        let end = indoc! {r#"
819            one
820            two
821            3
822            four
823            5
824
825            four
826            five
827        "# };
828
829        fs.insert_tree(
830            path!("/root"),
831            json!({
832                "file1": start,
833            }),
834        )
835        .await;
836
837        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
838
839        let diff = indoc! {r#"
840            --- a/file1
841            +++ b/file1
842             one
843             two
844            -three
845            +3
846             four
847            -five
848            +5
849        "#};
850
851        let _buffers = apply_diff(diff, &project, &mut cx.to_async())
852            .await
853            .unwrap();
854
855        let buffer_1 = project
856            .update(cx, |project, cx| {
857                let project_path = project.find_project_path(path!("/root/file1"), cx).unwrap();
858                project.open_buffer(project_path, cx)
859            })
860            .await
861            .unwrap();
862
863        buffer_1.read_with(cx, |buffer, _cx| {
864            assert_eq!(buffer.text(), end);
865        });
866    }
867
868    fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
869        cx.update(|cx| {
870            let settings_store = SettingsStore::test(cx);
871            cx.set_global(settings_store);
872        });
873
874        FakeFs::new(cx.background_executor.clone())
875    }
876}