diff --git a/openevolve/utils/code_utils.py b/openevolve/utils/code_utils.py index 5ef3f7cf0..faf09c0ed 100644 --- a/openevolve/utils/code_utils.py +++ b/openevolve/utils/code_utils.py @@ -206,24 +206,43 @@ def extract_diffs( ValueError: The standard SEARCH/REPLACE response has an extra or unmatched delimiter line. Custom diff patterns retain their own grammar. """ - matches = list(re.finditer(diff_pattern, diff_text, re.DOTALL)) if diff_pattern == _STANDARD_DIFF_PATTERN: - # A permissive regex can absorb a second separator into replacement - # text and then insert it as code. Validate the complete response's - # delimiter lines before applying any matched block. - cursor = 0 - for match in matches: - if _STANDARD_DIFF_MARKER.search(diff_text[cursor : match.start()]): - raise ValueError("Unmatched SEARCH/REPLACE delimiter outside a diff block") - block_markers = [ - marker.group(1) for marker in _STANDARD_DIFF_MARKER.finditer(match.group(0)) - ] - if block_markers != ["<<<<<<< SEARCH", "=======", ">>>>>>> REPLACE"]: + return _extract_standard_diffs(diff_text) + matches = re.finditer(diff_pattern, diff_text, re.DOTALL) + return [(match.group(1).rstrip(), match.group(2).rstrip()) for match in matches] + + +def _extract_standard_diffs(diff_text: str) -> List[Tuple[str, str]]: + """ + Extract SEARCH/REPLACE blocks delimited by whole marker lines + + A marker only counts on a line of its own, so code such as a "# =====" banner + comment stays part of a block. Every marker line in the response is checked + before any block is returned: a permissive match could otherwise absorb a + second separator into replacement text and insert it as code. + """ + markers = ["<<<<<<< SEARCH", "=======", ">>>>>>> REPLACE"] + blocks: List[Tuple[str, str]] = [] + texts: List[str] = [] # SEARCH and REPLACE text of the block being read + expected = 0 + section_start = 0 + for marker in _STANDARD_DIFF_MARKER.finditer(diff_text): + token = marker.group(1) + if token != markers[expected]: + if token == markers[1] and expected == 2: raise ValueError("Malformed SEARCH/REPLACE delimiter sequence") - cursor = match.end() - if _STANDARD_DIFF_MARKER.search(diff_text[cursor:]): raise ValueError("Unmatched SEARCH/REPLACE delimiter outside a diff block") - return [(match.group(1).rstrip(), match.group(2).rstrip()) for match in matches] + if expected > 0: + texts.append(diff_text[section_start : marker.start()].rstrip()) + # A section's text starts on the line after its marker + section_start = marker.end() + 1 + expected = (expected + 1) % 3 + if expected == 0: + blocks.append((texts[0], texts[1])) + texts = [] + if expected: + raise ValueError("Unmatched SEARCH/REPLACE delimiter outside a diff block") + return blocks def parse_full_rewrite(llm_response: str, language: str = "python") -> Optional[str]: diff --git a/tests/test_code_utils.py b/tests/test_code_utils.py index d1d08f7a9..ac023ab2a 100644 --- a/tests/test_code_utils.py +++ b/tests/test_code_utils.py @@ -237,12 +237,14 @@ def test_separator_with_trailing_whitespace_raises(self): with self.assertRaises(ValueError): extract_diffs(text) - def test_crlf_marker_is_recognised(self): - # Standard pattern needs LF after markers, so a CRLF response yields no - # match; its marker lines are then reported rather than silently ignored. + def test_crlf_response_is_parsed(self): text = "<<<<<<< SEARCH\r\nx = 1\r\n=======\r\nx = 2\r\n>>>>>>> REPLACE\r\n" - with self.assertRaises(ValueError): - extract_diffs(text) + self.assertEqual(extract_diffs(text), [("x = 1", "x = 2")]) + self.assertEqual(apply_diff("x = 1\ny = 1", text), "x = 2\ny = 1") + + def test_marker_lines_with_trailing_whitespace_are_parsed(self): + text = "<<<<<<< SEARCH \nx = 1\n=======\t\nx = 2\n>>>>>>> REPLACE \n" + self.assertEqual(extract_diffs(text), [("x = 1", "x = 2")]) def test_apply_diff_leaves_no_partial_changes(self): # A valid first block must not be applied when a later block is malformed. @@ -266,6 +268,22 @@ def test_separator_inside_code_line_is_allowed(self): text = f"<<<<<<< SEARCH\nx = 1\n=======\n{replace}\n>>>>>>> REPLACE\n" self.assertEqual(extract_diffs(text), [("x = 1", replace)]) + def test_code_line_ending_in_separator_does_not_split_search(self): + # A banner comment such as "# =====" ends with the separator text; only a + # line that is the separator ends the SEARCH section. + banner = "# " + "=" * 25 + search = f"{banner}\n# Helpers\n{banner}\ndef f(x):\n return x + 1" + replace = f"{banner}\n# Helpers\n{banner}\ndef f(x):\n return x + 2" + text = f"<<<<<<< SEARCH\n{search}\n=======\n{replace}\n>>>>>>> REPLACE\n" + self.assertEqual(extract_diffs(text), [(search, replace)]) + self.assertEqual(apply_diff(search + "\n", text), replace + "\n") + + def test_code_line_ending_in_marker_text_is_not_a_marker(self): + search = "x = 1 # <<<<<<< SEARCH" + replace = "x = 2 # >>>>>>> REPLACE" + text = f"<<<<<<< SEARCH\n{search}\n=======\n{replace}\n>>>>>>> REPLACE\n" + self.assertEqual(extract_diffs(text), [(search, replace)]) + def test_marker_words_in_prose_are_allowed(self): text = ( "I use the ======= separator and >>>>>>> REPLACE marker below.\n"