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