Skip to main content

diffs_rs/
model.rs

1use std::{io::BufRead, ops::Range};
2
3use anyhow::{Context, Result, bail};
4use git2::{Delta, Diff, Patch};
5use serde::Serialize;
6
7mod rename_names;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
10pub enum Kind {
11    Context,
12    Addition,
13    Deletion,
14}
15
16#[derive(Debug, Serialize)]
17pub struct SourceLine {
18    pub kind: Kind,
19    pub old: Option<u32>,
20    pub new: Option<u32>,
21    pub bytes: Vec<u8>,
22    pub no_newline: bool,
23    #[serde(skip)]
24    pub offset: usize,
25}
26
27#[derive(Debug, Serialize)]
28pub struct Hunk {
29    pub header: String,
30    pub old_start: u32,
31    pub new_start: u32,
32    pub lines: Range<usize>,
33}
34
35#[derive(Debug, Serialize)]
36pub struct FileDiff {
37    #[serde(skip)]
38    pub(crate) identity: PatchIdentity,
39    #[serde(skip)]
40    pub raw_path: Vec<u8>,
41    pub path: String,
42    pub old_path: String,
43    pub status: String,
44    pub old_mode: String,
45    pub new_mode: String,
46    pub additions: usize,
47    pub deletions: usize,
48    pub digits: usize,
49    pub hunks: Vec<Hunk>,
50}
51
52#[derive(Clone, Debug, Default)]
53pub(crate) struct PatchIdentity {
54    pub old_path: Vec<u8>,
55    pub new_path: Vec<u8>,
56    pub old_oid: Option<git2::Oid>,
57    pub new_oid: Option<git2::Oid>,
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum RowKind<S = usize> {
62    Header,
63    Hunk(usize),
64    Gap,
65    Metadata,
66    Code { left: Option<S>, right: Option<S> },
67    NoNewline { left: bool, right: bool },
68}
69
70#[derive(Clone, Copy, Debug)]
71pub struct Row<S = usize> {
72    pub file: usize,
73    pub kind: RowKind<S>,
74}
75
76impl<S> Row<S> {
77    pub fn map<T>(self, mut source: impl FnMut(S) -> T) -> Row<T> {
78        Row {
79            file: self.file,
80            kind: match self.kind {
81                RowKind::Header => RowKind::Header,
82                RowKind::Hunk(h) => RowKind::Hunk(h),
83                RowKind::Gap => RowKind::Gap,
84                RowKind::Metadata => RowKind::Metadata,
85                RowKind::Code { left, right } => RowKind::Code {
86                    left: left.map(&mut source),
87                    right: right.map(source),
88                },
89                RowKind::NoNewline { left, right } => RowKind::NoNewline { left, right },
90            },
91        }
92    }
93}
94
95#[derive(Debug, Default, Serialize)]
96pub struct Document {
97    pub files: Vec<FileDiff>,
98    pub lines: Vec<SourceLine>,
99    #[serde(skip)]
100    pub split: Vec<Row>,
101    #[serde(skip)]
102    pub unified: Vec<Row>,
103    #[serde(skip)]
104    pub split_locations: Vec<usize>,
105    #[serde(skip)]
106    pub unified_locations: Vec<usize>,
107}
108
109impl Document {
110    pub fn parse(input: &[u8]) -> Result<Self> {
111        Self::read(std::io::Cursor::new(input))
112    }
113
114    pub fn read(input: impl BufRead) -> Result<Self> {
115        let mut doc = Self::default();
116        crate::ingest::visit_files(input, |_, unit| {
117            let mut part = Self::parse_file(unit)?;
118            let base = doc.lines.len();
119            for file in &mut part.files {
120                for hunk in &mut file.hunks {
121                    hunk.lines = (hunk.lines.start + base)..(hunk.lines.end + base);
122                }
123            }
124            doc.lines.append(&mut part.lines);
125            doc.files.append(&mut part.files);
126            Ok(())
127        })?;
128        doc.index_rows();
129        Ok(doc)
130    }
131
132    pub fn parse_file(input: &[u8]) -> Result<Self> {
133        if input.iter().all(u8::is_ascii_whitespace) {
134            return Ok(Self::default());
135        }
136        if input
137            .split(|b| *b == b'\n')
138            .any(|l| l.starts_with(b"diff --cc ") || l.starts_with(b"diff --combined "))
139        {
140            bail!("Combined merge diffs are not supported. Supply a two-sided patch.");
141        }
142        let normalized = empty_file_headers(input);
143        let normalized = normalized.as_deref().unwrap_or(input);
144        let quoted = rename_names::quote_rename_names(normalized);
145        let diff = Diff::from_buffer(quoted.as_ref().map_or(normalized, |q| &q.bytes))
146            .context("Cannot parse this patch")?;
147        if diff.deltas().len() == 0 {
148            bail!("The input contains no recognized file changes.");
149        }
150        let mut doc = Self::default();
151        for (file_index, delta) in diff.deltas().enumerate() {
152            let old_path =
153                crate::text::safe_label(delta.old_file().path_bytes().unwrap_or(b"/dev/null"));
154            let path =
155                crate::text::safe_label(delta.new_file().path_bytes().unwrap_or(b"/dev/null"));
156            let mut file = FileDiff {
157                identity: PatchIdentity {
158                    old_path: delta
159                        .old_file()
160                        .path_bytes()
161                        .unwrap_or(b"/dev/null")
162                        .to_vec(),
163                    new_path: delta
164                        .new_file()
165                        .path_bytes()
166                        .unwrap_or(b"/dev/null")
167                        .to_vec(),
168                    old_oid: (!delta.old_file().id().is_zero()).then(|| delta.old_file().id()),
169                    new_oid: (!delta.new_file().id().is_zero()).then(|| delta.new_file().id()),
170                },
171                raw_path: if delta.status() == Delta::Deleted {
172                    delta.old_file().path_bytes().unwrap_or(b"/dev/null")
173                } else {
174                    delta.new_file().path_bytes().unwrap_or(b"/dev/null")
175                }
176                .to_vec(),
177                path: if delta.status() == Delta::Deleted {
178                    old_path.clone()
179                } else {
180                    path
181                },
182                old_path,
183                status: match delta.status() {
184                    Delta::Added => "Added",
185                    Delta::Deleted => "Deleted",
186                    Delta::Renamed => "Renamed",
187                    Delta::Copied => "Copied",
188                    Delta::Typechange => "Type changed",
189                    _ => "Modified",
190                }
191                .into(),
192                old_mode: format!("{:o}", u32::from(delta.old_file().mode())),
193                new_mode: format!("{:o}", u32::from(delta.new_file().mode())),
194                additions: 0,
195                deletions: 0,
196                digits: 3,
197                hunks: Vec::new(),
198            };
199            if let Some(patch) = Patch::from_diff(&diff, file_index)? {
200                let (mut old_end, mut new_end) = (0, 0);
201                for hunk_index in 0..patch.num_hunks() {
202                    let (hunk, count) = patch.hunk(hunk_index)?;
203                    let old_start = u64::from(hunk.old_start()) + u64::from(hunk.old_lines() == 0);
204                    let new_start = u64::from(hunk.new_start()) + u64::from(hunk.new_lines() == 0);
205                    if old_start < old_end || new_start < new_end {
206                        bail!("Hunks overlap or appear out of source order");
207                    }
208                    old_end = old_start + u64::from(hunk.old_lines());
209                    new_end = new_start + u64::from(hunk.new_lines());
210                    let start = doc.lines.len();
211                    let (mut old_count, mut new_count) = (0, 0);
212                    for i in 0..count {
213                        let line = patch.line_in_hunk(hunk_index, i)?;
214                        let kind = match line.origin() {
215                            ' ' => Kind::Context,
216                            '+' => Kind::Addition,
217                            '-' => Kind::Deletion,
218                            '=' | '<' | '>' => {
219                                if doc.lines.len() == start {
220                                    bail!("Newline marker without a source line in this hunk");
221                                }
222                                let previous = doc
223                                    .lines
224                                    .last_mut()
225                                    .context("Newline marker without a source line")?;
226                                previous.no_newline = true;
227                                if previous.bytes.last() == Some(&b'\n') {
228                                    previous.bytes.pop();
229                                }
230                                continue;
231                            }
232                            c => bail!("Unsupported patch line type: {c:?}"),
233                        };
234                        let old = line.old_lineno();
235                        let new = line.new_lineno();
236                        if let Some(n) = old {
237                            if n == 0 || Some(n) != hunk.old_start().checked_add(old_count) {
238                                bail!("Non-contiguous old source line");
239                            }
240                            old_count += 1;
241                        }
242                        if let Some(n) = new {
243                            if n == 0 || Some(n) != hunk.new_start().checked_add(new_count) {
244                                bail!("Non-contiguous new source line");
245                            }
246                            new_count += 1;
247                        }
248                        file.additions += usize::from(kind == Kind::Addition);
249                        file.deletions += usize::from(kind == Kind::Deletion);
250                        let largest = old.unwrap_or(0).max(new.unwrap_or(0)).max(1);
251                        file.digits = file.digits.max(largest.ilog10() as usize + 1);
252                        let offset = usize::try_from(line.content_offset())?;
253                        let offset = match &quoted {
254                            Some(quoted) => quoted.original_offset(offset),
255                            None => Some(offset),
256                        }
257                        .and_then(|offset| offset.checked_add(1))
258                        .context("Source offset overflow")?;
259                        let end = offset
260                            .checked_add(line.content().len())
261                            .context("Source length overflow")?;
262                        anyhow::ensure!(
263                            input.get(offset..end) == Some(line.content()),
264                            "Parser source offset does not match the supplied patch"
265                        );
266                        doc.lines.push(SourceLine {
267                            kind,
268                            old,
269                            new,
270                            bytes: line.content().to_vec(),
271                            no_newline: false,
272                            offset,
273                        });
274                    }
275                    if old_count != hunk.old_lines() || new_count != hunk.new_lines() {
276                        bail!("Hunk counts do not agree with its source lines");
277                    }
278                    file.hunks.push(Hunk {
279                        header: crate::text::safe_label(
280                            hunk.header().strip_suffix(b"\n").unwrap_or(hunk.header()),
281                        ),
282                        old_start: hunk.old_start(),
283                        new_start: hunk.new_start(),
284                        lines: start..doc.lines.len(),
285                    });
286                }
287            }
288            if delta.flags().is_binary() {
289                file.status = "Binary change".into();
290            }
291            doc.files.push(file);
292        }
293        Ok(doc)
294    }
295
296    fn push_both(&mut self, file: usize, kind: RowKind) {
297        self.split.push(Row { file, kind });
298        self.unified.push(Row { file, kind });
299    }
300
301    pub(crate) fn index_rows(&mut self) {
302        self.split_locations.resize(self.lines.len(), 0);
303        self.unified_locations.resize(self.lines.len(), 0);
304        for f in 0..self.files.len() {
305            self.push_both(f, RowKind::Header);
306            if self.files[f].hunks.is_empty() {
307                self.push_both(f, RowKind::Metadata);
308            }
309            let (mut old_end, mut new_end) = (1, 1);
310            for h in 0..self.files[f].hunks.len() {
311                let hunk = &self.files[f].hunks[h];
312                let range = hunk.lines.clone();
313                if hunk.old_start > old_end || hunk.new_start > new_end {
314                    self.push_both(f, RowKind::Gap);
315                }
316                self.push_both(f, RowKind::Hunk(h));
317                let mut i = range.start;
318                while i < range.end {
319                    if self.lines[i].kind == Kind::Context {
320                        self.add_split(f, Some(i), Some(i));
321                        self.add_unified(f, i);
322                        i += 1;
323                    } else {
324                        let start = i;
325                        while i < range.end && self.lines[i].kind != Kind::Context {
326                            i += 1;
327                        }
328                        let left: Vec<_> = (start..i)
329                            .filter(|n| self.lines[*n].kind == Kind::Deletion)
330                            .collect();
331                        let right: Vec<_> = (start..i)
332                            .filter(|n| self.lines[*n].kind == Kind::Addition)
333                            .collect();
334                        for id in left.iter().chain(&right) {
335                            self.add_unified(f, *id);
336                        }
337                        let offset = alignment_offset(&self.lines, &left, &right);
338                        let left_pad = if right.len() > left.len() { offset } else { 0 };
339                        let right_pad = if left.len() > right.len() { offset } else { 0 };
340                        for row in 0..(left.len() + left_pad).max(right.len() + right_pad) {
341                            let l = row.checked_sub(left_pad).and_then(|n| left.get(n)).copied();
342                            let r = row
343                                .checked_sub(right_pad)
344                                .and_then(|n| right.get(n))
345                                .copied();
346                            self.add_split(f, l, r);
347                        }
348                    }
349                }
350                for line in &self.lines[range] {
351                    if let Some(n) = line.old {
352                        old_end = n.saturating_add(1);
353                    }
354                    if let Some(n) = line.new {
355                        new_end = n.saturating_add(1);
356                    }
357                }
358            }
359        }
360    }
361
362    fn add_split(&mut self, file: usize, left: Option<usize>, right: Option<usize>) {
363        for id in left.into_iter().chain(right) {
364            self.split_locations[id] = self.split.len();
365        }
366        self.split.push(Row {
367            file,
368            kind: RowKind::Code { left, right },
369        });
370        let l = left.is_some_and(|i| self.lines[i].no_newline);
371        let r = right.is_some_and(|i| self.lines[i].no_newline);
372        if l || r {
373            self.split.push(Row {
374                file,
375                kind: RowKind::NoNewline { left: l, right: r },
376            });
377        }
378    }
379
380    fn add_unified(&mut self, file: usize, id: usize) {
381        let line = &self.lines[id];
382        self.unified_locations[id] = self.unified.len();
383        let left = line.old.map(|_| id);
384        let right = line.new.map(|_| id);
385        self.unified.push(Row {
386            file,
387            kind: RowKind::Code { left, right },
388        });
389        if line.no_newline {
390            self.unified.push(Row {
391                file,
392                kind: RowKind::NoNewline {
393                    left: left.is_some(),
394                    right: right.is_some(),
395                },
396            });
397        }
398    }
399}
400
401fn empty_file_headers(input: &[u8]) -> Option<Vec<u8>> {
402    let mut lines = input.split_inclusive(|b| *b == b'\n');
403    let (header, mode, index) = (lines.next()?, lines.next()?, lines.next()?);
404    if lines.next().is_some() {
405        return None;
406    }
407    let added = mode.starts_with(b"new file mode ");
408    let deleted = mode.starts_with(b"deleted file mode ");
409    if !(added || deleted) || !index.starts_with(b"index ") {
410        return None;
411    }
412    let header = header.strip_prefix(b"diff --git ")?.strip_suffix(b"\n")?;
413    let (old, new) = split_identical_paths(header)?;
414    let ids = index.strip_prefix(b"index ")?.strip_suffix(b"\n")?;
415    let (old_id, new_id) = std::str::from_utf8(ids).ok()?.split_once("..")?;
416    let empty = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391";
417    let matches_empty = |id: &str| id.len() >= 7 && empty.starts_with(id);
418    let zero = |id: &str| id.len() >= 7 && id.bytes().all(|b| b == b'0');
419    if !((added && zero(old_id) && matches_empty(new_id))
420        || (deleted && matches_empty(old_id) && zero(new_id)))
421    {
422        return None;
423    }
424    let mut result = input.to_vec();
425    result.extend_from_slice(b"--- ");
426    result.extend_from_slice(if added { b"/dev/null" } else { old });
427    result.extend_from_slice(b"\n+++ ");
428    result.extend_from_slice(if deleted { b"/dev/null" } else { new });
429    result.extend_from_slice(b"\n");
430    Some(result)
431}
432
433fn split_identical_paths(header: &[u8]) -> Option<(&[u8], &[u8])> {
434    let middle = header.len() / 2;
435    if header.get(middle) != Some(&b' ') {
436        return None;
437    }
438    let (old, new) = (&header[..middle], &header[middle + 1..]);
439    let unquoted = old.strip_prefix(b"a/").zip(new.strip_prefix(b"b/"));
440    let quoted = old.strip_prefix(b"\"a/").zip(new.strip_prefix(b"\"b/"));
441    if unquoted.or(quoted).is_some_and(|(a, b)| a == b) {
442        Some((old, new))
443    } else {
444        None
445    }
446}
447
448// Only shift uneven replacements when shared content clearly beats positional pairing.
449pub(crate) fn alignment_offset(lines: &[SourceLine], left: &[usize], right: &[usize]) -> usize {
450    let pairs = left.len().min(right.len());
451    let extra = left.len().abs_diff(right.len());
452    if !alignment_needed(left.len(), right.len()) {
453        return 0;
454    }
455    if left
456        .iter()
457        .chain(right)
458        .any(|i| lines[*i].bytes.len() > 4096)
459    {
460        return 0;
461    }
462    let mut a = Vec::with_capacity(
463        left.iter()
464            .map(|&i| lines[i].bytes.len())
465            .max()
466            .unwrap_or(0),
467    );
468    let mut b = Vec::with_capacity(
469        right
470            .iter()
471            .map(|&i| lines[i].bytes.len())
472            .max()
473            .unwrap_or(0),
474    );
475    let mut score = |offset: usize| -> f64 {
476        (0..pairs)
477            .map(|p| {
478                let left_bytes =
479                    &lines[left[p + if left.len() > right.len() { offset } else { 0 }]].bytes;
480                let right_bytes =
481                    &lines[right[p + if right.len() > left.len() { offset } else { 0 }]].bytes;
482                a.clear();
483                b.clear();
484                a.extend(
485                    left_bytes
486                        .iter()
487                        .copied()
488                        .filter(|c| !c.is_ascii_whitespace()),
489                );
490                b.extend(
491                    right_bytes
492                        .iter()
493                        .copied()
494                        .filter(|c| !c.is_ascii_whitespace()),
495                );
496                let prefix = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
497                let suffix = a[prefix..]
498                    .iter()
499                    .rev()
500                    .zip(b[prefix..].iter().rev())
501                    .take_while(|(x, y)| x == y)
502                    .count();
503                (prefix + suffix) as f64 / a.len().max(b.len()).max(1) as f64
504            })
505            .sum()
506    };
507    let mut best = score(0) + pairs as f64 * 0.5;
508    let mut offset = 0;
509    for n in 1..=extra {
510        let s = score(n);
511        if s > best {
512            best = s;
513            offset = n;
514        }
515    }
516    offset
517}
518
519pub(crate) fn alignment_needed(old: usize, new: usize) -> bool {
520    let pairs = old.min(new);
521    let extra = old.abs_diff(new);
522    pairs != 0 && extra != 0 && pairs.saturating_mul(extra + 1) <= 4096
523}