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