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 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}