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