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