Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions coworker/tools/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ def grep(
"--line-number",
"--no-heading",
"--color=never",
# Always print the filename (even for a single-file target) and
# NUL-separate it from `line:text`, so parsing never has to guess
# where the path ends — a Windows drive-letter colon (C:\ws\a.py),
# or a colon inside the matched text, would otherwise be taken as a
# field separator (issue #17). Without --with-filename a single-file
# search emits no path and no NUL, and the parser would drop it.
"--with-filename",
"--null",
"--max-count",
str(n),
"-e",
Expand Down Expand Up @@ -135,18 +143,23 @@ def _rel(path: str, root: Path) -> str:


def _parse_rg(stdout: str, root: Path, n: int) -> dict[str, Any]:
# `rg --null` emits each match as `<path>\0<line>:<text>`. Splitting the path off
# on the NUL byte first means the colon inside a Windows drive letter (C:\...) is
# never confused with the line/text separators — the old `split(":", 2)` parsed
# `C:\ws\a.py:12:def f()` as file="C", line="\ws\a.py", text="12:def f()".
matches: list[dict[str, Any]] = []
for line in stdout.splitlines():
parts = line.split(":", 2)
if len(parts) == 3:
f, ln, txt = parts
matches.append(
{
"file": _rel(f, root),
"line": int(ln) if ln.isdigit() else 0,
"text": txt[:300],
}
)
path, sep, rest = line.partition("\0")
if not sep:
continue
ln, _, txt = rest.partition(":")
matches.append(
{
"file": _rel(path, root),
"line": int(ln) if ln.isdigit() else 0,
"text": txt[:300],
}
)
if len(matches) >= n:
break
return {"count": len(matches), "matches": matches}
Expand Down
20 changes: 19 additions & 1 deletion tests/test_code_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -41,6 +41,24 @@ def test_grep_finds_matches_and_respects_glob(tmp_path):
assert only_py["matches"][0]["line"] == 1


def test_parse_rg_handles_windows_drive_letter_paths():
# `rg --null` emits `<path>\0<line>:<text>`. A Windows absolute path leads with a
# drive-letter colon (C:\...); the old `split(":", 2)` mis-parsed it as file="C",
# line=0, text="12:...". Regression for issue #17 — asserts hold on every platform.
from pathlib import Path

stdout = "C:\\ws\\a.py\x0012:def hello():\n"
out = _parse_rg(stdout, Path("C:\\ws"), 100)
assert out["count"] == 1
m = out["matches"][0]
assert m["line"] == 12
assert m["text"] == "def hello():"
# the whole path survives (not truncated to the drive letter "C"); .endswith
# stays platform-independent — on Linux _rel can't relativize a `C:\` path, so it
# returns the raw string, which still ends with the filename.
assert m["file"].endswith("a.py")


def test_ripgrep_uses_the_same_ignored_dirs_as_the_python_fallback(tmp_path, monkeypatch):
import coworker.tools.search as search

Expand Down