udiff.rs

   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}