mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/tools/file: add grep_files regex content search
Line-oriented regex search under the workspace; skip VCS/hidden/binary and cap match count.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import tool
|
||||
from plyngent.tools.file.tree import VCS_DIR_NAMES
|
||||
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_MAX_MATCHES = 100
|
||||
DEFAULT_MAX_FILE_BYTES = 1_000_000
|
||||
DEFAULT_LINE_PREVIEW = 200
|
||||
_BINARY_SNIFF = 8192
|
||||
_TEXT_RATIO = 0.75
|
||||
_ASCII_PRINTABLE_MIN = 32
|
||||
_ASCII_PRINTABLE_MAX = 126
|
||||
_CTRL_TAB = 9
|
||||
_CTRL_CR = 13
|
||||
|
||||
|
||||
def _skip_dir(name: str, *, skip_hidden_dirs: bool) -> bool:
|
||||
if name in VCS_DIR_NAMES:
|
||||
return True
|
||||
return bool(skip_hidden_dirs and name.startswith("."))
|
||||
|
||||
|
||||
def _is_text_byte(value: int) -> bool:
|
||||
return _CTRL_TAB <= value <= _CTRL_CR or _ASCII_PRINTABLE_MIN <= value <= _ASCII_PRINTABLE_MAX
|
||||
|
||||
|
||||
def _is_probably_binary(sample: bytes) -> bool:
|
||||
if b"\x00" in sample:
|
||||
return True
|
||||
if not sample:
|
||||
return False
|
||||
textish = sum(1 for b in sample if _is_text_byte(b))
|
||||
return textish / len(sample) < _TEXT_RATIO
|
||||
|
||||
|
||||
def _iter_files(base: Path, *, skip_hidden_dirs: bool) -> list[Path]:
|
||||
out: list[Path] = []
|
||||
stack = [base]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
try:
|
||||
entries = list(current.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_dir():
|
||||
if not _skip_dir(entry.name, skip_hidden_dirs=skip_hidden_dirs):
|
||||
stack.append(entry)
|
||||
elif entry.is_file():
|
||||
out.append(entry)
|
||||
except OSError:
|
||||
continue
|
||||
out.sort(key=lambda p: str(p).casefold())
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_files(path: str, *, skip_hidden_dirs: bool) -> list[Path] | str:
|
||||
try:
|
||||
base = resolve_path(path)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
if not base.exists():
|
||||
return f"error: path does not exist: {path}"
|
||||
if base.is_file():
|
||||
return [base]
|
||||
if base.is_dir():
|
||||
return _iter_files(base, skip_hidden_dirs=skip_hidden_dirs)
|
||||
return f"error: unsupported path type: {path}"
|
||||
|
||||
|
||||
def _read_text_file(file_path: Path) -> str | None:
|
||||
try:
|
||||
if file_path.stat().st_size > DEFAULT_MAX_FILE_BYTES:
|
||||
return None
|
||||
with file_path.open("rb") as fh:
|
||||
sample = fh.read(_BINARY_SNIFF)
|
||||
if _is_probably_binary(sample):
|
||||
return None
|
||||
return file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _match_lines(
|
||||
file_path: Path,
|
||||
root: Path,
|
||||
regex: re.Pattern[str],
|
||||
*,
|
||||
remaining: int,
|
||||
) -> Iterator[str]:
|
||||
text = _read_text_file(file_path)
|
||||
if text is None:
|
||||
return
|
||||
try:
|
||||
rel = str(file_path.resolve().relative_to(root))
|
||||
except ValueError:
|
||||
return
|
||||
found = 0
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
if found >= remaining:
|
||||
return
|
||||
if regex.search(line) is None:
|
||||
continue
|
||||
content = line if len(line) <= DEFAULT_LINE_PREVIEW else line[:DEFAULT_LINE_PREVIEW] + "…"
|
||||
yield f"{rel}:{line_no}: {content}"
|
||||
found += 1
|
||||
|
||||
|
||||
def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str] | str:
|
||||
if not pattern:
|
||||
return "error: pattern must not be empty"
|
||||
flags = re.MULTILINE
|
||||
if case_insensitive:
|
||||
flags |= re.IGNORECASE
|
||||
try:
|
||||
return re.compile(pattern, flags)
|
||||
except re.error as exc:
|
||||
return f"error: invalid regex: {exc}"
|
||||
|
||||
|
||||
@tool
|
||||
def grep_files(
|
||||
pattern: str,
|
||||
path: str = ".",
|
||||
*,
|
||||
case_insensitive: bool = False,
|
||||
max_matches: int = DEFAULT_MAX_MATCHES,
|
||||
skip_hidden_dirs: bool = True,
|
||||
) -> str:
|
||||
"""Search file contents under the workspace with a regular expression.
|
||||
|
||||
Returns ``path:line: content`` lines (paths relative to workspace root).
|
||||
Skips VCS/hidden dirs by default and ignores likely-binary files.
|
||||
"""
|
||||
if max_matches < 1:
|
||||
return "error: max_matches must be >= 1"
|
||||
regex = _compile_pattern(pattern, case_insensitive=case_insensitive)
|
||||
if isinstance(regex, str):
|
||||
return regex
|
||||
|
||||
try:
|
||||
root = get_workspace_root()
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
|
||||
files = _resolve_files(path, skip_hidden_dirs=skip_hidden_dirs)
|
||||
if isinstance(files, str):
|
||||
return files
|
||||
|
||||
hits: list[str] = []
|
||||
for file_path in files:
|
||||
remaining = max_matches - len(hits)
|
||||
if remaining <= 0:
|
||||
break
|
||||
hits.extend(_match_lines(file_path, root, regex, remaining=remaining))
|
||||
|
||||
if not hits:
|
||||
return "(no matches)"
|
||||
body = "\n".join(hits[:max_matches])
|
||||
if len(hits) >= max_matches:
|
||||
body += f"\n...[truncated at {max_matches} matches]"
|
||||
return body
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.tools.file.grep_files import grep_files
|
||||
from tests.test_tools.helpers import call_sync
|
||||
|
||||
|
||||
def test_grep_files_basic(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
_ = (workspace / "a.py").write_text("hello world\nfoo\n", encoding="utf-8")
|
||||
_ = (workspace / "b.py").write_text("nope\n", encoding="utf-8")
|
||||
out = call_sync(grep_files, "hello")
|
||||
assert "a.py:1:" in out
|
||||
assert "hello world" in out
|
||||
assert "b.py" not in out
|
||||
|
||||
|
||||
def test_grep_files_case_insensitive(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
_ = (workspace / "t.txt").write_text("Hello\n", encoding="utf-8")
|
||||
assert "no matches" in call_sync(grep_files, "hello")
|
||||
out = call_sync(grep_files, "hello", case_insensitive=True)
|
||||
assert "t.txt:1:" in out
|
||||
|
||||
|
||||
def test_grep_files_invalid_regex(workspace: object) -> None:
|
||||
del workspace
|
||||
assert "invalid regex" in call_sync(grep_files, "[")
|
||||
|
||||
|
||||
def test_grep_files_skip_git(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
(workspace / ".git").mkdir()
|
||||
_ = (workspace / ".git" / "x").write_text("secret\n", encoding="utf-8")
|
||||
_ = (workspace / "ok.txt").write_text("secret\n", encoding="utf-8")
|
||||
out = call_sync(grep_files, "secret")
|
||||
assert "ok.txt" in out
|
||||
assert ".git" not in out
|
||||
|
||||
|
||||
def test_grep_files_max_matches(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
lines = "\n".join(f"match {i}" for i in range(10))
|
||||
_ = (workspace / "m.txt").write_text(lines + "\n", encoding="utf-8")
|
||||
out = call_sync(grep_files, "match", max_matches=3)
|
||||
assert "truncated" in out
|
||||
Reference in New Issue
Block a user