udiff.rs

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