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