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