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