udiff.rs

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