1use std::path::PathBuf;
13
14const BEGIN_PATCH: &str = "*** Begin Patch";
15const END_PATCH: &str = "*** End Patch";
16const ADD_FILE: &str = "*** Add File: ";
17const DELETE_FILE: &str = "*** Delete File: ";
18const UPDATE_FILE: &str = "*** Update File: ";
19const MOVE_TO: &str = "*** Move to: ";
20const EOF_MARKER: &str = "*** End of File";
21const CHANGE_CONTEXT: &str = "@@ ";
22const EMPTY_CHANGE_CONTEXT: &str = "@@";
23
24#[derive(Debug, PartialEq, Eq)]
28pub enum ParseError {
29 Invalid(String),
31 InvalidHunk { message: String, line_number: usize },
33}
34
35impl std::fmt::Display for ParseError {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 ParseError::Invalid(m) => write!(f, "invalid patch: {m}"),
39 ParseError::InvalidHunk {
40 message,
41 line_number,
42 } => write!(f, "invalid hunk at patch line {line_number}: {message}"),
43 }
44 }
45}
46
47#[derive(Debug, PartialEq, Eq, Clone)]
49pub enum Hunk {
50 AddFile { path: PathBuf, contents: String },
52 DeleteFile { path: PathBuf },
54 UpdateFile {
56 path: PathBuf,
57 move_path: Option<PathBuf>,
58 chunks: Vec<UpdateFileChunk>,
59 },
60}
61
62#[derive(Debug, PartialEq, Eq, Clone, Default)]
66pub struct UpdateFileChunk {
67 pub change_context: Option<String>,
69 pub old_lines: Vec<String>,
70 pub new_lines: Vec<String>,
71 pub is_end_of_file: bool,
73}
74
75fn is_file_marker(line: &str) -> bool {
76 line.starts_with(ADD_FILE) || line.starts_with(DELETE_FILE) || line.starts_with(UPDATE_FILE)
77}
78
79pub fn parse_patch(patch: &str) -> Result<Vec<Hunk>, ParseError> {
82 let raw: Vec<&str> = patch.lines().collect();
83 let start = raw
84 .iter()
85 .position(|l| l.trim_end() == BEGIN_PATCH)
86 .ok_or_else(|| ParseError::Invalid(format!("missing '{BEGIN_PATCH}' line")))?;
87 let end_rel = raw[start + 1..]
88 .iter()
89 .position(|l| l.trim_end() == END_PATCH)
90 .ok_or_else(|| ParseError::Invalid(format!("missing '{END_PATCH}' line")))?;
91 let body = &raw[start + 1..start + 1 + end_rel];
92
93 let mut hunks = Vec::new();
94 let mut i = 0;
95 while i < body.len() {
96 let line = body[i];
97 if let Some(path) = line.strip_prefix(ADD_FILE) {
98 let contents = parse_add_contents(body, &mut i)?;
99 hunks.push(Hunk::AddFile {
100 path: PathBuf::from(path.trim()),
101 contents,
102 });
103 } else if let Some(path) = line.strip_prefix(DELETE_FILE) {
104 hunks.push(Hunk::DeleteFile {
105 path: PathBuf::from(path.trim()),
106 });
107 i += 1;
108 } else if let Some(path) = line.strip_prefix(UPDATE_FILE) {
109 i += 1;
110 let move_path = body
111 .get(i)
112 .and_then(|l| l.strip_prefix(MOVE_TO))
113 .map(|dst| {
114 i += 1;
115 PathBuf::from(dst.trim())
116 });
117 let chunks = parse_update_chunks(body, &mut i)?;
118 hunks.push(Hunk::UpdateFile {
119 path: PathBuf::from(path.trim()),
120 move_path,
121 chunks,
122 });
123 } else if line.trim().is_empty() {
124 i += 1;
125 } else {
126 return Err(ParseError::Invalid(format!(
127 "unexpected line outside a hunk: {line:?}"
128 )));
129 }
130 }
131 if hunks.is_empty() {
132 return Err(ParseError::Invalid("patch contains no hunks".to_string()));
133 }
134 Ok(hunks)
135}
136
137fn parse_add_contents(body: &[&str], i: &mut usize) -> Result<String, ParseError> {
138 *i += 1;
139 let mut lines: Vec<&str> = Vec::new();
140 while *i < body.len() && !is_file_marker(body[*i]) {
141 let l = body[*i];
142 let content = l.strip_prefix('+').ok_or_else(|| ParseError::InvalidHunk {
143 message: format!("expected a '+' line in an Add File hunk, got {l:?}"),
144 line_number: *i,
145 })?;
146 lines.push(content);
147 *i += 1;
148 }
149 Ok(lines.join("\n"))
150}
151
152fn parse_update_chunks(body: &[&str], i: &mut usize) -> Result<Vec<UpdateFileChunk>, ParseError> {
153 let mut chunks: Vec<UpdateFileChunk> = Vec::new();
154 let mut current: Option<UpdateFileChunk> = None;
155 while *i < body.len() && !is_file_marker(body[*i]) {
156 let l = body[*i];
157 if l == EMPTY_CHANGE_CONTEXT || l.starts_with(CHANGE_CONTEXT) {
158 if let Some(c) = current.take() {
159 chunks.push(c);
160 }
161 current = Some(UpdateFileChunk {
162 change_context: l.strip_prefix(CHANGE_CONTEXT).map(str::to_string),
163 ..Default::default()
164 });
165 } else if l == EOF_MARKER {
166 current
167 .get_or_insert_with(UpdateFileChunk::default)
168 .is_end_of_file = true;
169 } else {
170 let c = current.get_or_insert_with(UpdateFileChunk::default);
171 match l.chars().next() {
172 Some('+') => c.new_lines.push(l[1..].to_string()),
173 Some('-') => c.old_lines.push(l[1..].to_string()),
174 Some(' ') => {
175 let s = l[1..].to_string();
176 c.old_lines.push(s.clone());
177 c.new_lines.push(s);
178 },
179 None => {
180 c.old_lines.push(String::new());
181 c.new_lines.push(String::new());
182 },
183 _ => {
184 return Err(ParseError::InvalidHunk {
185 message: format!("unexpected line in an update hunk: {l:?}"),
186 line_number: *i,
187 });
188 },
189 }
190 }
191 *i += 1;
192 }
193 if let Some(c) = current.take() {
194 chunks.push(c);
195 }
196 if chunks.is_empty() {
197 return Err(ParseError::InvalidHunk {
198 message: "update hunk has no changes".to_string(),
199 line_number: *i,
200 });
201 }
202 Ok(chunks)
203}
204
205struct SeekHit {
209 index: usize,
210 exact: bool,
211}
212
213fn seek_sequence(lines: &[String], pattern: &[String], start: usize, eof: bool) -> Option<SeekHit> {
217 if pattern.is_empty() {
218 return Some(SeekHit {
219 index: start,
220 exact: true,
221 });
222 }
223 if pattern.len() > lines.len() {
224 return None;
225 }
226 let search_start = if eof {
227 lines.len() - pattern.len()
228 } else {
229 start
230 };
231 let last = lines.len().saturating_sub(pattern.len());
232
233 for i in search_start..=last {
234 if lines[i..i + pattern.len()] == *pattern {
235 return Some(SeekHit {
236 index: i,
237 exact: true,
238 });
239 }
240 }
241 for i in search_start..=last {
242 if pattern
243 .iter()
244 .enumerate()
245 .all(|(p, pat)| lines[i + p].trim_end() == pat.trim_end())
246 {
247 return Some(SeekHit {
248 index: i,
249 exact: false,
250 });
251 }
252 }
253 for i in search_start..=last {
254 if pattern
255 .iter()
256 .enumerate()
257 .all(|(p, pat)| lines[i + p].trim() == pat.trim())
258 {
259 return Some(SeekHit {
260 index: i,
261 exact: false,
262 });
263 }
264 }
265 for i in search_start..=last {
266 if pattern
267 .iter()
268 .enumerate()
269 .all(|(p, pat)| normalise(&lines[i + p]) == normalise(pat))
270 {
271 return Some(SeekHit {
272 index: i,
273 exact: false,
274 });
275 }
276 }
277 None
278}
279
280fn normalise(s: &str) -> String {
282 s.trim()
283 .chars()
284 .map(|c| match c {
285 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
286 | '\u{2212}' => '-',
287 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
288 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
289 '\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
290 | '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
291 | '\u{3000}' => ' ',
292 other => other,
293 })
294 .collect()
295}
296
297#[derive(Debug, PartialEq, Eq)]
301pub enum ApplyError {
302 ContextNotFound(String),
304 LinesNotFound(String),
306}
307
308impl std::fmt::Display for ApplyError {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 match self {
311 ApplyError::ContextNotFound(c) => write!(f, "could not find context line '{c}'"),
312 ApplyError::LinesNotFound(l) => write!(f, "could not find the lines to replace:\n{l}"),
313 }
314 }
315}
316
317pub struct AppliedFile {
320 pub new_contents: String,
321 pub fuzzy: bool,
322}
323
324pub fn derive_new_contents(
326 original: &str,
327 chunks: &[UpdateFileChunk],
328) -> Result<AppliedFile, ApplyError> {
329 let mut lines: Vec<String> = original.split('\n').map(String::from).collect();
330 if lines.last().is_some_and(String::is_empty) {
331 lines.pop();
332 }
333 let (replacements, fuzzy) = compute_replacements(&lines, chunks)?;
334 let mut new_lines = apply_replacements(lines, &replacements);
335 if !new_lines.last().is_some_and(String::is_empty) {
336 new_lines.push(String::new());
337 }
338 Ok(AppliedFile {
339 new_contents: new_lines.join("\n"),
340 fuzzy,
341 })
342}
343
344type Replacement = (usize, usize, Vec<String>);
345
346fn compute_replacements(
347 original_lines: &[String],
348 chunks: &[UpdateFileChunk],
349) -> Result<(Vec<Replacement>, bool), ApplyError> {
350 let mut replacements: Vec<Replacement> = Vec::new();
351 let mut line_index = 0usize;
352 let mut fuzzy = false;
353
354 for chunk in chunks {
355 if let Some(ctx) = &chunk.change_context {
356 match seek_sequence(original_lines, std::slice::from_ref(ctx), line_index, false) {
357 Some(hit) => {
358 fuzzy |= !hit.exact;
359 line_index = hit.index + 1;
360 },
361 None => return Err(ApplyError::ContextNotFound(ctx.clone())),
362 }
363 }
364
365 if chunk.old_lines.is_empty() {
366 let idx = if original_lines.last().is_some_and(String::is_empty) {
367 original_lines.len() - 1
368 } else {
369 original_lines.len()
370 };
371 replacements.push((idx, 0, chunk.new_lines.clone()));
372 continue;
373 }
374
375 let mut pattern: &[String] = &chunk.old_lines;
376 let mut new_slice: &[String] = &chunk.new_lines;
377 let mut found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
378 if found.is_none() && pattern.last().is_some_and(String::is_empty) {
379 pattern = &pattern[..pattern.len() - 1];
380 if new_slice.last().is_some_and(String::is_empty) {
381 new_slice = &new_slice[..new_slice.len() - 1];
382 }
383 found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
384 }
385
386 match found {
387 Some(hit) => {
388 fuzzy |= !hit.exact;
389 replacements.push((hit.index, pattern.len(), new_slice.to_vec()));
390 line_index = hit.index + pattern.len();
391 },
392 None => return Err(ApplyError::LinesNotFound(chunk.old_lines.join("\n"))),
393 }
394 }
395
396 replacements.sort_by_key(|(i, _, _)| *i);
397 Ok((replacements, fuzzy))
398}
399
400fn apply_replacements(mut lines: Vec<String>, replacements: &[Replacement]) -> Vec<String> {
401 for (start_idx, old_len, new_segment) in replacements.iter().rev() {
402 let start_idx = *start_idx;
403 for _ in 0..*old_len {
404 if start_idx < lines.len() {
405 lines.remove(start_idx);
406 }
407 }
408 for (offset, new_line) in new_segment.iter().enumerate() {
409 lines.insert(start_idx + offset, new_line.clone());
410 }
411 }
412 lines
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 fn v(items: &[&str]) -> Vec<String> {
420 items.iter().map(|s| s.to_string()).collect()
421 }
422
423 fn chunk(ctx: Option<&str>, old: &[&str], new: &[&str], eof: bool) -> UpdateFileChunk {
424 UpdateFileChunk {
425 change_context: ctx.map(str::to_string),
426 old_lines: v(old),
427 new_lines: v(new),
428 is_end_of_file: eof,
429 }
430 }
431
432 #[test]
433 fn parses_update_add_delete_move() {
434 let patch = "*** Begin Patch\n*** Update File: src/main.rs\n@@ fn main()\n- old();\n+ new();\n*** End Patch\n";
435 assert_eq!(
436 parse_patch(patch).unwrap(),
437 vec![Hunk::UpdateFile {
438 path: PathBuf::from("src/main.rs"),
439 move_path: None,
440 chunks: vec![chunk(
441 Some("fn main()"),
442 &[" old();"],
443 &[" new();"],
444 false
445 )],
446 }]
447 );
448 assert_eq!(
449 parse_patch("*** Begin Patch\n*** Add File: a.txt\n+x\n+y\n*** End Patch").unwrap(),
450 vec![Hunk::AddFile {
451 path: PathBuf::from("a.txt"),
452 contents: "x\ny".to_string(),
453 }]
454 );
455 let mv =
456 "*** Begin Patch\n*** Update File: old.rs\n*** Move to: new.rs\n-a\n+b\n*** End Patch";
457 match &parse_patch(mv).unwrap()[0] {
458 Hunk::UpdateFile { move_path, .. } => {
459 assert_eq!(move_path.as_deref(), Some(std::path::Path::new("new.rs")))
460 },
461 other => panic!("expected UpdateFile, got {other:?}"),
462 }
463 }
464
465 #[test]
466 fn context_lines_go_to_both_sides_and_eof_flag() {
467 match &parse_patch("*** Begin Patch\n*** Update File: x\n import foo\n+bar\n*** End Patch")
468 .unwrap()[0]
469 {
470 Hunk::UpdateFile { chunks, .. } => {
471 assert_eq!(chunks[0].old_lines, v(&["import foo"]));
472 assert_eq!(chunks[0].new_lines, v(&["import foo", "bar"]));
473 },
474 other => panic!("expected UpdateFile, got {other:?}"),
475 }
476 match &parse_patch(
477 "*** Begin Patch\n*** Update File: x\n+quux\n*** End of File\n*** End Patch",
478 )
479 .unwrap()[0]
480 {
481 Hunk::UpdateFile { chunks, .. } => assert!(chunks[0].is_end_of_file),
482 other => panic!("expected UpdateFile, got {other:?}"),
483 }
484 }
485
486 #[test]
487 fn rejects_missing_markers_and_empty() {
488 assert!(parse_patch("no markers").is_err());
489 assert!(parse_patch("*** Begin Patch\n*** End Patch").is_err());
490 }
491
492 #[test]
493 fn seek_reports_exact_vs_fuzzy_and_eof() {
494 assert!(
495 seek_sequence(&v(&["foo", "bar"]), &v(&["bar"]), 0, false)
496 .unwrap()
497 .exact
498 );
499 let fuzzy = seek_sequence(&v(&["foo "]), &v(&["foo"]), 0, false).unwrap();
500 assert!(!fuzzy.exact);
501 assert!(seek_sequence(&v(&["only"]), &v(&["a", "b"]), 0, false).is_none());
502 assert_eq!(
503 seek_sequence(&v(&["a", "x", "b", "x"]), &v(&["x"]), 0, true)
504 .unwrap()
505 .index,
506 3
507 );
508 }
509
510 #[test]
511 fn applies_exact_fuzzy_anchor_and_eof() {
512 assert_eq!(
513 derive_new_contents("a\nold\nc\n", &[chunk(None, &["old"], &["new"], false)])
514 .unwrap()
515 .new_contents,
516 "a\nnew\nc\n"
517 );
518 let f = derive_new_contents("a\nold \nc\n", &[chunk(None, &["old"], &["new"], false)])
519 .unwrap();
520 assert_eq!(f.new_contents, "a\nnew\nc\n");
521 assert!(f.fuzzy);
522 let out = derive_new_contents(
524 "fn a() {\n x();\n}\nfn b() {\n x();\n}\n",
525 &[chunk(Some("fn b() {"), &[" x();"], &[" y();"], false)],
526 )
527 .unwrap();
528 assert_eq!(
529 out.new_contents,
530 "fn a() {\n x();\n}\nfn b() {\n y();\n}\n"
531 );
532 assert_eq!(
533 derive_new_contents("a\nb\n", &[chunk(None, &[], &["c"], true)])
534 .unwrap()
535 .new_contents,
536 "a\nb\nc\n"
537 );
538 }
539
540 #[test]
541 fn apply_errors_on_missing_context_or_lines() {
542 assert!(matches!(
543 derive_new_contents("a\n", &[chunk(Some("nope"), &["a"], &["b"], false)]),
544 Err(ApplyError::ContextNotFound(c)) if c == "nope"
545 ));
546 assert!(matches!(
547 derive_new_contents("a\n", &[chunk(None, &["zzz"], &["b"], false)]),
548 Err(ApplyError::LinesNotFound(_))
549 ));
550 }
551}