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