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