From 13f25e5fd97623163dbcf44ce979bf414ecfc3bd Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:45:08 -0400 Subject: [PATCH] =?UTF-8?q?fix(tools):=20preserve=20each=20normalized=20re?= =?UTF-8?q?placement=E2=80=99s=20Unicode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> (cherry picked from commit 8e18884e6b4638c8a91ba4fd678821e6812f6130) --- tests/tools/test_patch_unicode_replace_all.py | 29 +++++++++++++++++++ tools/fuzzy_match.py | 15 ++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 tests/tools/test_patch_unicode_replace_all.py diff --git a/tests/tools/test_patch_unicode_replace_all.py b/tests/tools/test_patch_unicode_replace_all.py new file mode 100644 index 0000000000..1cf787ed93 --- /dev/null +++ b/tests/tools/test_patch_unicode_replace_all.py @@ -0,0 +1,29 @@ +"""Replacing every normalized match preserves each region's untouched Unicode.""" + +import json + +from tools import file_tools, terminal_tool +from tools.registry import registry + + +def test_replace_all_preserves_each_matches_unicode(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + path = tmp_path / "notes.txt" + original = "Price ‘old’—ready\nPrice 'old'—ready\n" + path.write_text(original, encoding="utf-8") + task = "unicode-replace-all" + terminal_tool.register_task_env_overrides(task, {"env_type": "local", "cwd": str(tmp_path)}) + try: + read = json.loads(registry.dispatch("read_file", {"path": str(path)}, task_id=task)) + assert "error" not in read + result = json.loads(registry.dispatch("patch", { + "mode": "replace", "path": str(path), "old_string": "Price 'old'--ready", + "new_string": "Price 'new'--ready", "replace_all": True, + }, task_id=task)) + assert "error" not in result, result + assert path.read_text(encoding="utf-8") == original.replace("old", "new") + finally: + file_tools.clear_file_ops_cache(task) + terminal_tool.clear_task_env_overrides(task) diff --git a/tools/fuzzy_match.py b/tools/fuzzy_match.py index a6a439d70e..e1892e19a5 100644 --- a/tools/fuzzy_match.py +++ b/tools/fuzzy_match.py @@ -389,11 +389,10 @@ def fuzzy_find_and_replace(content: str, old_string: str, new_string: str, return content, 0, None, drift_err effective_new = _maybe_unescape_new_string(new_string, content, matches) - if strategy_name == "unicode_normalized": - effective_new = _preserve_unicode_in_replacement(content, matches, old_string, effective_new) new_content = _apply_replacements( content, matches, effective_new, - old_string=old_string if strategy_name != "exact" else None) + old_string=old_string if strategy_name != "exact" else None, + preserve_unicode=strategy_name == "unicode_normalized") _note_edit_match(strategy_name) return new_content, len(matches), strategy_name, None @@ -575,14 +574,20 @@ def _preserve_unicode_in_replacement(content: str, matches: list[Span], def _apply_replacements(content: str, matches: list[Span], - new_string: str, old_string: Optional[str] = None) -> str: + new_string: str, old_string: Optional[str] = None, + preserve_unicode: bool = False) -> str: """Splice ``new_string`` over each span (end-to-start so offsets stay valid); ``old_string`` non-None (non-exact match) re-indents it per region.""" result = content for start, end in sorted(matches, key=lambda x: x[0], reverse=True): adjusted = new_string if old_string is not None: - adjusted = _reindent_replacement(content[start:end], old_string, new_string) + if preserve_unicode: + # Each occurrence may use different typographic characters even + # though all normalize to the same old_string. + adjusted = _preserve_unicode_in_replacement( + content, [(start, end)], old_string, adjusted) + adjusted = _reindent_replacement(content[start:end], old_string, adjusted) result = result[:start] + adjusted + result[end:] return result