1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "regexp"
13 "strings"
14 "sync"
15 "time"
16 "unicode/utf8"
17
18 "github.com/bnema/zut/packages/core"
19 "github.com/bnema/zut/packages/provider"
20)
21
22const (
23 maxGrepInputBytes = 16 * 1024
24 maxGrepOutputBytes = 50 * 1024
25 maxGrepOutputLines = 2000
26 grepTruncationReserve = 128
27 grepInternalTimeout = 30 * time.Second
28)
29
30// GrepTool searches text files below a requested path without invoking a
31// shell. It prefers ripgrep and uses the system grep as a portable fallback.
32// LookPath is injectable for deterministic tests; nil uses exec.LookPath.
33type GrepTool struct {
34 CWD string
35 Sandbox *Sandbox
36 LookPath func(string) (string, error)
37 // ASTAvailable reports whether the ast tool is registered and usable.
38 // When nil or false, grep never suggests ast.
39 ASTAvailable func() bool
40}
41
42var _ core.Tool = (*GrepTool)(nil)
43
44type grepArgs struct {
45 Pattern string `json:"pattern"`
46 Path string `json:"path"`
47}
48
49const grepSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"Regular expression to search for."},"path":{"type":"string","description":"File or directory to search recursively."}},"required":["pattern","path"],"additionalProperties":false}`
50
51func (t *GrepTool) Name() string { return "grep" }
52
53func (t *GrepTool) Description() string {
54 return "Search files for a regular expression using ripgrep when available, with a portable grep fallback; fallback regex syntax and ignore/hidden-file behavior may differ."
55}
56
57func (t *GrepTool) Schema() json.RawMessage { return json.RawMessage(grepSchema) }
58
59// Execute searches the requested file or directory. The search root is
60// checked before the executable is selected or started, so a denied path
61// cannot be used to probe or read through the subprocess.
62func (t *GrepTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
63 return t.executeWithTimeout(ctx, raw, progress, grepInternalTimeout)
64}
65
66// grepTimeoutError distinguishes the search's hard deadline from a caller
67// cancellation or deadline, so the caller gets actionable search guidance.
68type grepTimeoutError struct {
69 after time.Duration
70}
71
72func (e *grepTimeoutError) Error() string {
73 return fmt.Sprintf("grep: search timed out after %s; try narrowing the path or glob", e.after)
74}
75
76func (e *grepTimeoutError) Timeout() bool { return true }
77
78// executeWithTimeout keeps the production deadline in Execute while allowing
79// deadline behavior to be tested without waiting 30 seconds.
80func (t *GrepTool) executeWithTimeout(ctx context.Context, raw json.RawMessage, progress func(string), timeout time.Duration) (core.ToolResult, error) {
81 args, err := parseGrepArgs(raw)
82 if err != nil {
83 return core.ToolResult{}, err
84 }
85 if err := ctx.Err(); err != nil {
86 return core.ToolResult{}, err
87 }
88
89 root := filepath.Clean(resolvePath(t.CWD, args.Path))
90 if !filepath.IsAbs(root) {
91 root, err = filepath.Abs(root)
92 if err != nil {
93 return core.ToolResult{}, err
94 }
95 }
96 if err := t.Sandbox.CheckReadPath(root); err != nil {
97 return core.ToolResult{}, err
98 }
99 info, err := os.Stat(root)
100 if err != nil {
101 return core.ToolResult{}, err
102 }
103 if !info.IsDir() && !info.Mode().IsRegular() {
104 return core.ToolResult{}, fmt.Errorf("grep: %s is not a regular file or directory", args.Path)
105 }
106
107 engine, executable, err := t.searchExecutable()
108 if err != nil {
109 return core.ToolResult{}, err
110 }
111
112 commandArgs := grepCommandArgs(engine, args.Pattern, root)
113 // WithTimeout preserves a sooner deadline or cancellation from ctx; the
114 // internal deadline can only add a limit when the caller has not already
115 // supplied a stricter one.
116 runCtx, cancel := context.WithTimeout(ctx, timeout)
117 defer cancel()
118 cmd := exec.CommandContext(runCtx, executable, commandArgs...)
119 if info.IsDir() {
120 cmd.Dir = root
121 } else {
122 cmd.Dir = filepath.Dir(root)
123 }
124 // Reuse the process-tree cancellation behavior used by the direct command
125 // tools. This does not invoke a shell; executable is run directly above.
126 configureBashProcess(cmd, nil)
127
128 output := &grepOutput{
129 progress: progress,
130 cancel: func() {
131 if cmd.Cancel != nil {
132 _ = cmd.Cancel()
133 }
134 },
135 }
136 cmd.Stdout = output
137 cmd.Stderr = output
138 runErr := cmd.Run()
139 if err := ctx.Err(); err != nil {
140 return core.ToolResult{}, err
141 }
142 if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
143 return core.ToolResult{}, &grepTimeoutError{after: timeout}
144 }
145
146 actualExitCode := 0
147 if runErr != nil {
148 var exitErr *exec.ExitError
149 if !errors.As(runErr, &exitErr) {
150 return core.ToolResult{}, fmt.Errorf("grep: run %s: %w", engine, runErr)
151 }
152 actualExitCode = exitErr.ExitCode()
153 }
154
155 outputCancelled := output.wasCancelled()
156 matched := actualExitCode == 0 || outputCancelled
157 noMatch := actualExitCode == 1
158 if noMatch {
159 // Both rg and grep use exit status 1 for a successful search with no
160 // matches. Do not turn that normal result into a failed tool call.
161 matched = false
162 }
163
164 text, linesTruncated := output.text()
165 if noMatch && strings.TrimSpace(text) == "" {
166 text = "No matches found.\n"
167 }
168 if text == "" && actualExitCode != 0 {
169 text = fmt.Sprintf("%s exited with status %d.\n", engine, actualExitCode)
170 }
171
172 exitCode := actualExitCode
173 if noMatch || outputCancelled {
174 exitCode = 0
175 }
176 isError := actualExitCode > 1 || actualExitCode < 0
177 if outputCancelled {
178 // Reaching the output bound is an intentional, successful stop rather
179 // than a failed search. Preserve the process status separately for
180 // diagnostics while reporting the normalized tool exit code above.
181 isError = false
182 }
183
184 discovers := grepResultPaths(text, root, info.IsDir())
185 // The hint is appended after path discovery so it is never read as a
186 // "file:line" result.
187 hint := ""
188 if !isError && t.ASTAvailable != nil {
189 if hint = grepASTHint(args.Pattern); hint != "" && !t.ASTAvailable() {
190 hint = ""
191 }
192 }
193 text += hint
194 return core.ToolResult{
195 Content: []provider.Content{provider.TextBlock{Text: text}},
196 IsError: isError,
197 Context: provider.ToolContext{Discovers: discovers},
198 Details: map[string]any{
199 "engine": engine,
200 "path": t.Sandbox.DisplayPath(root, args.Path),
201 "exit_code": exitCode,
202 "process_exit_code": actualExitCode,
203 "matched": matched,
204 "bytes_truncated": output.bytesTruncated(),
205 "lines_truncated": linesTruncated,
206 "output_cancelled": outputCancelled,
207 "ast_hint": hint != "",
208 },
209 }, nil
210}
211
212var (
213 // grepCallPattern matches regexes that search for a call, such as
214 // `fmt\.Errorf\(` or `\bNewClient\(`. The capture is the callee.
215 // Qualified names need an escaped dot: an unescaped `.` matches any
216 // character, so a literal structural pattern would be narrower.
217 grepCallPattern = regexp.MustCompile(`^(?:\\b)?([A-Za-z_]\w*(?:\\\.[A-Za-z_]\w*)*)\\\($`)
218 // grepDefinitionPattern matches regexes that search for a definition.
219 grepDefinitionPattern = regexp.MustCompile(`^\^?(?:\\s\*|\s)*(?:func|def|class|fn|function|interface|struct|impl)(?:\s|\\s|\\b|\\\()`)
220)
221
222// grepASTHint suggests the structural ast tool when a regex is really a
223// search for code shape. Regexes miss calls split across lines and cannot
224// tell code from comments or strings; ast-grep handles both.
225func grepASTHint(pattern string) string {
226 if match := grepCallPattern.FindStringSubmatch(pattern); match != nil {
227 callee := strings.ReplaceAll(match[1], `\.`, ".")
228 return fmt.Sprintf("\nTip: for code structure, the ast tool matches calls across lines and skips comments and strings: ast {\"pattern\":%q,\"path\":\"...\"}. Add \"rewrite\" to change every call at once.\n", callee+"($$$ARGS)")
229 }
230 if grepDefinitionPattern.MatchString(pattern) {
231 return "\nTip: for definitions, the ast tool matches code shape, e.g. ast {\"pattern\":\"func $NAME($$$) $$$ { $$$ }\",\"path\":\"...\"}.\n"
232 }
233 return ""
234}
235
236func grepResultPaths(output, root string, rootIsDir bool) []string {
237 seen := make(map[string]bool)
238 var paths []string
239 for _, line := range strings.Split(output, "\n") {
240 candidate, _, ok := strings.Cut(line, ":")
241 if !ok || candidate == "" {
242 continue
243 }
244 path := candidate
245 if !filepath.IsAbs(path) {
246 base := root
247 if !rootIsDir {
248 base = filepath.Dir(root)
249 }
250 path = filepath.Join(base, path)
251 }
252 path = canonicalResourcePath(path)
253 if !seen[path] {
254 seen[path] = true
255 paths = append(paths, path)
256 }
257 }
258 return paths
259}
260
261func parseGrepArgs(raw json.RawMessage) (grepArgs, error) {
262 if len(raw) == 0 || len(raw) > maxGrepInputBytes {
263 return grepArgs{}, fmt.Errorf("grep: invalid arguments")
264 }
265 var fields map[string]json.RawMessage
266 if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
267 return grepArgs{}, fmt.Errorf("grep: invalid arguments")
268 }
269 for key := range fields {
270 if key != "pattern" && key != "path" {
271 return grepArgs{}, fmt.Errorf("grep: unknown argument %q", key)
272 }
273 }
274 var args grepArgs
275 if err := json.Unmarshal(raw, &args); err != nil {
276 return grepArgs{}, fmt.Errorf("grep: invalid arguments: %w", err)
277 }
278 if _, ok := fields["pattern"]; !ok || args.Pattern == "" {
279 return grepArgs{}, fmt.Errorf("grep: pattern is required")
280 }
281 if _, ok := fields["path"]; !ok || args.Path == "" {
282 return grepArgs{}, fmt.Errorf("grep: path is required")
283 }
284 if !utf8.ValidString(args.Pattern) || !utf8.ValidString(args.Path) || strings.IndexByte(args.Pattern, 0) >= 0 || strings.IndexByte(args.Path, 0) >= 0 {
285 return grepArgs{}, fmt.Errorf("grep: arguments contain invalid characters")
286 }
287 return args, nil
288}
289
290func (t *GrepTool) searchExecutable() (string, string, error) {
291 lookup := t.LookPath
292 if lookup == nil {
293 lookup = exec.LookPath
294 }
295 if path, err := lookup("rg"); err == nil && path != "" {
296 return "rg", path, nil
297 }
298 if path, err := lookup("grep"); err == nil && path != "" {
299 return "grep", path, nil
300 }
301 return "", "", fmt.Errorf("grep: neither rg nor grep is available")
302}
303
304func grepCommandArgs(engine, pattern, root string) []string {
305 if engine == "rg" {
306 // These flags avoid headings and terminal color while retaining rg's
307 // fast default ignore and binary-file filtering behavior.
308 return []string{"--no-heading", "--line-number", "--color=never", "-e", pattern, root}
309 }
310 // -e keeps patterns beginning with '-' from being parsed as options. The
311 // absolute resolved root also makes this portable across GNU and BSD grep.
312 return []string{"-r", "-n", "-H", "-I", "-e", pattern, root}
313}
314
315type grepOutput struct {
316 mu sync.Mutex
317 data bytes.Buffer
318 truncated bool
319 cancelled bool
320 cancel func()
321 progress func(string)
322}
323
324func (w *grepOutput) Write(p []byte) (int, error) {
325 w.mu.Lock()
326 room := maxGrepOutputBytes - grepTruncationReserve - w.data.Len()
327 var streamed string
328 shouldCancel := false
329 if room <= 0 {
330 if !w.truncated {
331 w.truncated = true
332 w.cancelled = true
333 shouldCancel = true
334 }
335 } else if len(p) > room {
336 _, _ = w.data.Write(p[:room])
337 w.truncated = true
338 w.cancelled = true
339 shouldCancel = true
340 streamed = string(p[:room])
341 } else {
342 _, _ = w.data.Write(p)
343 streamed = string(p)
344 }
345 cancel := w.cancel
346 progress := w.progress
347 w.mu.Unlock()
348
349 if shouldCancel && cancel != nil {
350 cancel()
351 }
352 if progress != nil && streamed != "" {
353 progress(streamed)
354 }
355 return len(p), nil
356}
357
358func (w *grepOutput) bytesTruncated() bool {
359 w.mu.Lock()
360 defer w.mu.Unlock()
361 return w.truncated
362}
363
364func (w *grepOutput) wasCancelled() bool {
365 w.mu.Lock()
366 defer w.mu.Unlock()
367 return w.cancelled
368}
369
370func (w *grepOutput) text() (string, bool) {
371 w.mu.Lock()
372 defer w.mu.Unlock()
373 text := w.data.String()
374 lineCount := strings.Count(text, "\n")
375 if text != "" && !strings.HasSuffix(text, "\n") {
376 lineCount++
377 }
378 truncatedLines := lineCount > maxGrepOutputLines
379 if truncatedLines {
380 lines := strings.SplitAfter(text, "\n")
381 text = strings.Join(lines[:maxGrepOutputLines], "")
382 }
383 if w.truncated || truncatedLines {
384 if text != "" && !strings.HasSuffix(text, "\n") {
385 text += "\n"
386 }
387 if w.truncated {
388 text += fmt.Sprintf("... [truncated at %d bytes]\n", maxGrepOutputBytes)
389 }
390 if truncatedLines {
391 text += fmt.Sprintf("... [truncated at %d lines]\n", maxGrepOutputLines)
392 }
393 }
394 return text, truncatedLines
395}