File size: 5,424 Bytes
5ef3875 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | """Search tools: search_files (content search) and find_files (glob)."""
from __future__ import annotations
import fnmatch
import re
from pathlib import Path
from typing import Any
from ycode.errors import ToolError
from ycode.tools.base import Tool, ToolContext, ToolResult
from ycode.tools.filesystem import iter_files
MAX_FILE_BYTES = 1_000_000
def _matches_glob(rel: str, pattern: str | None) -> bool:
if not pattern:
return True
name = rel.rsplit("/", 1)[-1]
patterns = [p.strip() for p in pattern.split(",") if p.strip()]
return any(
fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(name, p) or fnmatch.fnmatch(rel, f"**/{p}") for p in patterns
)
class SearchFilesTool(Tool):
name = "search_files"
description = (
"Search file contents with a regular expression (Python syntax). Returns matching "
"lines as path:line: text. Use `glob` (e.g. '*.py' or 'src/**/*.ts') to narrow files."
)
parameters = {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regular expression to search for."},
"path": {"type": "string", "description": "Directory to search (default project root)."},
"glob": {"type": "string", "description": "Filename filter, comma-separated globs."},
"case_sensitive": {"type": "boolean", "description": "Default false."},
"max_results": {"type": "integer", "description": "Default 100."},
},
"required": ["pattern"],
}
def describe(self, args: dict[str, Any]) -> str:
where = f" in {args['glob']}" if args.get("glob") else ""
return f"Searching for /{args.get('pattern', '')}/{where}"
def run(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
root = ctx.permissions.paths.resolve(args.get("path") or ".")
if not root.exists():
raise ToolError(f"Path not found: {args.get('path')}")
flags = 0 if args.get("case_sensitive") else re.IGNORECASE
try:
regex = re.compile(args["pattern"], flags)
except re.error as exc:
raise ToolError(f"invalid regular expression: {exc}") from None
limit = min(max(int(args.get("max_results") or 100), 1), 1000)
files = [root] if root.is_file() else iter_files(root, ctx.ignore_dirs)
results: list[str] = []
files_matched = 0
for file in files:
rel = ctx.permissions.paths.relative(file)
if not _matches_glob(rel, args.get("glob")):
continue
try:
if file.stat().st_size > MAX_FILE_BYTES:
continue
data = file.read_bytes()
except OSError:
continue
if b"\x00" in data[:8192]:
continue
hit = False
for lineno, line in enumerate(data.decode("utf-8", errors="replace").splitlines(), 1):
if regex.search(line):
hit = True
results.append(f"{rel}:{lineno}: {line.strip()[:300]}")
if len(results) >= limit:
break
files_matched += hit
if len(results) >= limit:
break
if not results:
return ToolResult(content="No matches found.", summary="No matches")
suffix = f"\n[Stopped after {limit} results; refine the pattern.]" if len(results) >= limit else ""
return ToolResult(
content="\n".join(results) + suffix,
summary=f"{len(results)} matches in {files_matched} files",
)
class FindFilesTool(Tool):
name = "find_files"
description = "Find files by name/glob pattern (e.g. '*.test.ts', 'package.json', 'src/**/auth*')."
parameters = {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Glob pattern (comma-separated for several)."},
"path": {"type": "string", "description": "Directory to search (default project root)."},
"max_results": {"type": "integer", "description": "Default 200."},
},
"required": ["pattern"],
}
def describe(self, args: dict[str, Any]) -> str:
return f"Finding files matching {args.get('pattern', '')}"
def run(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
root = ctx.permissions.paths.resolve(args.get("path") or ".")
if not root.is_dir():
raise ToolError(f"Directory not found: {args.get('path')}")
limit = min(max(int(args.get("max_results") or 200), 1), 2000)
found: list[str] = []
for file in iter_files(root, ctx.ignore_dirs):
rel = ctx.permissions.paths.relative(file)
if _matches_glob(rel, args["pattern"]):
found.append(rel)
if len(found) >= limit:
break
if not found:
return ToolResult(content="No files found.", summary="No files found")
return ToolResult(content="\n".join(found), summary=f"Found {len(found)} files")
def list_project_files(root: Path, ignore_dirs: tuple[str, ...], limit: int = 2000) -> list[str]:
out: list[str] = []
for file in iter_files(root, ignore_dirs):
out.append(file.relative_to(root).as_posix())
if len(out) >= limit:
break
return out
|