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