Skip to main content

rootle_diff/
parse.rs

1use crate::{DiffLine, FileDiff, Hunk, HunkRange, LineNumber, LineOrigin};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum PatchErrorKind {
5    Header,
6    UnexpectedLine,
7    MissingNewlineTarget,
8    IncompleteHunk,
9    ExcessLines,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct PatchError {
14    pub patch_line: usize,
15    pub kind: PatchErrorKind,
16}
17impl std::fmt::Display for PatchError {
18    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        let reason = match self.kind {
20            PatchErrorKind::Header => "invalid hunk range",
21            PatchErrorKind::UnexpectedLine => "unprefixed or unexpected patch line",
22            PatchErrorKind::MissingNewlineTarget => "newline marker without a preceding line",
23            PatchErrorKind::IncompleteHunk => "incomplete hunk (patch may be truncated)",
24            PatchErrorKind::ExcessLines => "hunk contains more lines than its range",
25        };
26        write!(formatter, "patch line {}: {reason}", self.patch_line)
27    }
28}
29impl std::error::Error for PatchError {}
30
31impl FileDiff {
32    pub fn parse(patch: &str) -> Result<Self, PatchError> {
33        let mut diff = Self::default();
34        let mut consumed_old = 0;
35        let mut consumed_new = 0;
36        let mut last_line = 0;
37        for (index, line) in patch.split_terminator('\n').enumerate() {
38            let patch_line = index + 1;
39            last_line = patch_line;
40            let error = |kind| PatchError { patch_line, kind };
41            if line.starts_with("@@") {
42                finish(&diff, consumed_old, consumed_new, patch_line)?;
43                let (old, new, context) =
44                    parse_header(line).ok_or_else(|| error(PatchErrorKind::Header))?;
45                diff.hunks.push(Hunk {
46                    old,
47                    new,
48                    context: context.to_string(),
49                    lines: Vec::new(),
50                });
51                consumed_old = 0;
52                consumed_new = 0;
53                continue;
54            }
55            if line == "\\ No newline at end of file" {
56                let target = diff
57                    .hunks
58                    .last_mut()
59                    .and_then(|hunk| hunk.lines.last_mut())
60                    .ok_or_else(|| error(PatchErrorKind::MissingNewlineTarget))?;
61                target.has_newline = false;
62                continue;
63            }
64            let Some(hunk) = diff.hunks.last_mut() else {
65                if line.is_empty()
66                    || [
67                        "diff --git ",
68                        "index ",
69                        "--- ",
70                        "+++ ",
71                        "new file mode ",
72                        "deleted file mode ",
73                        "old mode ",
74                        "new mode ",
75                        "similarity index ",
76                        "rename from ",
77                        "rename to ",
78                    ]
79                    .iter()
80                    .any(|prefix| line.starts_with(prefix))
81                {
82                    continue;
83                }
84                return Err(error(PatchErrorKind::UnexpectedLine));
85            };
86            let (origin, text) = if let Some(text) = line.strip_prefix('+') {
87                (LineOrigin::Addition, text)
88            } else if let Some(text) = line.strip_prefix('-') {
89                (LineOrigin::Deletion, text)
90            } else if let Some(text) = line.strip_prefix(' ') {
91                (LineOrigin::Context, text)
92            } else {
93                return Err(error(PatchErrorKind::UnexpectedLine));
94            };
95            let old_line = if origin != LineOrigin::Addition {
96                Some(
97                    take_line(hunk.old, &mut consumed_old)
98                        .ok_or_else(|| error(PatchErrorKind::ExcessLines))?,
99                )
100            } else {
101                None
102            };
103            let new_line = if origin != LineOrigin::Deletion {
104                Some(
105                    take_line(hunk.new, &mut consumed_new)
106                        .ok_or_else(|| error(PatchErrorKind::ExcessLines))?,
107                )
108            } else {
109                None
110            };
111            match origin {
112                LineOrigin::Addition => diff.added += 1,
113                LineOrigin::Deletion => diff.deleted += 1,
114                LineOrigin::Context => {}
115            }
116            // The transport may omit the patch string's final newline.
117            // Only the explicit marker describes the source file's EOF.
118            hunk.lines.push(DiffLine {
119                origin,
120                old_line,
121                new_line,
122                text: text.to_string(),
123                has_newline: true,
124            });
125        }
126        finish(&diff, consumed_old, consumed_new, last_line)?;
127        Ok(diff)
128    }
129}
130
131fn take_line(range: HunkRange, consumed: &mut u32) -> Option<LineNumber> {
132    if *consumed >= range.count {
133        return None;
134    }
135    let line = LineNumber::new(range.start.checked_add(*consumed)?)?;
136    *consumed += 1;
137    Some(line)
138}
139
140fn finish(diff: &FileDiff, old: u32, new: u32, patch_line: usize) -> Result<(), PatchError> {
141    if let Some(hunk) = diff.hunks.last()
142        && (old != hunk.old.count || new != hunk.new.count)
143    {
144        return Err(PatchError {
145            patch_line,
146            kind: PatchErrorKind::IncompleteHunk,
147        });
148    }
149    Ok(())
150}
151
152fn parse_header(line: &str) -> Option<(HunkRange, HunkRange, &str)> {
153    let (ranges, context) = line.strip_prefix("@@ ")?.split_once(" @@")?;
154    let (old, new) = ranges.split_once(' ')?;
155    Some((
156        parse_range(old.strip_prefix('-')?)?,
157        parse_range(new.strip_prefix('+')?)?,
158        context.trim_start(),
159    ))
160}
161
162fn parse_range(text: &str) -> Option<HunkRange> {
163    let (start, count) = match text.split_once(',') {
164        Some((start, count)) => (start.parse::<u32>().ok()?, count.parse::<u32>().ok()?),
165        None => (text.parse::<u32>().ok()?, 1),
166    };
167    if count > 0 {
168        LineNumber::new(start)?;
169        start.checked_add(count - 1)?;
170    }
171    Some(HunkRange { start, count })
172}