Skip to main content

grit_lib/apply/
apply_blob.rs

1//! Apply parsed unified-diff hunks to a single blob (no worktree or index I/O).
2
3use std::borrow::Cow;
4
5use super::{Hunk, HunkLine};
6use crate::error::ApplyError;
7
8/// Options controlling blob-level hunk application.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub struct ApplyOptions {
11    /// When true, interpret the patch as reversing a forward diff (`+`/`-` swapped).
12    pub reverse: bool,
13}
14
15/// One logical line in the working blob (bytes include `\n` when the source line had one).
16///
17/// Untouched rows borrow from the original blob; only rows written by a hunk are owned.
18#[derive(Debug, Clone)]
19struct LineRecord<'a> {
20    raw: Cow<'a, [u8]>,
21    /// Set when an earlier hunk replaced this row (later hunks must not match through it).
22    consumed: bool,
23}
24
25/// Mutable line-split view of blob bytes while hunks are applied in order.
26#[derive(Debug, Clone)]
27struct WorkingBlob<'a> {
28    rows: Vec<LineRecord<'a>>,
29}
30
31impl<'a> WorkingBlob<'a> {
32    fn decode(data: &'a [u8]) -> Self {
33        let mut rows = Vec::with_capacity(memchr::memchr_iter(b'\n', data).count() + 1);
34        let mut start = 0usize;
35        for i in memchr::memchr_iter(b'\n', data) {
36            rows.push(LineRecord {
37                raw: Cow::Borrowed(&data[start..=i]),
38                consumed: false,
39            });
40            start = i + 1;
41        }
42        if start < data.len() {
43            rows.push(LineRecord {
44                raw: Cow::Borrowed(&data[start..]),
45                consumed: false,
46            });
47        }
48        Self { rows }
49    }
50
51    fn encode(&self) -> Vec<u8> {
52        let mut out = Vec::with_capacity(self.rows.iter().map(|row| row.raw.len()).sum());
53        for row in &self.rows {
54            out.extend_from_slice(&row.raw);
55        }
56        out
57    }
58
59    fn len(&self) -> usize {
60        self.rows.len()
61    }
62}
63
64/// Lines that must match in the working blob vs lines that replace them.
65#[derive(Debug, Clone)]
66struct LineEdit {
67    match_rows: Vec<Vec<u8>>,
68    replace_rows: Vec<Vec<u8>>,
69    stable_prefix: usize,
70    stable_suffix: usize,
71}
72
73/// Where to start scanning for [`LineEdit::match_rows`].
74#[derive(Debug, Clone, Copy)]
75struct ScanHint {
76    /// 0-based row index in the current working blob.
77    start_row: usize,
78    /// Match only when the edit aligns with the first row of the blob.
79    only_at_front: bool,
80    /// Match only when the edit ends on the last row of the blob.
81    only_at_back: bool,
82}
83
84/// Apply `hunks` to `original` and return the post-image bytes.
85///
86/// Matches `git apply` default behavior: exact line match at the hunk's post-image
87/// line (`new_start` forward, `old_start` when reversing), then search outward;
88/// prefix/suffix context rows may be dropped and the search retried. No fuzz or
89/// whitespace fixing.
90pub fn apply_hunks_to_blob(
91    original: &[u8],
92    hunks: &[Hunk],
93    opts: &ApplyOptions,
94) -> Result<Vec<u8>, ApplyError> {
95    let mut working = WorkingBlob::decode(original);
96    for (hunk_index, hunk) in hunks.iter().enumerate() {
97        let edit = line_edit_from_hunk(hunk, opts.reverse);
98        let insert_only = if opts.reverse {
99            hunk.new_count == 0
100        } else {
101            hunk.old_count == 0
102        };
103        let hint = scan_hint_for_hunk(hunk, &edit, opts.reverse);
104        let Some(at) = locate_and_apply(&mut working, &edit, hint, hunk, hunk_index, insert_only)?
105        else {
106            return Err(ApplyError::ContextMismatch {
107                hunk_index,
108                expected_line: if opts.reverse {
109                    hunk.old_start
110                } else {
111                    hunk.new_start
112                },
113            });
114        };
115        let _ = at;
116    }
117    Ok(working.encode())
118}
119
120fn scan_hint_for_hunk(hunk: &Hunk, edit: &LineEdit, reverse: bool) -> ScanHint {
121    let (header_line, only_at_front) = if reverse {
122        (hunk.old_start, hunk.new_start <= 1)
123    } else {
124        (hunk.new_start, hunk.old_start <= 1)
125    };
126    let start_row = if header_line == 0 {
127        0
128    } else {
129        header_line.saturating_sub(1)
130    };
131    ScanHint {
132        start_row,
133        only_at_front,
134        // Pin to EOF only when the hunk had leading context but no trailing context
135        // (a change that ends at the file end). Zero-context hunks (`-U0`) must not
136        // take this pin unless we add an explicit unidiff-zero mode; otherwise a line
137        // that also appears at EOF is matched at the wrong row.
138        only_at_back: edit.stable_suffix == 0 && edit.stable_prefix > 0,
139    }
140}
141
142fn line_edit_from_hunk(hunk: &Hunk, reverse: bool) -> LineEdit {
143    let mut match_rows = Vec::new();
144    let mut replace_rows = Vec::new();
145    let mut stable_prefix = 0usize;
146    let mut stable_suffix = 0usize;
147    let mut in_change = false;
148
149    let mut i = 0usize;
150    while i < hunk.lines.len() {
151        let (tag, text) = match &hunk.lines[i] {
152            HunkLine::Context(s) => ("ctx", s.as_str()),
153            HunkLine::Add(s) => ("add", s.as_str()),
154            HunkLine::Remove(s) => ("del", s.as_str()),
155            HunkLine::NoNewline => {
156                i += 1;
157                continue;
158            }
159        };
160        let tag = if reverse {
161            match tag {
162                "add" => "del",
163                "del" => "add",
164                other => other,
165            }
166        } else {
167            tag
168        };
169
170        let with_nl = !matches!(hunk.lines.get(i + 1), Some(HunkLine::NoNewline));
171        let row = hunk_text_as_row(text, with_nl);
172
173        match tag {
174            "ctx" => {
175                if !in_change {
176                    stable_prefix += 1;
177                }
178                stable_suffix += 1;
179                match_rows.push(row.clone());
180                replace_rows.push(row);
181            }
182            "del" => {
183                in_change = true;
184                stable_suffix = 0;
185                match_rows.push(row);
186            }
187            "add" => {
188                in_change = true;
189                stable_suffix = 0;
190                replace_rows.push(row);
191            }
192            _ => {}
193        }
194        i += 1;
195    }
196
197    if !in_change {
198        stable_suffix = stable_prefix;
199    }
200
201    LineEdit {
202        match_rows,
203        replace_rows,
204        stable_prefix,
205        stable_suffix,
206    }
207}
208
209fn hunk_text_as_row(text: &str, with_newline: bool) -> Vec<u8> {
210    let mut row = text.as_bytes().to_vec();
211    if with_newline {
212        row.push(b'\n');
213    }
214    row
215}
216
217fn locate_and_apply(
218    working: &mut WorkingBlob,
219    edit: &LineEdit,
220    hint: ScanHint,
221    hunk: &Hunk,
222    _hunk_index: usize,
223    insert_only: bool,
224) -> Result<Option<usize>, ApplyError> {
225    let mut candidate = edit.clone();
226    let mut hint = hint;
227    let mut prefix = edit.stable_prefix;
228    let mut suffix = edit.stable_suffix;
229    let mut pin_front = hint.only_at_front;
230    let mut pin_back = hint.only_at_back;
231
232    loop {
233        if candidate.match_rows.is_empty() {
234            if insert_only && !candidate.replace_rows.is_empty() {
235                let row = insert_only_row(hint, working, hunk);
236                splice_edit(working, row, &candidate);
237                return Ok(Some(row));
238            }
239            if prefix == 0 && suffix == 0 {
240                return Ok(None);
241            }
242        } else if let Some(row) =
243            search_outward(working, &candidate.match_rows, hint, pin_front, pin_back)
244        {
245            splice_edit(working, row, &candidate);
246            return Ok(Some(row));
247        }
248
249        if pin_front || pin_back {
250            pin_front = false;
251            pin_back = false;
252            continue;
253        }
254        if prefix == 0 && suffix == 0 {
255            return Ok(None);
256        }
257        if prefix >= suffix {
258            trim_edit_front(&mut candidate);
259            prefix -= 1;
260            hint.start_row = hint.start_row.saturating_sub(1);
261        }
262        if suffix > prefix {
263            trim_edit_back(&mut candidate);
264            suffix -= 1;
265        }
266    }
267}
268
269fn trim_edit_front(edit: &mut LineEdit) {
270    if !edit.match_rows.is_empty() {
271        edit.match_rows.remove(0);
272    }
273    if !edit.replace_rows.is_empty() {
274        edit.replace_rows.remove(0);
275    }
276}
277
278fn trim_edit_back(edit: &mut LineEdit) {
279    if !edit.match_rows.is_empty() {
280        edit.match_rows.pop();
281    }
282    if !edit.replace_rows.is_empty() {
283        edit.replace_rows.pop();
284    }
285}
286
287fn insert_only_row(hint: ScanHint, working: &WorkingBlob, hunk: &Hunk) -> usize {
288    let len = working.len();
289    let at = hint.start_row.min(len);
290    if hint.only_at_back
291        && hunk.old_count == 0
292        && hunk.old_start > 0
293        && hunk.old_start >= len
294        && hunk.new_start >= hunk.old_start
295    {
296        return len;
297    }
298    at
299}
300
301fn search_outward(
302    working: &WorkingBlob,
303    pattern: &[Vec<u8>],
304    hint: ScanHint,
305    pin_front: bool,
306    pin_back: bool,
307) -> Option<usize> {
308    let width = pattern.len();
309    debug_assert!(!pattern.is_empty());
310    let mut anchor = hint.start_row.min(working.len());
311    if pin_front {
312        anchor = 0;
313    } else if pin_back {
314        anchor = working.len().saturating_sub(width);
315    }
316
317    let mut low = anchor;
318    let mut high = anchor;
319    let mut cursor = anchor;
320
321    let attempts = working.len().saturating_add(width).saturating_add(1);
322    for step in 0..attempts {
323        if rows_equal_pattern(working, cursor, pattern, pin_front, pin_back) {
324            return Some(cursor);
325        }
326
327        let can_low = low > 0;
328        let can_high = high < working.len();
329        if !can_low && !can_high {
330            break;
331        }
332
333        if step & 1 == 1 {
334            if can_low {
335                low -= 1;
336                cursor = low;
337            }
338        } else if can_high {
339            high += 1;
340            cursor = high;
341        }
342    }
343    None
344}
345
346fn rows_equal_pattern(
347    working: &WorkingBlob,
348    at: usize,
349    pattern: &[Vec<u8>],
350    pin_front: bool,
351    pin_back: bool,
352) -> bool {
353    if pin_front && at != 0 {
354        return false;
355    }
356    if pin_back && at + pattern.len() != working.len() {
357        return false;
358    }
359    if at + pattern.len() > working.len() {
360        return false;
361    }
362    for (offset, expected) in pattern.iter().enumerate() {
363        let row = &working.rows[at + offset];
364        if row.consumed {
365            return false;
366        }
367        if row.raw.as_ref() != expected.as_slice() {
368            return false;
369        }
370    }
371    true
372}
373
374fn splice_edit(working: &mut WorkingBlob, at: usize, edit: &LineEdit) {
375    let remove = edit
376        .match_rows
377        .len()
378        .min(working.rows.len().saturating_sub(at));
379    let new_rows = edit.replace_rows.iter().map(|raw| LineRecord {
380        raw: Cow::Owned(raw.clone()),
381        consumed: true,
382    });
383    working.rows.splice(at..at + remove, new_rows);
384}