edit_action.rs

  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}