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