udiff.rs

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