udiff.rs

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