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