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