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