1use crate::ApplyPatchArgs;
26use std::path::Path;
27use std::path::PathBuf;
28
29use thiserror::Error;
30
31const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
32const END_PATCH_MARKER: &str = "*** End Patch";
33const ADD_FILE_MARKER: &str = "*** Add File: ";
34const DELETE_FILE_MARKER: &str = "*** Delete File: ";
35const UPDATE_FILE_MARKER: &str = "*** Update File: ";
36const MOVE_TO_MARKER: &str = "*** Move to: ";
37const EOF_MARKER: &str = "*** End of File";
38const CHANGE_CONTEXT_MARKER: &str = "@@ ";
39const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
40
41const PARSE_IN_STRICT_MODE: bool = false;
48
49#[derive(Debug, PartialEq, Error, Clone)]
50pub enum ParseError {
51 #[error("invalid patch: {0}")]
52 InvalidPatchError(String),
53 #[error("invalid hunk at line {line_number}, {message}")]
54 InvalidHunkError { message: String, line_number: usize },
55}
56use ParseError::*;
57
58#[derive(Debug, PartialEq, Clone)]
59#[allow(clippy::enum_variant_names)]
60pub enum Hunk {
61 AddFile {
62 path: PathBuf,
63 contents: String,
64 },
65 DeleteFile {
66 path: PathBuf,
67 },
68 UpdateFile {
69 path: PathBuf,
70 move_path: Option<PathBuf>,
71
72 chunks: Vec<UpdateFileChunk>,
75 },
76}
77
78impl Hunk {
79 pub fn resolve_path(&self, cwd: &Path) -> PathBuf {
80 match self {
81 Hunk::AddFile { path, .. } => cwd.join(path),
82 Hunk::DeleteFile { path } => cwd.join(path),
83 Hunk::UpdateFile { path, .. } => cwd.join(path),
84 }
85 }
86}
87
88use Hunk::*;
89
90#[derive(Debug, PartialEq, Clone)]
91pub struct UpdateFileChunk {
92 pub change_context: Option<String>,
95
96 pub old_lines: Vec<String>,
99 pub new_lines: Vec<String>,
100
101 pub is_end_of_file: bool,
104}
105
106pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
107 let mode = if PARSE_IN_STRICT_MODE {
108 ParseMode::Strict
109 } else {
110 ParseMode::Lenient
111 };
112 parse_patch_text(patch, mode)
113}
114
115enum ParseMode {
116 Strict,
118
119 Lenient,
152}
153
154fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
155 let lines: Vec<&str> = patch.trim().lines().collect();
156 let lines: &[&str] = match check_patch_boundaries_strict(&lines) {
157 Ok(()) => &lines,
158 Err(e) => match mode {
159 ParseMode::Strict => {
160 return Err(e);
161 }
162 ParseMode::Lenient => check_patch_boundaries_lenient(&lines, e)?,
163 },
164 };
165
166 let mut hunks: Vec<Hunk> = Vec::new();
167 let last_line_index = lines.len().saturating_sub(1);
169 let mut remaining_lines = &lines[1..last_line_index];
170 let mut line_number = 2;
171 while !remaining_lines.is_empty() {
172 let (hunk, hunk_lines) = parse_one_hunk(remaining_lines, line_number)?;
173 hunks.push(hunk);
174 line_number += hunk_lines;
175 remaining_lines = &remaining_lines[hunk_lines..]
176 }
177 let patch = lines.join("\n");
178 Ok(ApplyPatchArgs {
179 hunks,
180 patch,
181 workdir: None,
182 })
183}
184
185fn check_patch_boundaries_strict(lines: &[&str]) -> Result<(), ParseError> {
188 let (first_line, last_line) = match lines {
189 [] => (None, None),
190 [first] => (Some(first), Some(first)),
191 [first, .., last] => (Some(first), Some(last)),
192 };
193 check_start_and_end_lines_strict(first_line, last_line)
194}
195
196fn check_patch_boundaries_lenient<'a>(
204 original_lines: &'a [&'a str],
205 original_parse_error: ParseError,
206) -> Result<&'a [&'a str], ParseError> {
207 match original_lines {
208 [first, .., last] => {
209 if (first == &"<<EOF" || first == &"<<'EOF'" || first == &"<<\"EOF\"")
210 && last.ends_with("EOF")
211 && original_lines.len() >= 4
212 {
213 let inner_lines = &original_lines[1..original_lines.len() - 1];
214 match check_patch_boundaries_strict(inner_lines) {
215 Ok(()) => Ok(inner_lines),
216 Err(e) => Err(e),
217 }
218 } else {
219 Err(original_parse_error)
220 }
221 }
222 _ => Err(original_parse_error),
223 }
224}
225
226fn check_start_and_end_lines_strict(
227 first_line: Option<&&str>,
228 last_line: Option<&&str>,
229) -> Result<(), ParseError> {
230 match (first_line, last_line) {
231 (Some(&first), Some(&last)) if first == BEGIN_PATCH_MARKER && last == END_PATCH_MARKER => {
232 Ok(())
233 }
234 (Some(&first), _) if first != BEGIN_PATCH_MARKER => Err(InvalidPatchError(String::from(
235 "The first line of the patch must be '*** Begin Patch'",
236 ))),
237 _ => Err(InvalidPatchError(String::from(
238 "The last line of the patch must be '*** End Patch'",
239 ))),
240 }
241}
242
243fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), ParseError> {
246 let first_line = lines[0].trim();
248 if let Some(path) = first_line.strip_prefix(ADD_FILE_MARKER) {
249 let mut contents = String::new();
251 let mut parsed_lines = 1;
252 for add_line in &lines[1..] {
253 if let Some(line_to_add) = add_line.strip_prefix('+') {
254 contents.push_str(line_to_add);
255 contents.push('\n');
256 parsed_lines += 1;
257 } else {
258 break;
259 }
260 }
261 return Ok((
262 AddFile {
263 path: PathBuf::from(path),
264 contents,
265 },
266 parsed_lines,
267 ));
268 } else if let Some(path) = first_line.strip_prefix(DELETE_FILE_MARKER) {
269 return Ok((
271 DeleteFile {
272 path: PathBuf::from(path),
273 },
274 1,
275 ));
276 } else if let Some(path) = first_line.strip_prefix(UPDATE_FILE_MARKER) {
277 let mut remaining_lines = &lines[1..];
279 let mut parsed_lines = 1;
280
281 let move_path = remaining_lines
283 .first()
284 .and_then(|x| x.strip_prefix(MOVE_TO_MARKER));
285
286 if move_path.is_some() {
287 remaining_lines = &remaining_lines[1..];
288 parsed_lines += 1;
289 }
290
291 let mut chunks = Vec::new();
292 while !remaining_lines.is_empty() {
294 if remaining_lines[0].trim().is_empty() {
296 parsed_lines += 1;
297 remaining_lines = &remaining_lines[1..];
298 continue;
299 }
300
301 if remaining_lines[0].starts_with("***") {
302 break;
303 }
304
305 let (chunk, chunk_lines) = parse_update_file_chunk(
306 remaining_lines,
307 line_number + parsed_lines,
308 chunks.is_empty(),
309 )?;
310 chunks.push(chunk);
311 parsed_lines += chunk_lines;
312 remaining_lines = &remaining_lines[chunk_lines..]
313 }
314
315 if chunks.is_empty() {
316 return Err(InvalidHunkError {
317 message: format!("Update file hunk for path '{path}' is empty"),
318 line_number,
319 });
320 }
321
322 return Ok((
323 UpdateFile {
324 path: PathBuf::from(path),
325 move_path: move_path.map(PathBuf::from),
326 chunks,
327 },
328 parsed_lines,
329 ));
330 }
331
332 Err(InvalidHunkError {
333 message: format!(
334 "'{first_line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
335 ),
336 line_number,
337 })
338}
339
340fn parse_update_file_chunk(
341 lines: &[&str],
342 line_number: usize,
343 allow_missing_context: bool,
344) -> Result<(UpdateFileChunk, usize), ParseError> {
345 if lines.is_empty() {
346 return Err(InvalidHunkError {
347 message: "Update hunk does not contain any lines".to_string(),
348 line_number,
349 });
350 }
351 let (change_context, start_index) = if lines[0] == EMPTY_CHANGE_CONTEXT_MARKER {
354 (None, 1)
355 } else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
356 (Some(context.to_string()), 1)
357 } else {
358 if !allow_missing_context {
359 return Err(InvalidHunkError {
360 message: format!(
361 "Expected update hunk to start with a @@ context marker, got: '{}'",
362 lines[0]
363 ),
364 line_number,
365 });
366 }
367 (None, 0)
368 };
369 if start_index >= lines.len() {
370 return Err(InvalidHunkError {
371 message: "Update hunk does not contain any lines".to_string(),
372 line_number: line_number + 1,
373 });
374 }
375 let mut chunk = UpdateFileChunk {
376 change_context,
377 old_lines: Vec::new(),
378 new_lines: Vec::new(),
379 is_end_of_file: false,
380 };
381 let mut parsed_lines = 0;
382 for line in &lines[start_index..] {
383 match *line {
384 EOF_MARKER => {
385 if parsed_lines == 0 {
386 return Err(InvalidHunkError {
387 message: "Update hunk does not contain any lines".to_string(),
388 line_number: line_number + 1,
389 });
390 }
391 chunk.is_end_of_file = true;
392 parsed_lines += 1;
393 break;
394 }
395 line_contents => {
396 match line_contents.chars().next() {
397 None => {
398 chunk.old_lines.push(String::new());
400 chunk.new_lines.push(String::new());
401 }
402 Some(' ') => {
403 chunk.old_lines.push(line_contents[1..].to_string());
404 chunk.new_lines.push(line_contents[1..].to_string());
405 }
406 Some('+') => {
407 chunk.new_lines.push(line_contents[1..].to_string());
408 }
409 Some('-') => {
410 chunk.old_lines.push(line_contents[1..].to_string());
411 }
412 _ => {
413 if parsed_lines == 0 {
414 return Err(InvalidHunkError {
415 message: format!(
416 "Unexpected line found in update hunk: '{line_contents}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
417 ),
418 line_number: line_number + 1,
419 });
420 }
421 break;
423 }
424 }
425 parsed_lines += 1;
426 }
427 }
428 }
429
430 Ok((chunk, parsed_lines + start_index))
431}
432
433#[test]
434fn test_parse_patch() {
435 assert_eq!(
436 parse_patch_text("bad", ParseMode::Strict),
437 Err(InvalidPatchError(
438 "The first line of the patch must be '*** Begin Patch'".to_string()
439 ))
440 );
441 assert_eq!(
442 parse_patch_text("*** Begin Patch\nbad", ParseMode::Strict),
443 Err(InvalidPatchError(
444 "The last line of the patch must be '*** End Patch'".to_string()
445 ))
446 );
447 assert_eq!(
448 parse_patch_text(
449 "*** Begin Patch\n\
450 *** Update File: test.py\n\
451 *** End Patch",
452 ParseMode::Strict
453 ),
454 Err(InvalidHunkError {
455 message: "Update file hunk for path 'test.py' is empty".to_string(),
456 line_number: 2,
457 })
458 );
459 assert_eq!(
460 parse_patch_text(
461 "*** Begin Patch\n\
462 *** End Patch",
463 ParseMode::Strict
464 )
465 .unwrap()
466 .hunks,
467 Vec::new()
468 );
469 assert_eq!(
470 parse_patch_text(
471 "*** Begin Patch\n\
472 *** Add File: path/add.py\n\
473 +abc\n\
474 +def\n\
475 *** Delete File: path/delete.py\n\
476 *** Update File: path/update.py\n\
477 *** Move to: path/update2.py\n\
478 @@ def f():\n\
479 - pass\n\
480 + return 123\n\
481 *** End Patch",
482 ParseMode::Strict
483 )
484 .unwrap()
485 .hunks,
486 vec![
487 AddFile {
488 path: PathBuf::from("path/add.py"),
489 contents: "abc\ndef\n".to_string()
490 },
491 DeleteFile {
492 path: PathBuf::from("path/delete.py")
493 },
494 UpdateFile {
495 path: PathBuf::from("path/update.py"),
496 move_path: Some(PathBuf::from("path/update2.py")),
497 chunks: vec![UpdateFileChunk {
498 change_context: Some("def f():".to_string()),
499 old_lines: vec![" pass".to_string()],
500 new_lines: vec![" return 123".to_string()],
501 is_end_of_file: false
502 }]
503 }
504 ]
505 );
506 assert_eq!(
508 parse_patch_text(
509 "*** Begin Patch\n\
510 *** Update File: file.py\n\
511 @@\n\
512 +line\n\
513 *** Add File: other.py\n\
514 +content\n\
515 *** End Patch",
516 ParseMode::Strict
517 )
518 .unwrap()
519 .hunks,
520 vec![
521 UpdateFile {
522 path: PathBuf::from("file.py"),
523 move_path: None,
524 chunks: vec![UpdateFileChunk {
525 change_context: None,
526 old_lines: vec![],
527 new_lines: vec!["line".to_string()],
528 is_end_of_file: false
529 }],
530 },
531 AddFile {
532 path: PathBuf::from("other.py"),
533 contents: "content\n".to_string()
534 }
535 ]
536 );
537
538 assert_eq!(
541 parse_patch_text(
542 r#"*** Begin Patch
543*** Update File: file2.py
544 import foo
545+bar
546*** End Patch"#,
547 ParseMode::Strict
548 )
549 .unwrap()
550 .hunks,
551 vec![UpdateFile {
552 path: PathBuf::from("file2.py"),
553 move_path: None,
554 chunks: vec![UpdateFileChunk {
555 change_context: None,
556 old_lines: vec!["import foo".to_string()],
557 new_lines: vec!["import foo".to_string(), "bar".to_string()],
558 is_end_of_file: false,
559 }],
560 }]
561 );
562}
563
564#[test]
565fn test_parse_patch_lenient() {
566 let patch_text = r#"*** Begin Patch
567*** Update File: file2.py
568 import foo
569+bar
570*** End Patch"#;
571 let expected_patch = vec![UpdateFile {
572 path: PathBuf::from("file2.py"),
573 move_path: None,
574 chunks: vec![UpdateFileChunk {
575 change_context: None,
576 old_lines: vec!["import foo".to_string()],
577 new_lines: vec!["import foo".to_string(), "bar".to_string()],
578 is_end_of_file: false,
579 }],
580 }];
581 let expected_error =
582 InvalidPatchError("The first line of the patch must be '*** Begin Patch'".to_string());
583
584 let patch_text_in_heredoc = format!("<<EOF\n{patch_text}\nEOF\n");
585 assert_eq!(
586 parse_patch_text(&patch_text_in_heredoc, ParseMode::Strict),
587 Err(expected_error.clone())
588 );
589 assert_eq!(
590 parse_patch_text(&patch_text_in_heredoc, ParseMode::Lenient),
591 Ok(ApplyPatchArgs {
592 hunks: expected_patch.clone(),
593 patch: patch_text.to_string(),
594 workdir: None,
595 })
596 );
597
598 let patch_text_in_single_quoted_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n");
599 assert_eq!(
600 parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Strict),
601 Err(expected_error.clone())
602 );
603 assert_eq!(
604 parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Lenient),
605 Ok(ApplyPatchArgs {
606 hunks: expected_patch.clone(),
607 patch: patch_text.to_string(),
608 workdir: None,
609 })
610 );
611
612 let patch_text_in_double_quoted_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n");
613 assert_eq!(
614 parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Strict),
615 Err(expected_error.clone())
616 );
617 assert_eq!(
618 parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Lenient),
619 Ok(ApplyPatchArgs {
620 hunks: expected_patch,
621 patch: patch_text.to_string(),
622 workdir: None,
623 })
624 );
625
626 let patch_text_in_mismatched_quotes_heredoc = format!("<<\"EOF'\n{patch_text}\nEOF\n");
627 assert_eq!(
628 parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Strict),
629 Err(expected_error.clone())
630 );
631 assert_eq!(
632 parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Lenient),
633 Err(expected_error.clone())
634 );
635
636 let patch_text_with_missing_closing_heredoc =
637 "<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string();
638 assert_eq!(
639 parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Strict),
640 Err(expected_error)
641 );
642 assert_eq!(
643 parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Lenient),
644 Err(InvalidPatchError(
645 "The last line of the patch must be '*** End Patch'".to_string()
646 ))
647 );
648}
649
650#[test]
651fn test_parse_one_hunk() {
652 assert_eq!(
653 parse_one_hunk(&["bad"], 234),
654 Err(InvalidHunkError {
655 message: "'bad' is not a valid hunk header. \
656 Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'".to_string(),
657 line_number: 234
658 })
659 );
660 }
662
663#[test]
664fn test_update_file_chunk() {
665 assert_eq!(
666 parse_update_file_chunk(&["bad"], 123, false),
667 Err(InvalidHunkError {
668 message: "Expected update hunk to start with a @@ context marker, got: 'bad'"
669 .to_string(),
670 line_number: 123
671 })
672 );
673 assert_eq!(
674 parse_update_file_chunk(&["@@"], 123, false),
675 Err(InvalidHunkError {
676 message: "Update hunk does not contain any lines".to_string(),
677 line_number: 124
678 })
679 );
680 assert_eq!(
681 parse_update_file_chunk(&["@@", "bad"], 123, false),
682 Err(InvalidHunkError {
683 message: "Unexpected line found in update hunk: 'bad'. \
684 Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)".to_string(),
685 line_number: 124
686 })
687 );
688 assert_eq!(
689 parse_update_file_chunk(&["@@", "*** End of File"], 123, false),
690 Err(InvalidHunkError {
691 message: "Update hunk does not contain any lines".to_string(),
692 line_number: 124
693 })
694 );
695 assert_eq!(
696 parse_update_file_chunk(
697 &[
698 "@@ change_context",
699 "",
700 " context",
701 "-remove",
702 "+add",
703 " context2",
704 "*** End Patch",
705 ],
706 123,
707 false
708 ),
709 Ok((
710 (UpdateFileChunk {
711 change_context: Some("change_context".to_string()),
712 old_lines: vec![
713 "".to_string(),
714 "context".to_string(),
715 "remove".to_string(),
716 "context2".to_string()
717 ],
718 new_lines: vec![
719 "".to_string(),
720 "context".to_string(),
721 "add".to_string(),
722 "context2".to_string()
723 ],
724 is_end_of_file: false
725 }),
726 6
727 ))
728 );
729 assert_eq!(
730 parse_update_file_chunk(&["@@", "+line", "*** End of File"], 123, false),
731 Ok((
732 (UpdateFileChunk {
733 change_context: None,
734 old_lines: vec![],
735 new_lines: vec!["line".to_string()],
736 is_end_of_file: true
737 }),
738 3
739 ))
740 );
741}