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