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