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