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