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