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