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