mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Merge pull request #2942 from weijun-xia/fix/edit-fuzzy-preserve-whitespace
fix(edit): keep untouched lines intact on fuzzy match
This commit is contained in:
@@ -111,20 +111,46 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
|||||||
content_for_replacement=content
|
content_for_replacement=content
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try fuzzy match
|
# Fuzzy match: the exact substring was not found, most likely because the
|
||||||
fuzzy_content = normalize_for_fuzzy_match(content)
|
# whitespace differs (indentation, spaces around operators, trailing
|
||||||
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
# spaces). Locate the region in the ORIGINAL content using a
|
||||||
|
# whitespace-flexible pattern and return offsets into that original
|
||||||
index = fuzzy_content.find(fuzzy_old_text)
|
# content.
|
||||||
if index != -1:
|
#
|
||||||
# Fuzzy match successful, use normalized content for replacement
|
# This must NOT replace inside a whitespace-normalized copy of the file:
|
||||||
return FuzzyMatchResult(
|
# doing so previously returned the normalized copy as
|
||||||
found=True,
|
# content_for_replacement, which caused the whole file to be rewritten
|
||||||
index=index,
|
# with collapsed indentation (every untouched line got reformatted).
|
||||||
match_length=len(fuzzy_old_text),
|
stripped = old_text.strip('\n')
|
||||||
content_for_replacement=fuzzy_content
|
if stripped.strip():
|
||||||
)
|
source_lines = stripped.split('\n')
|
||||||
|
line_patterns = []
|
||||||
|
for i, line in enumerate(source_lines):
|
||||||
|
tokens = line.split()
|
||||||
|
if not tokens:
|
||||||
|
line_patterns.append(r'[ \t]*')
|
||||||
|
continue
|
||||||
|
# Tolerate any run of blanks between tokens.
|
||||||
|
core = r'[ \t]+'.join(re.escape(tok) for tok in tokens)
|
||||||
|
# First-line leading whitespace is folded into the match only when
|
||||||
|
# old_text itself was indented here; otherwise it stays OUTSIDE the
|
||||||
|
# match so a no-indent old_text preserves (does not swallow and drop)
|
||||||
|
# the file's existing indentation -- mirroring an exact substring
|
||||||
|
# match. Inner lines always tolerate indentation: it sits inside the
|
||||||
|
# matched region and is re-supplied by new_text.
|
||||||
|
if i > 0 or line[:1] in (' ', '\t'):
|
||||||
|
core = r'[ \t]*' + core
|
||||||
|
line_patterns.append(core + r'[ \t]*')
|
||||||
|
pattern = '\n'.join(line_patterns)
|
||||||
|
match = re.search(pattern, content)
|
||||||
|
if match:
|
||||||
|
return FuzzyMatchResult(
|
||||||
|
found=True,
|
||||||
|
index=match.start(),
|
||||||
|
match_length=match.end() - match.start(),
|
||||||
|
content_for_replacement=content
|
||||||
|
)
|
||||||
|
|
||||||
# Not found
|
# Not found
|
||||||
return FuzzyMatchResult(found=False)
|
return FuzzyMatchResult(found=False)
|
||||||
|
|
||||||
|
|||||||
103
tests/test_edit_tool.py
Normal file
103
tests/test_edit_tool.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# encoding:utf-8
|
||||||
|
"""
|
||||||
|
Regression tests for the Edit tool's fuzzy matching.
|
||||||
|
|
||||||
|
When the provided oldText does not match byte-for-byte (usually because the
|
||||||
|
whitespace differs), the Edit tool falls back to a whitespace-tolerant fuzzy
|
||||||
|
match. The fuzzy match must replace only the matched region in the original
|
||||||
|
file. It previously rewrote the entire file from a whitespace-normalized copy,
|
||||||
|
which collapsed the indentation of every untouched line and corrupted the file
|
||||||
|
(e.g. broke Python indentation).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from agent.tools.edit.edit import Edit
|
||||||
|
|
||||||
|
|
||||||
|
class TestEditFuzzyPreservesWhitespace(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.work = tempfile.mkdtemp()
|
||||||
|
self.path = os.path.join(self.work, "sample.py")
|
||||||
|
self.original = (
|
||||||
|
"def foo():\n"
|
||||||
|
" x = 1\n"
|
||||||
|
" y = 2\n"
|
||||||
|
" return x + y\n"
|
||||||
|
)
|
||||||
|
with open(self.path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(self.original)
|
||||||
|
self.tool = Edit({"cwd": self.work})
|
||||||
|
|
||||||
|
def _read(self):
|
||||||
|
with open(self.path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def test_fuzzy_match_does_not_reformat_untouched_lines(self):
|
||||||
|
# oldText differs from the file only by extra spaces around '=', so the
|
||||||
|
# exact match fails and the fuzzy path is taken. Only the 'x = 1' line
|
||||||
|
# should change; the other lines must keep their 4-space indentation.
|
||||||
|
result = self.tool.execute({
|
||||||
|
"path": self.path,
|
||||||
|
"oldText": " x = 1",
|
||||||
|
"newText": " x = 100",
|
||||||
|
})
|
||||||
|
self.assertEqual(result.status, "success", result.result)
|
||||||
|
|
||||||
|
expected = (
|
||||||
|
"def foo():\n"
|
||||||
|
" x = 100\n"
|
||||||
|
" y = 2\n"
|
||||||
|
" return x + y\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(self._read(), expected)
|
||||||
|
|
||||||
|
def test_exact_match_still_replaces_in_place(self):
|
||||||
|
result = self.tool.execute({
|
||||||
|
"path": self.path,
|
||||||
|
"oldText": " y = 2",
|
||||||
|
"newText": " y = 20",
|
||||||
|
})
|
||||||
|
self.assertEqual(result.status, "success", result.result)
|
||||||
|
self.assertEqual(
|
||||||
|
self._read(),
|
||||||
|
"def foo():\n x = 1\n y = 20\n return x + y\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_multiline_fuzzy_match_preserves_surrounding_indentation(self):
|
||||||
|
result = self.tool.execute({
|
||||||
|
"path": self.path,
|
||||||
|
"oldText": " x = 1\n y = 2", # extra spaces on 2nd line
|
||||||
|
"newText": " x = 9\n y = 8",
|
||||||
|
})
|
||||||
|
self.assertEqual(result.status, "success", result.result)
|
||||||
|
self.assertEqual(
|
||||||
|
self._read(),
|
||||||
|
"def foo():\n x = 9\n y = 8\n return x + y\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fuzzy_match_with_unindented_oldtext_preserves_file_indent(self):
|
||||||
|
# oldText has NO leading indentation (and loose spacing around '='), so
|
||||||
|
# the exact match fails and the fuzzy path runs against an indented file
|
||||||
|
# line. The file's indentation must be preserved -- kept OUTSIDE the
|
||||||
|
# replaced region -- instead of being swallowed into the match and
|
||||||
|
# dropped (which would break the file's indentation). newText is
|
||||||
|
# likewise unindented, mirroring exact-substring replacement.
|
||||||
|
result = self.tool.execute({
|
||||||
|
"path": self.path,
|
||||||
|
"oldText": "x = 1",
|
||||||
|
"newText": "x = 100",
|
||||||
|
})
|
||||||
|
self.assertEqual(result.status, "success", result.result)
|
||||||
|
self.assertEqual(
|
||||||
|
self._read(),
|
||||||
|
"def foo():\n x = 100\n y = 2\n return x + y\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user