1use util::ResultExt;
2
3/// Represents an edit action to be performed on a file.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum EditAction {
6 /// Replace specific content in a file with new content
7 Replace {
8 file_path: String,
9 old: String,
10 new: String,
11 },
12 /// Write content to a file (create or overwrite)
13 Write { file_path: String, content: String },
14}
15
16impl EditAction {
17 pub fn file_path(&self) -> &str {
18 match self {
19 EditAction::Replace { file_path, .. } => file_path,
20 EditAction::Write { file_path, .. } => file_path,
21 }
22 }
23}
24
25/// Parses edit actions from an LLM response.
26/// See system.md for more details on the format.
27#[derive(Debug)]
28pub struct EditActionParser {
29 state: State,
30 pre_fence_line: Vec<u8>,
31 marker_ix: usize,
32 line: usize,
33 column: usize,
34 old_bytes: Vec<u8>,
35 new_bytes: Vec<u8>,
36 errors: Vec<ParseError>,
37}
38
39#[derive(Debug, PartialEq, Eq)]
40enum State {
41 /// Anywhere outside an action
42 Default,
43 /// After opening ```, in optional language tag
44 OpenFence,
45 /// In SEARCH marker
46 SearchMarker,
47 /// In search block or divider
48 SearchBlock,
49 /// In replace block or REPLACE marker
50 ReplaceBlock,
51 /// In closing ```
52 CloseFence,
53}
54
55impl EditActionParser {
56 /// Creates a new `EditActionParser`
57 pub fn new() -> Self {
58 Self {
59 state: State::Default,
60 pre_fence_line: Vec::new(),
61 marker_ix: 0,
62 line: 1,
63 column: 0,
64 old_bytes: Vec::new(),
65 new_bytes: Vec::new(),
66 errors: Vec::new(),
67 }
68 }
69
70 /// Processes a chunk of input text and returns any completed edit actions.
71 ///
72 /// This method can be called repeatedly with fragments of input. The parser
73 /// maintains its state between calls, allowing you to process streaming input
74 /// as it becomes available. Actions are only inserted once they are fully parsed.
75 ///
76 /// If a block fails to parse, it will simply be skipped and an error will be recorded.
77 /// All errors can be accessed through the `EditActionsParser::errors` method.
78 pub fn parse_chunk(&mut self, input: &str) -> Vec<EditAction> {
79 use State::*;
80
81 const FENCE: &[u8] = b"```";
82 const SEARCH_MARKER: &[u8] = b"<<<<<<< SEARCH";
83 const DIVIDER: &[u8] = b"=======";
84 const NL_DIVIDER: &[u8] = b"\n=======";
85 const REPLACE_MARKER: &[u8] = b">>>>>>> REPLACE";
86 const NL_REPLACE_MARKER: &[u8] = b"\n>>>>>>> REPLACE";
87
88 let mut actions = Vec::new();
89
90 for byte in input.bytes() {
91 // Update line and column tracking
92 if byte == b'\n' {
93 self.line += 1;
94 self.column = 0;
95 } else {
96 self.column += 1;
97 }
98
99 match &self.state {
100 Default => match match_marker(byte, FENCE, false, &mut self.marker_ix) {
101 MarkerMatch::Complete => {
102 self.to_state(OpenFence);
103 }
104 MarkerMatch::Partial => {}
105 MarkerMatch::None => {
106 if self.marker_ix > 0 {
107 self.marker_ix = 0;
108 } else if self.pre_fence_line.ends_with(b"\n") {
109 self.pre_fence_line.clear();
110 }
111
112 self.pre_fence_line.push(byte);
113 }
114 },
115 OpenFence => {
116 // skip language tag
117 if byte == b'\n' {
118 self.to_state(SearchMarker);
119 }
120 }
121 SearchMarker => {
122 if self.expect_marker(byte, SEARCH_MARKER, true) {
123 self.to_state(SearchBlock);
124 }
125 }
126 SearchBlock => {
127 if collect_until_marker(
128 byte,
129 DIVIDER,
130 NL_DIVIDER,
131 true,
132 &mut self.marker_ix,
133 &mut self.old_bytes,
134 ) {
135 self.to_state(ReplaceBlock);
136 }
137 }
138 ReplaceBlock => {
139 if collect_until_marker(
140 byte,
141 REPLACE_MARKER,
142 NL_REPLACE_MARKER,
143 true,
144 &mut self.marker_ix,
145 &mut self.new_bytes,
146 ) {
147 self.to_state(CloseFence);
148 }
149 }
150 CloseFence => {
151 if self.expect_marker(byte, FENCE, false) {
152 if let Some(action) = self.action() {
153 actions.push(action);
154 }
155 self.errors();
156 self.reset();
157 }
158 }
159 };
160 }
161
162 actions
163 }
164
165 /// Returns a reference to the errors encountered during parsing.
166 pub fn errors(&self) -> &[ParseError] {
167 &self.errors
168 }
169
170 fn action(&mut self) -> Option<EditAction> {
171 if self.old_bytes.is_empty() && self.new_bytes.is_empty() {
172 self.push_error(ParseErrorKind::NoOp);
173 return None;
174 }
175
176 let mut pre_fence_line = std::mem::take(&mut self.pre_fence_line);
177
178 if pre_fence_line.ends_with(b"\n") {
179 pre_fence_line.pop();
180 pop_carriage_return(&mut pre_fence_line);
181 }
182
183 let file_path = String::from_utf8(pre_fence_line).log_err()?;
184 let content = String::from_utf8(std::mem::take(&mut self.new_bytes)).log_err()?;
185
186 if self.old_bytes.is_empty() {
187 Some(EditAction::Write { file_path, content })
188 } else {
189 let old = String::from_utf8(std::mem::take(&mut self.old_bytes)).log_err()?;
190
191 Some(EditAction::Replace {
192 file_path,
193 old,
194 new: content,
195 })
196 }
197 }
198
199 fn expect_marker(&mut self, byte: u8, marker: &'static [u8], trailing_newline: bool) -> bool {
200 match match_marker(byte, marker, trailing_newline, &mut self.marker_ix) {
201 MarkerMatch::Complete => true,
202 MarkerMatch::Partial => false,
203 MarkerMatch::None => {
204 self.push_error(ParseErrorKind::ExpectedMarker {
205 expected: marker,
206 found: byte,
207 });
208 self.reset();
209 false
210 }
211 }
212 }
213
214 fn to_state(&mut self, state: State) {
215 self.state = state;
216 self.marker_ix = 0;
217 }
218
219 fn reset(&mut self) {
220 self.pre_fence_line.clear();
221 self.old_bytes.clear();
222 self.new_bytes.clear();
223 self.to_state(State::Default);
224 }
225
226 fn push_error(&mut self, kind: ParseErrorKind) {
227 self.errors.push(ParseError {
228 line: self.line,
229 column: self.column,
230 kind,
231 });
232 }
233}
234
235#[derive(Debug)]
236enum MarkerMatch {
237 None,
238 Partial,
239 Complete,
240}
241
242fn match_marker(
243 byte: u8,
244 marker: &[u8],
245 trailing_newline: bool,
246 marker_ix: &mut usize,
247) -> MarkerMatch {
248 if trailing_newline && *marker_ix >= marker.len() {
249 if byte == b'\n' {
250 MarkerMatch::Complete
251 } else if byte == b'\r' {
252 MarkerMatch::Partial
253 } else {
254 MarkerMatch::None
255 }
256 } else if byte == marker[*marker_ix] {
257 *marker_ix += 1;
258
259 if *marker_ix < marker.len() || trailing_newline {
260 MarkerMatch::Partial
261 } else {
262 MarkerMatch::Complete
263 }
264 } else {
265 MarkerMatch::None
266 }
267}
268
269fn collect_until_marker(
270 byte: u8,
271 marker: &[u8],
272 nl_marker: &[u8],
273 trailing_newline: bool,
274 marker_ix: &mut usize,
275 buf: &mut Vec<u8>,
276) -> bool {
277 let marker = if buf.is_empty() {
278 // do not require another newline if block is empty
279 marker
280 } else {
281 nl_marker
282 };
283
284 match match_marker(byte, marker, trailing_newline, marker_ix) {
285 MarkerMatch::Complete => {
286 pop_carriage_return(buf);
287 true
288 }
289 MarkerMatch::Partial => false,
290 MarkerMatch::None => {
291 if *marker_ix > 0 {
292 buf.extend_from_slice(&marker[..*marker_ix]);
293 *marker_ix = 0;
294
295 // The beginning of marker might match current byte
296 match match_marker(byte, marker, trailing_newline, marker_ix) {
297 MarkerMatch::Complete => return true,
298 MarkerMatch::Partial => return false,
299 MarkerMatch::None => { /* no match, keep collecting */ }
300 }
301 }
302
303 buf.push(byte);
304
305 false
306 }
307 }
308}
309
310fn pop_carriage_return(buf: &mut Vec<u8>) {
311 if buf.ends_with(b"\r") {
312 buf.pop();
313 }
314}
315
316#[derive(Debug, PartialEq, Eq)]
317pub struct ParseError {
318 line: usize,
319 column: usize,
320 kind: ParseErrorKind,
321}
322
323#[derive(Debug, PartialEq, Eq)]
324pub enum ParseErrorKind {
325 ExpectedMarker { expected: &'static [u8], found: u8 },
326 NoOp,
327}
328
329impl std::fmt::Display for ParseErrorKind {
330 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331 match self {
332 ParseErrorKind::ExpectedMarker { expected, found } => {
333 write!(
334 f,
335 "Expected marker {:?}, found {:?}",
336 String::from_utf8_lossy(expected),
337 *found as char
338 )
339 }
340 ParseErrorKind::NoOp => {
341 write!(f, "No search or replace")
342 }
343 }
344 }
345}
346
347impl std::fmt::Display for ParseError {
348 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349 write!(f, "input:{}:{}: {}", self.line, self.column, self.kind)
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use rand::prelude::*;
357
358 #[test]
359 fn test_simple_edit_action() {
360 let input = r#"src/main.rs
361```
362<<<<<<< SEARCH
363fn original() {}
364=======
365fn replacement() {}
366>>>>>>> REPLACE
367```
368"#;
369
370 let mut parser = EditActionParser::new();
371 let actions = parser.parse_chunk(input);
372
373 assert_eq!(actions.len(), 1);
374 assert_eq!(
375 actions[0],
376 EditAction::Replace {
377 file_path: "src/main.rs".to_string(),
378 old: "fn original() {}".to_string(),
379 new: "fn replacement() {}".to_string(),
380 }
381 );
382 assert_eq!(parser.errors().len(), 0);
383 }
384
385 #[test]
386 fn test_with_language_tag() {
387 let input = r#"src/main.rs
388```rust
389<<<<<<< SEARCH
390fn original() {}
391=======
392fn replacement() {}
393>>>>>>> REPLACE
394```
395"#;
396
397 let mut parser = EditActionParser::new();
398 let actions = parser.parse_chunk(input);
399
400 assert_eq!(actions.len(), 1);
401 assert_eq!(
402 actions[0],
403 EditAction::Replace {
404 file_path: "src/main.rs".to_string(),
405 old: "fn original() {}".to_string(),
406 new: "fn replacement() {}".to_string(),
407 }
408 );
409 assert_eq!(parser.errors().len(), 0);
410 }
411
412 #[test]
413 fn test_with_surrounding_text() {
414 let input = r#"Here's a modification I'd like to make to the file:
415
416src/main.rs
417```rust
418<<<<<<< SEARCH
419fn original() {}
420=======
421fn replacement() {}
422>>>>>>> REPLACE
423```
424
425This change makes the function better.
426"#;
427
428 let mut parser = EditActionParser::new();
429 let actions = parser.parse_chunk(input);
430
431 assert_eq!(actions.len(), 1);
432 assert_eq!(
433 actions[0],
434 EditAction::Replace {
435 file_path: "src/main.rs".to_string(),
436 old: "fn original() {}".to_string(),
437 new: "fn replacement() {}".to_string(),
438 }
439 );
440 assert_eq!(parser.errors().len(), 0);
441 }
442
443 #[test]
444 fn test_multiple_edit_actions() {
445 let input = r#"First change:
446src/main.rs
447```
448<<<<<<< SEARCH
449fn original() {}
450=======
451fn replacement() {}
452>>>>>>> REPLACE
453```
454
455Second change:
456src/utils.rs
457```rust
458<<<<<<< SEARCH
459fn old_util() -> bool { false }
460=======
461fn new_util() -> bool { true }
462>>>>>>> REPLACE
463```
464"#;
465
466 let mut parser = EditActionParser::new();
467 let actions = parser.parse_chunk(input);
468
469 assert_eq!(actions.len(), 2);
470 assert_eq!(
471 actions[0],
472 EditAction::Replace {
473 file_path: "src/main.rs".to_string(),
474 old: "fn original() {}".to_string(),
475 new: "fn replacement() {}".to_string(),
476 }
477 );
478 assert_eq!(
479 actions[1],
480 EditAction::Replace {
481 file_path: "src/utils.rs".to_string(),
482 old: "fn old_util() -> bool { false }".to_string(),
483 new: "fn new_util() -> bool { true }".to_string(),
484 }
485 );
486 assert_eq!(parser.errors().len(), 0);
487 }
488
489 #[test]
490 fn test_multiline() {
491 let input = r#"src/main.rs
492```rust
493<<<<<<< SEARCH
494fn original() {
495 println!("This is the original function");
496 let x = 42;
497 if x > 0 {
498 println!("Positive number");
499 }
500}
501=======
502fn replacement() {
503 println!("This is the replacement function");
504 let x = 100;
505 if x > 50 {
506 println!("Large number");
507 } else {
508 println!("Small number");
509 }
510}
511>>>>>>> REPLACE
512```
513"#;
514
515 let mut parser = EditActionParser::new();
516 let actions = parser.parse_chunk(input);
517
518 assert_eq!(actions.len(), 1);
519 assert_eq!(
520 actions[0],
521 EditAction::Replace {
522 file_path: "src/main.rs".to_string(),
523 old: "fn original() {\n println!(\"This is the original function\");\n let x = 42;\n if x > 0 {\n println!(\"Positive number\");\n }\n}".to_string(),
524 new: "fn replacement() {\n println!(\"This is the replacement function\");\n let x = 100;\n if x > 50 {\n println!(\"Large number\");\n } else {\n println!(\"Small number\");\n }\n}".to_string(),
525 }
526 );
527 assert_eq!(parser.errors().len(), 0);
528 }
529
530 #[test]
531 fn test_write_action() {
532 let input = r#"Create a new main.rs file:
533
534src/main.rs
535```rust
536<<<<<<< SEARCH
537=======
538fn new_function() {
539 println!("This function is being added");
540}
541>>>>>>> REPLACE
542```
543"#;
544
545 let mut parser = EditActionParser::new();
546 let actions = parser.parse_chunk(input);
547
548 assert_eq!(actions.len(), 1);
549 assert_eq!(
550 actions[0],
551 EditAction::Write {
552 file_path: "src/main.rs".to_string(),
553 content: "fn new_function() {\n println!(\"This function is being added\");\n}"
554 .to_string(),
555 }
556 );
557 assert_eq!(parser.errors().len(), 0);
558 }
559
560 #[test]
561 fn test_empty_replace() {
562 let input = r#"src/main.rs
563```rust
564<<<<<<< SEARCH
565fn this_will_be_deleted() {
566 println!("Deleting this function");
567}
568=======
569>>>>>>> REPLACE
570```
571"#;
572
573 let mut parser = EditActionParser::new();
574 let actions = parser.parse_chunk(&input);
575 assert_eq!(actions.len(), 1);
576 assert_eq!(
577 actions[0],
578 EditAction::Replace {
579 file_path: "src/main.rs".to_string(),
580 old: "fn this_will_be_deleted() {\n println!(\"Deleting this function\");\n}"
581 .to_string(),
582 new: "".to_string(),
583 }
584 );
585 assert_eq!(parser.errors().len(), 0);
586
587 let actions = parser.parse_chunk(&input.replace("\n", "\r\n"));
588 assert_eq!(actions.len(), 1);
589 assert_eq!(
590 actions[0],
591 EditAction::Replace {
592 file_path: "src/main.rs".to_string(),
593 old:
594 "fn this_will_be_deleted() {\r\n println!(\"Deleting this function\");\r\n}"
595 .to_string(),
596 new: "".to_string(),
597 }
598 );
599 assert_eq!(parser.errors().len(), 0);
600 }
601
602 #[test]
603 fn test_empty_both() {
604 let input = r#"src/main.rs
605```rust
606<<<<<<< SEARCH
607=======
608>>>>>>> REPLACE
609```
610"#;
611
612 let mut parser = EditActionParser::new();
613 let actions = parser.parse_chunk(input);
614
615 // Should not create an action when both sections are empty
616 assert_eq!(actions.len(), 0);
617
618 // Check that the NoOp error was added
619 assert_eq!(parser.errors().len(), 1);
620 match parser.errors()[0].kind {
621 ParseErrorKind::NoOp => {}
622 _ => panic!("Expected NoOp error"),
623 }
624 }
625
626 #[test]
627 fn test_resumability() {
628 let input_part1 = r#"src/main.rs
629```rust
630<<<<<<< SEARCH
631fn ori"#;
632
633 let input_part2 = r#"ginal() {}
634=======
635fn replacement() {}"#;
636
637 let input_part3 = r#"
638>>>>>>> REPLACE
639```
640"#;
641
642 let mut parser = EditActionParser::new();
643 let actions1 = parser.parse_chunk(input_part1);
644 assert_eq!(actions1.len(), 0);
645 assert_eq!(parser.errors().len(), 0);
646
647 let actions2 = parser.parse_chunk(input_part2);
648 // No actions should be complete yet
649 assert_eq!(actions2.len(), 0);
650 assert_eq!(parser.errors().len(), 0);
651
652 let actions3 = parser.parse_chunk(input_part3);
653 // The third chunk should complete the action
654 assert_eq!(actions3.len(), 1);
655 assert_eq!(
656 actions3[0],
657 EditAction::Replace {
658 file_path: "src/main.rs".to_string(),
659 old: "fn original() {}".to_string(),
660 new: "fn replacement() {}".to_string(),
661 }
662 );
663 assert_eq!(parser.errors().len(), 0);
664 }
665
666 #[test]
667 fn test_parser_state_preservation() {
668 let mut parser = EditActionParser::new();
669 let actions1 = parser.parse_chunk("src/main.rs\n```rust\n<<<<<<< SEARCH\n");
670
671 // Check parser is in the correct state
672 assert_eq!(parser.state, State::SearchBlock);
673 assert_eq!(parser.pre_fence_line, b"src/main.rs\n");
674 assert_eq!(parser.errors().len(), 0);
675
676 // Continue parsing
677 let actions2 = parser.parse_chunk("original code\n=======\n");
678 assert_eq!(parser.state, State::ReplaceBlock);
679 assert_eq!(parser.old_bytes, b"original code");
680 assert_eq!(parser.errors().len(), 0);
681
682 let actions3 = parser.parse_chunk("replacement code\n>>>>>>> REPLACE\n```\n");
683
684 // After complete parsing, state should reset
685 assert_eq!(parser.state, State::Default);
686 assert_eq!(parser.pre_fence_line, b"\n");
687 assert!(parser.old_bytes.is_empty());
688 assert!(parser.new_bytes.is_empty());
689
690 assert_eq!(actions1.len(), 0);
691 assert_eq!(actions2.len(), 0);
692 assert_eq!(actions3.len(), 1);
693 assert_eq!(parser.errors().len(), 0);
694 }
695
696 #[test]
697 fn test_invalid_search_marker() {
698 let input = r#"src/main.rs
699```rust
700<<<<<<< WRONG_MARKER
701fn original() {}
702=======
703fn replacement() {}
704>>>>>>> REPLACE
705```
706"#;
707
708 let mut parser = EditActionParser::new();
709 let actions = parser.parse_chunk(input);
710 assert_eq!(actions.len(), 0);
711
712 assert_eq!(parser.errors().len(), 1);
713 let error = &parser.errors()[0];
714
715 assert_eq!(
716 error.to_string(),
717 "input:3:9: Expected marker \"<<<<<<< SEARCH\", found 'W'"
718 );
719 }
720
721 #[test]
722 fn test_missing_closing_fence() {
723 let input = r#"src/main.rs
724```rust
725<<<<<<< SEARCH
726fn original() {}
727=======
728fn replacement() {}
729>>>>>>> REPLACE
730<!-- Missing closing fence -->
731
732src/utils.rs
733```rust
734<<<<<<< SEARCH
735fn utils_func() {}
736=======
737fn new_utils_func() {}
738>>>>>>> REPLACE
739```
740"#;
741
742 let mut parser = EditActionParser::new();
743 let actions = parser.parse_chunk(input);
744
745 // Only the second block should be parsed
746 assert_eq!(actions.len(), 1);
747 assert_eq!(
748 actions[0],
749 EditAction::Replace {
750 file_path: "src/utils.rs".to_string(),
751 old: "fn utils_func() {}".to_string(),
752 new: "fn new_utils_func() {}".to_string(),
753 }
754 );
755 assert_eq!(parser.errors().len(), 1);
756 assert_eq!(
757 parser.errors()[0].to_string(),
758 "input:8:1: Expected marker \"```\", found '<'".to_string()
759 );
760
761 // The parser should continue after an error
762 assert_eq!(parser.state, State::Default);
763 }
764
765 const SYSTEM_PROMPT: &str = include_str!("./edit_prompt.md");
766
767 #[test]
768 fn test_parse_examples_in_system_prompt() {
769 let mut parser = EditActionParser::new();
770 let actions = parser.parse_chunk(SYSTEM_PROMPT);
771 assert_examples_in_system_prompt(&actions, parser.errors());
772 }
773
774 #[gpui::test(iterations = 10)]
775 fn test_random_chunking_of_system_prompt(mut rng: StdRng) {
776 let mut parser = EditActionParser::new();
777 let mut remaining = SYSTEM_PROMPT;
778 let mut actions = Vec::with_capacity(5);
779
780 while !remaining.is_empty() {
781 let chunk_size = rng.gen_range(1..=std::cmp::min(remaining.len(), 100));
782
783 let (chunk, rest) = remaining.split_at(chunk_size);
784
785 actions.extend(parser.parse_chunk(chunk));
786 remaining = rest;
787 }
788
789 assert_examples_in_system_prompt(&actions, parser.errors());
790 }
791
792 fn assert_examples_in_system_prompt(actions: &[EditAction], errors: &[ParseError]) {
793 assert_eq!(actions.len(), 5);
794
795 assert_eq!(
796 actions[0],
797 EditAction::Replace {
798 file_path: "mathweb/flask/app.py".to_string(),
799 old: "from flask import Flask".to_string(),
800 new: "import math\nfrom flask import Flask".to_string(),
801 }
802 );
803
804 assert_eq!(
805 actions[1],
806 EditAction::Replace {
807 file_path: "mathweb/flask/app.py".to_string(),
808 old: "def factorial(n):\n \"compute factorial\"\n\n if n == 0:\n return 1\n else:\n return n * factorial(n-1)\n".to_string(),
809 new: "".to_string(),
810 }
811 );
812
813 assert_eq!(
814 actions[2],
815 EditAction::Replace {
816 file_path: "mathweb/flask/app.py".to_string(),
817 old: " return str(factorial(n))".to_string(),
818 new: " return str(math.factorial(n))".to_string(),
819 }
820 );
821
822 assert_eq!(
823 actions[3],
824 EditAction::Write {
825 file_path: "hello.py".to_string(),
826 content: "def hello():\n \"print a greeting\"\n\n print(\"hello\")"
827 .to_string(),
828 }
829 );
830
831 assert_eq!(
832 actions[4],
833 EditAction::Replace {
834 file_path: "main.py".to_string(),
835 old: "def hello():\n \"print a greeting\"\n\n print(\"hello\")".to_string(),
836 new: "from hello import hello".to_string(),
837 }
838 );
839
840 // The system prompt includes some text that would produce errors
841 assert_eq!(
842 errors[0].to_string(),
843 "input:102:1: Expected marker \"<<<<<<< SEARCH\", found '3'"
844 );
845 assert_eq!(
846 errors[1].to_string(),
847 "input:109:0: Expected marker \"<<<<<<< SEARCH\", found '\\n'"
848 );
849 }
850
851 #[test]
852 fn test_print_error() {
853 let input = r#"src/main.rs
854```rust
855<<<<<<< WRONG_MARKER
856fn original() {}
857=======
858fn replacement() {}
859>>>>>>> REPLACE
860```
861"#;
862
863 let mut parser = EditActionParser::new();
864 parser.parse_chunk(input);
865
866 assert_eq!(parser.errors().len(), 1);
867 let error = &parser.errors()[0];
868 let expected_error = r#"input:3:9: Expected marker "<<<<<<< SEARCH", found 'W'"#;
869
870 assert_eq!(format!("{}", error), expected_error);
871 }
872}