diff --git a/tests/tools/test_search_auto_multiline.py b/tests/tools/test_search_auto_multiline.py new file mode 100644 index 0000000000000..6183832438380 --- /dev/null +++ b/tests/tools/test_search_auto_multiline.py @@ -0,0 +1,51 @@ +"""Tests for search_files auto-multiline routing on \\n patterns.""" + +import json + +import pytest + +from tools.file_tools import search_tool + + +@pytest.fixture +def proj(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + d = tmp_path / "proj" + d.mkdir() + (d / "mod.py").write_text( + "def setup():\n init_db()\n return True\n\n" + "def teardown():\n close_db()\n" + ) + return d + + +class TestAutoMultiline: + def test_newline_regex_matches_across_lines(self, proj): + r = json.loads(search_tool(r"def setup\(\):\n init_db\(\)", path=str(proj), task_id="t-ml")) + assert "error" not in r + assert r["total_count"] >= 1 + assert "multiline" in r.get("warning", "") + + def test_literal_newline_in_pattern_matches(self, proj): + # A raw newline in the pattern (not the \n escape) also routes to + # multiline mode. Keep the pattern free of regex metachars. + r = json.loads(search_tool("return True\n\ndef teardown", path=str(proj), task_id="t-ml")) + assert "error" not in r + assert r["total_count"] >= 1 + + def test_plain_pattern_unaffected(self, proj): + r = json.loads(search_tool("init_db", path=str(proj), task_id="t-ml")) + assert r["total_count"] == 1 + assert "multiline" not in r.get("warning", "") + + def test_escaped_backslash_n_stays_literal(self, proj): + # \\n = literal backslash+n search, not a newline: no multiline mode. + (proj / "strings.py").write_text('SEP = "a\\\\nb"\n') + r = json.loads(search_tool(r"a\\nb", path=str(proj), task_id="t-ml")) + assert "error" not in r + assert "multiline" not in (r.get("warning") or "") + + def test_multiline_zero_match_is_clean(self, proj): + r = json.loads(search_tool(r"def missing\(\):\n nope\(\)", path=str(proj), task_id="t-ml")) + assert "error" not in r + assert r["total_count"] == 0 diff --git a/tools/file_operations.py b/tools/file_operations.py index 5aa4c79fd167a..c3e4ef112dbbe 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -2478,6 +2478,9 @@ class ShellFileOperations(FileOperations): if self._has_command('rg'): result = self._search_with_rg(pattern, path, file_glob, limit, offset, output_mode, context) + # rg auto-enables --multiline for \n patterns, so the line- + # oriented warning below no longer applies to this engine. + return result elif self._has_command('grep'): result = self._search_with_grep(pattern, path, file_glob, limit, offset, output_mode, context) @@ -2506,7 +2509,16 @@ class ShellFileOperations(FileOperations): limit: int, offset: int, output_mode: str, context: int) -> SearchResult: """Search using ripgrep.""" cmd_parts = ["rg", "--line-number", "--no-heading", "--with-filename"] - + + # Auto-multiline: a regex `\n` (or a literal newline in the pattern) + # cannot match in rg's default line-oriented mode — it used to hard + # error ("the literal \"\\n\" is not allowed") and burn a turn. When + # the pattern clearly wants to cross lines, enable -U/--multiline + # up front and note it in the result. + multiline = _pattern_has_regex_newline(pattern) + if multiline: + cmd_parts.append("--multiline") + # Add context if requested if context > 0: cmd_parts.extend(["-C", str(context)]) @@ -2556,6 +2568,10 @@ class ShellFileOperations(FileOperations): # Parse the diagnostic-free payload so error text never becomes a match. stdout = payload + _ml_note = ( + "Pattern contains \\n — multiline mode (-U) was enabled automatically " + "so the regex can match across line boundaries." + ) if multiline else None # Parse results based on output mode if output_mode == "files_only": all_files = [f for f in stdout.strip().split('\n') if f] @@ -2566,6 +2582,7 @@ class ShellFileOperations(FileOperations): total_count=total, truncated=bool(limit_reason), limit_reason=limit_reason, + warning=_ml_note, ) elif output_mode == "count": @@ -2626,6 +2643,7 @@ class ShellFileOperations(FileOperations): total_count=total, truncated=total > offset + limit or bool(limit_reason), limit_reason=limit_reason, + warning=_ml_note, ) def _search_with_grep(self, pattern: str, path: str, file_glob: Optional[str],