From a94f4e3c186fd94081c7a9432fe787db767d4568 Mon Sep 17 00:00:00 2001 From: xiaweiwei67-stack <293320877+xiaweiwei67-stack@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:50:16 +0800 Subject: [PATCH 1/2] 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 --- agent/tools/utils/diff.py | 47 +++++++++++++++------- tests/test_edit_tool.py | 85 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 tests/test_edit_tool.py diff --git a/agent/tools/utils/diff.py b/agent/tools/utils/diff.py index 3801ffe5..1872f326 100644 --- a/agent/tools/utils/diff.py +++ b/agent/tools/utils/diff.py @@ -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) diff --git a/tests/test_edit_tool.py b/tests/test_edit_tool.py new file mode 100644 index 00000000..2d006674 --- /dev/null +++ b/tests/test_edit_tool.py @@ -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() From 93162d2f1076df3c0d7d5a5d7f13d7780e89222c Mon Sep 17 00:00:00 2001 From: xiaweiwei67-stack <293320877+xiaweiwei67-stack@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:24:58 +0800 Subject: [PATCH 2/2] fix(edit): preserve file indentation when oldText is unindented The fuzzy fallback anchored every line with a leading [ \t]* that greedily consumed the file's indentation into the matched region, so a no-indent oldText dropped the edited line's indentation. Fold leading whitespace into the match only when oldText was itself indented on the first line, mirroring exact-substring semantics. Add a regression test. --- agent/tools/utils/diff.py | 23 +++++++++++++++-------- tests/test_edit_tool.py | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/agent/tools/utils/diff.py b/agent/tools/utils/diff.py index 1872f326..bcb5c694 100644 --- a/agent/tools/utils/diff.py +++ b/agent/tools/utils/diff.py @@ -123,17 +123,24 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult: # with collapsed indentation (every untouched line got reformatted). stripped = old_text.strip('\n') if stripped.strip(): + source_lines = stripped.split('\n') line_patterns = [] - for line in stripped.split('\n'): + for i, line in enumerate(source_lines): 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: + 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: diff --git a/tests/test_edit_tool.py b/tests/test_edit_tool.py index 2d006674..24b77759 100644 --- a/tests/test_edit_tool.py +++ b/tests/test_edit_tool.py @@ -80,6 +80,24 @@ class TestEditFuzzyPreservesWhitespace(unittest.TestCase): "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()