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 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}