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}