fix(edit): keep untouched lines intact on fuzzy match

The Edit tool falls back to a whitespace-tolerant fuzzy match when oldText does not match byte-for-byte. On a fuzzy hit it replaced text inside a whitespace-normalized copy of the whole file and wrote that copy back, so every untouched line lost its original indentation (runs of spaces/tabs collapsed to a single space). For indentation-sensitive files such as Python this silently corrupts the file.

Locate the fuzzy match in the original content with a whitespace-flexible regex and return offsets into that original content, so only the matched region is replaced. Adds regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
xiaweiwei67-stack
2026-07-07 16:50:16 +08:00
parent d531e14fbf
commit a94f4e3c18
2 changed files with 118 additions and 14 deletions

View File

@@ -111,20 +111,39 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
content_for_replacement=content
)
# Try fuzzy match
fuzzy_content = normalize_for_fuzzy_match(content)
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
index = fuzzy_content.find(fuzzy_old_text)
if index != -1:
# Fuzzy match successful, use normalized content for replacement
return FuzzyMatchResult(
found=True,
index=index,
match_length=len(fuzzy_old_text),
content_for_replacement=fuzzy_content
)
# Fuzzy match: the exact substring was not found, most likely because the
# whitespace differs (indentation, spaces around operators, trailing
# spaces). Locate the region in the ORIGINAL content using a
# whitespace-flexible pattern and return offsets into that original
# content.
#
# This must NOT replace inside a whitespace-normalized copy of the file:
# doing so previously returned the normalized copy as
# content_for_replacement, which caused the whole file to be rewritten
# with collapsed indentation (every untouched line got reformatted).
stripped = old_text.strip('\n')
if stripped.strip():
line_patterns = []
for line in stripped.split('\n'):
tokens = line.split()
if tokens:
# Tolerate flexible indentation and any run of blanks between
# tokens, matching the leniency of the old normalized compare.
line_patterns.append(
r'[ \t]*' + r'[ \t]+'.join(re.escape(tok) for tok in tokens) + r'[ \t]*'
)
else:
line_patterns.append(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
return FuzzyMatchResult(found=False)

85
tests/test_edit_tool.py Normal file
View File

@@ -0,0 +1,85 @@
# 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",
)
if __name__ == "__main__":
unittest.main()