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