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