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
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use rand::prelude::*;
349    use util::line_endings;
350
351    const WRONG_MARKER: &str = concat!(marker_sym!('<'), " WRONG_MARKER");
352
353    #[test]
354    fn test_simple_edit_action() {
355        // Construct test input using format with multiline string literals
356        let input = format!(
357            r#"src/main.rs
358```
359{}
360fn original() {{}}
361{}
362fn replacement() {{}}
363{}
364```
365"#,
366            SEARCH_MARKER, DIVIDER, REPLACE_MARKER
367        );
368
369        let mut parser = EditActionParser::new();
370        let actions = parser.parse_chunk(&input);
371
372        assert_no_errors(&parser);
373        assert_eq!(actions.len(), 1);
374        assert_eq!(
375            actions[0].0,
376            EditAction::Replace {
377                file_path: PathBuf::from("src/main.rs"),
378                old: "fn original() {}".to_string(),
379                new: "fn replacement() {}".to_string(),
380            }
381        );
382    }
383
384    #[test]
385    fn test_with_language_tag() {
386        // Construct test input using format with multiline string literals
387        let input = format!(
388            r#"src/main.rs
389```rust
390{}
391fn original() {{}}
392{}
393fn replacement() {{}}
394{}
395```
396"#,
397            SEARCH_MARKER, DIVIDER, REPLACE_MARKER
398        );
399
400        let mut parser = EditActionParser::new();
401        let actions = parser.parse_chunk(&input);
402
403        assert_no_errors(&parser);
404        assert_eq!(actions.len(), 1);
405        assert_eq!(
406            actions[0].0,
407            EditAction::Replace {
408                file_path: PathBuf::from("src/main.rs"),
409                old: "fn original() {}".to_string(),
410                new: "fn replacement() {}".to_string(),
411            }
412        );
413    }
414
415    #[test]
416    fn test_with_surrounding_text() {
417        // Construct test input using format with multiline string literals
418        let input = format!(
419            r#"Here's a modification I'd like to make to the file:
420
421src/main.rs
422```rust
423{}
424fn original() {{}}
425{}
426fn replacement() {{}}
427{}
428```
429
430This change makes the function better.
431"#,
432            SEARCH_MARKER, DIVIDER, REPLACE_MARKER
433        );
434
435        let mut parser = EditActionParser::new();
436        let actions = parser.parse_chunk(&input);
437
438        assert_no_errors(&parser);
439        assert_eq!(actions.len(), 1);
440        assert_eq!(
441            actions[0].0,
442            EditAction::Replace {
443                file_path: PathBuf::from("src/main.rs"),
444                old: "fn original() {}".to_string(),
445                new: "fn replacement() {}".to_string(),
446            }
447        );
448    }
449
450    #[test]
451    fn test_multiple_edit_actions() {
452        // Construct test input using format with multiline string literals
453        let input = format!(
454            r#"First change:
455src/main.rs
456```
457{}
458fn original() {{}}
459{}
460fn replacement() {{}}
461{}
462```
463
464Second change:
465src/utils.rs
466```rust
467{}
468fn old_util() -> bool {{ false }}
469{}
470fn new_util() -> bool {{ true }}
471{}
472```
473"#,
474            SEARCH_MARKER, DIVIDER, REPLACE_MARKER, SEARCH_MARKER, DIVIDER, REPLACE_MARKER
475        );
476
477        let mut parser = EditActionParser::new();
478        let actions = parser.parse_chunk(&input);
479
480        assert_no_errors(&parser);
481        assert_eq!(actions.len(), 2);
482
483        let (action, _) = &actions[0];
484        assert_eq!(
485            action,
486            &EditAction::Replace {
487                file_path: PathBuf::from("src/main.rs"),
488                old: "fn original() {}".to_string(),
489                new: "fn replacement() {}".to_string(),
490            }
491        );
492        let (action2, _) = &actions[1];
493        assert_eq!(
494            action2,
495            &EditAction::Replace {
496                file_path: PathBuf::from("src/utils.rs"),
497                old: "fn old_util() -> bool { false }".to_string(),
498                new: "fn new_util() -> bool { true }".to_string(),
499            }
500        );
501    }
502
503    #[test]
504    fn test_multiline() {
505        // Construct test input using format with multiline string literals
506        let input = format!(
507            r#"src/main.rs
508```rust
509{}
510fn original() {{
511    println!("This is the original function");
512    let x = 42;
513    if x > 0 {{
514        println!("Positive number");
515    }}
516}}
517{}
518fn replacement() {{
519    println!("This is the replacement function");
520    let x = 100;
521    if x > 50 {{
522        println!("Large number");
523    }} else {{
524        println!("Small number");
525    }}
526}}
527{}
528```
529"#,
530            SEARCH_MARKER, DIVIDER, REPLACE_MARKER
531        );
532
533        let mut parser = EditActionParser::new();
534        let actions = parser.parse_chunk(&input);
535
536        assert_no_errors(&parser);
537        assert_eq!(actions.len(), 1);
538
539        let (action, _) = &actions[0];
540        assert_eq!(
541            action,
542            &EditAction::Replace {
543                file_path: PathBuf::from("src/main.rs"),
544                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(),
545                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(),
546            }
547        );
548    }
549
550    #[test]
551    fn test_write_action() {
552        // Construct test input using format with multiline string literals
553        let input = format!(
554            r#"Create a new main.rs file:
555
556src/main.rs
557```rust
558{}
559{}
560fn new_function() {{
561    println!("This function is being added");
562}}
563{}
564```
565"#,
566            SEARCH_MARKER, DIVIDER, REPLACE_MARKER
567        );
568
569        let mut parser = EditActionParser::new();
570        let actions = parser.parse_chunk(&input);
571
572        assert_no_errors(&parser);
573        assert_eq!(actions.len(), 1);
574        assert_eq!(
575            actions[0].0,
576            EditAction::Write {
577                file_path: PathBuf::from("src/main.rs"),
578                content: "fn new_function() {\n    println!(\"This function is being added\");\n}"
579                    .to_string(),
580            }
581        );
582    }
583
584    #[test]
585    fn test_empty_replace() {
586        // Construct test input using format with multiline string literals
587        let input = format!(
588            r#"src/main.rs
589```rust
590{}
591fn this_will_be_deleted() {{
592    println!("Deleting this function");
593}}
594{}
595{}
596```
597"#,
598            SEARCH_MARKER, DIVIDER, REPLACE_MARKER
599        );
600
601        let mut parser = EditActionParser::new();
602        let actions = parser.parse_chunk(&input);
603
604        assert_no_errors(&parser);
605        assert_eq!(actions.len(), 1);
606        assert_eq!(
607            actions[0].0,
608            EditAction::Replace {
609                file_path: PathBuf::from("src/main.rs"),
610                old: "fn this_will_be_deleted() {\n    println!(\"Deleting this function\");\n}"
611                    .to_string(),
612                new: "".to_string(),
613            }
614        );
615
616        let mut parser = EditActionParser::new();
617        let actions = parser.parse_chunk(&input.replace("\n", "\r\n"));
618        assert_no_errors(&parser);
619        assert_eq!(actions.len(), 1);
620        assert_eq!(
621            actions[0].0,
622            EditAction::Replace {
623                file_path: PathBuf::from("src/main.rs"),
624                old:
625                    "fn this_will_be_deleted() {\r\n    println!(\"Deleting this function\");\r\n}"
626                        .to_string(),
627                new: "".to_string(),
628            }
629        );
630    }
631
632    #[test]
633    fn test_empty_both() {
634        // Construct test input using format with multiline string literals
635        let input = format!(
636            r#"src/main.rs
637```rust
638{}
639{}
640{}
641```
642"#,
643            SEARCH_MARKER, DIVIDER, REPLACE_MARKER
644        );
645
646        let mut parser = EditActionParser::new();
647        let actions = parser.parse_chunk(&input);
648
649        assert_eq!(actions.len(), 1);
650        assert_eq!(
651            actions[0].0,
652            EditAction::Write {
653                file_path: PathBuf::from("src/main.rs"),
654                content: String::new(),
655            }
656        );
657        assert_no_errors(&parser);
658    }
659
660    #[test]
661    fn test_resumability() {
662        // Construct test input using format with multiline string literals
663        let input_part1 = format!("src/main.rs\n```rust\n{}\nfn ori", SEARCH_MARKER);
664
665        let input_part2 = format!("ginal() {{}}\n{}\nfn replacement() {{}}", DIVIDER);
666
667        let input_part3 = format!("\n{}\n```\n", REPLACE_MARKER);
668
669        let mut parser = EditActionParser::new();
670        let actions1 = parser.parse_chunk(&input_part1);
671        assert_no_errors(&parser);
672        assert_eq!(actions1.len(), 0);
673
674        let actions2 = parser.parse_chunk(&input_part2);
675        // No actions should be complete yet
676        assert_no_errors(&parser);
677        assert_eq!(actions2.len(), 0);
678
679        let actions3 = parser.parse_chunk(&input_part3);
680        // The third chunk should complete the action
681        assert_no_errors(&parser);
682        assert_eq!(actions3.len(), 1);
683        let (action, _) = &actions3[0];
684        assert_eq!(
685            action,
686            &EditAction::Replace {
687                file_path: PathBuf::from("src/main.rs"),
688                old: "fn original() {}".to_string(),
689                new: "fn replacement() {}".to_string(),
690            }
691        );
692    }
693
694    #[test]
695    fn test_parser_state_preservation() {
696        let mut parser = EditActionParser::new();
697        let first_chunk = format!("src/main.rs\n```rust\n{}\n", SEARCH_MARKER);
698        let actions1 = parser.parse_chunk(&first_chunk);
699
700        // Check parser is in the correct state
701        assert_no_errors(&parser);
702        assert_eq!(parser.state, State::SearchBlock);
703        assert_eq!(parser.action_source, first_chunk.as_bytes());
704
705        // Continue parsing
706        let second_chunk = format!("original code\n{}\n", DIVIDER);
707        let actions2 = parser.parse_chunk(&second_chunk);
708
709        assert_no_errors(&parser);
710        assert_eq!(parser.state, State::ReplaceBlock);
711        assert_eq!(
712            &parser.action_source[parser.old_range.clone()],
713            b"original code"
714        );
715
716        let third_chunk = format!("replacement code\n{}\n```\n", REPLACE_MARKER);
717        let actions3 = parser.parse_chunk(&third_chunk);
718
719        // After complete parsing, state should reset
720        assert_no_errors(&parser);
721        assert_eq!(parser.state, State::Default);
722        assert_eq!(parser.action_source, b"\n");
723        assert!(parser.old_range.is_empty());
724        assert!(parser.new_range.is_empty());
725
726        assert_eq!(actions1.len(), 0);
727        assert_eq!(actions2.len(), 0);
728        assert_eq!(actions3.len(), 1);
729    }
730
731    #[test]
732    fn test_invalid_search_marker() {
733        let input = format!(
734            r#"src/main.rs
735```rust
736{}
737fn original() {{}}
738{}
739fn replacement() {{}}
740{}
741```
742"#,
743            WRONG_MARKER, DIVIDER, REPLACE_MARKER
744        );
745
746        let mut parser = EditActionParser::new();
747        let actions = parser.parse_chunk(&input);
748        assert_eq!(actions.len(), 0);
749
750        assert_eq!(parser.errors().len(), 1);
751        let error = &parser.errors()[0];
752
753        assert_eq!(
754            error.to_string(),
755            format!(
756                "input:3:9: Expected marker \"{}\", found 'W'",
757                SEARCH_MARKER
758            )
759        );
760    }
761
762    #[test]
763    fn test_missing_closing_fence() {
764        // Construct test input using format with multiline string literals
765        let input = format!(
766            r#"src/main.rs
767```rust
768{}
769fn original() {{}}
770{}
771fn replacement() {{}}
772{}
773<!-- Missing closing fence -->
774
775src/utils.rs
776```rust
777{}
778fn utils_func() {{}}
779{}
780fn new_utils_func() {{}}
781{}
782```
783"#,
784            SEARCH_MARKER, DIVIDER, REPLACE_MARKER, SEARCH_MARKER, DIVIDER, REPLACE_MARKER
785        );
786
787        let mut parser = EditActionParser::new();
788        let actions = parser.parse_chunk(&input);
789
790        // Only the second block should be parsed
791        assert_eq!(actions.len(), 1);
792        let (action, _) = &actions[0];
793        assert_eq!(
794            action,
795            &EditAction::Replace {
796                file_path: PathBuf::from("src/utils.rs"),
797                old: "fn utils_func() {}".to_string(),
798                new: "fn new_utils_func() {}".to_string(),
799            }
800        );
801        assert_eq!(parser.errors().len(), 1);
802        assert_eq!(
803            parser.errors()[0].to_string(),
804            "input:8:1: Expected marker \"```\", found '<'"
805        );
806
807        // The parser should continue after an error
808        assert_eq!(parser.state, State::Default);
809    }
810
811    const SYSTEM_PROMPT: &str = include_str!("./edit_prompt.md");
812
813    #[test]
814    fn test_parse_examples_in_system_prompt() {
815        let mut parser = EditActionParser::new();
816        let actions = parser.parse_chunk(SYSTEM_PROMPT);
817        assert_examples_in_system_prompt(&actions, parser.errors());
818    }
819
820    #[gpui::test(iterations = 10)]
821    fn test_random_chunking_of_system_prompt(mut rng: StdRng) {
822        let mut parser = EditActionParser::new();
823        let mut remaining = SYSTEM_PROMPT;
824        let mut actions = Vec::with_capacity(5);
825
826        while !remaining.is_empty() {
827            let chunk_size = rng.gen_range(1..=std::cmp::min(remaining.len(), 100));
828
829            let (chunk, rest) = remaining.split_at(chunk_size);
830
831            let chunk_actions = parser.parse_chunk(chunk);
832            actions.extend(chunk_actions);
833            remaining = rest;
834        }
835
836        assert_examples_in_system_prompt(&actions, parser.errors());
837    }
838
839    fn assert_examples_in_system_prompt(actions: &[(EditAction, String)], errors: &[ParseError]) {
840        assert_eq!(actions.len(), 5);
841
842        assert_eq!(
843            actions[0].0,
844            EditAction::Replace {
845                file_path: PathBuf::from("mathweb/flask/app.py"),
846                old: "from flask import Flask".to_string(),
847                new: line_endings!("import math\nfrom flask import Flask").to_string(),
848            },
849        );
850
851        assert_eq!(
852            actions[1].0,
853            EditAction::Replace {
854                file_path: PathBuf::from("mathweb/flask/app.py"),
855                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(),
856                new: "".to_string(),
857            }
858        );
859
860        assert_eq!(
861            actions[2].0,
862            EditAction::Replace {
863                file_path: PathBuf::from("mathweb/flask/app.py"),
864                old: "    return str(factorial(n))".to_string(),
865                new: "    return str(math.factorial(n))".to_string(),
866            },
867        );
868
869        assert_eq!(
870            actions[3].0,
871            EditAction::Write {
872                file_path: PathBuf::from("hello.py"),
873                content: line_endings!(
874                    "def hello():\n    \"print a greeting\"\n\n    print(\"hello\")"
875                )
876                .to_string(),
877            },
878        );
879
880        assert_eq!(
881            actions[4].0,
882            EditAction::Replace {
883                file_path: PathBuf::from("main.py"),
884                old: line_endings!(
885                    "def hello():\n    \"print a greeting\"\n\n    print(\"hello\")"
886                )
887                .to_string(),
888                new: "from hello import hello".to_string(),
889            },
890        );
891
892        // The system prompt includes some text that would produce errors
893        assert_eq!(
894            errors[0].to_string(),
895            format!(
896                "input:102:1: Expected marker \"{}\", found '3'",
897                SEARCH_MARKER
898            )
899        );
900        #[cfg(not(windows))]
901        assert_eq!(
902            errors[1].to_string(),
903            format!(
904                "input:109:0: Expected marker \"{}\", found '\\n'",
905                SEARCH_MARKER
906            )
907        );
908        #[cfg(windows)]
909        assert_eq!(
910            errors[1].to_string(),
911            format!(
912                "input:108:1: Expected marker \"{}\", found '\\r'",
913                SEARCH_MARKER
914            )
915        );
916    }
917
918    #[test]
919    fn test_print_error() {
920        let input = format!(
921            r#"src/main.rs
922```rust
923{}
924fn original() {{}}
925{}
926fn replacement() {{}}
927{}
928```
929"#,
930            WRONG_MARKER, DIVIDER, REPLACE_MARKER
931        );
932
933        let mut parser = EditActionParser::new();
934        parser.parse_chunk(&input);
935
936        assert_eq!(parser.errors().len(), 1);
937        let error = &parser.errors()[0];
938        let expected_error = format!(
939            r#"input:3:9: Expected marker "{}", found 'W'"#,
940            SEARCH_MARKER
941        );
942
943        assert_eq!(format!("{}", error), expected_error);
944    }
945
946    // helpers
947
948    fn assert_no_errors(parser: &EditActionParser) {
949        let errors = parser.errors();
950
951        assert!(
952            errors.is_empty(),
953            "Expected no errors, but found:\n\n{}",
954            errors
955                .iter()
956                .map(|e| e.to_string())
957                .collect::<Vec<String>>()
958                .join("\n")
959        );
960    }
961}