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