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