diff --git a/coworker/tools/search.py b/coworker/tools/search.py index ad489e1fa2..bb03c0fb75 100644 --- a/coworker/tools/search.py +++ b/coworker/tools/search.py @@ -134,16 +134,24 @@ def _rel(path: str, root: Path) -> str: return path +# ripgrep --no-heading --line-number emits "::". A positional +# split(":", 2) mis-parses on Windows, where the absolute path starts with a +# drive letter ("C:\...") whose colon looks like the field separator. Anchor on +# the numeric "::" instead, so both the drive-letter colon and any colons +# inside the matched text stay intact. +_RG_LINE = re.compile(r"(.+?):(\d+):(.*)") + + def _parse_rg(stdout: str, root: Path, n: int) -> dict[str, Any]: matches: list[dict[str, Any]] = [] for line in stdout.splitlines(): - parts = line.split(":", 2) - if len(parts) == 3: - f, ln, txt = parts + m = _RG_LINE.match(line) + if m: + f, ln, txt = m.group(1), m.group(2), m.group(3) matches.append( { "file": _rel(f, root), - "line": int(ln) if ln.isdigit() else 0, + "line": int(ln), "text": txt[:300], } ) diff --git a/tests/test_code_tools.py b/tests/test_code_tools.py index 0edc83b2f1..5e78128135 100644 --- a/tests/test_code_tools.py +++ b/tests/test_code_tools.py @@ -13,7 +13,7 @@ from coworker.tools.files import file_tools from coworker.tools.git import git_tools -from coworker.tools.search import _py_grep, search_tools +from coworker.tools.search import _parse_rg, _py_grep, search_tools from coworker.web.fetch import _html_to_text, make_web_fetch_tool @@ -61,6 +61,23 @@ def test_ripgrep_uses_the_same_ignored_dirs_as_the_python_fallback(tmp_path, mon assert commands[0].index(f"!**/{ignored}/**") > user_glob +def test_parse_rg_handles_windows_drive_letter_and_colons_in_text(): + # ripgrep prints absolute paths; on Windows they begin with a drive letter + # ("C:\\..."), whose colon must not be mistaken for the path:line:text + # separator. The matched text can also contain colons. Hardcode a Windows + # line so this guards the parser on every platform, not just Windows CI. + from pathlib import Path + + stdout = "C:\\Users\\me\\ws\\a.py:12:x = {'k': 'v'}\n" + out = _parse_rg(stdout, Path("C:\\Users\\me\\ws"), 100) + + assert out["count"] == 1 + m = out["matches"][0] + assert m["line"] == 12 # was 0 with the old split(":", 2) + assert m["text"] == "x = {'k': 'v'}" # line number not leaked, colons kept + assert m["file"] != "C" # the drive letter is not the filename + + def test_grep_rejects_path_escape(tmp_path): grep = search_tools(str(tmp_path))[0] assert "escapes" in grep(pattern="x", path="../..")["error"]