edit_action.rs

  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}