udiff.rs

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