返回 DeepSeek-Reasonix
grep.go
根目录 / internal / tool / builtin / grep.go
1 package builtin
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "fmt"
9 "io"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "time"
16
17 "golang.org/x/text/transform"
18
19 fileenc "reasonix/internal/fileutil/encoding"
20 "reasonix/internal/proc"
21 "reasonix/internal/sandbox"
22 "reasonix/internal/secrets"
23 "reasonix/internal/sessiontemp"
24 "reasonix/internal/tool"
25 )
26
27 const (
28 grepMaxMatches = 200
29 grepDefaultTimeout = 30 * time.Second
30 grepMaxTimeout = 300 * time.Second
31 )
32
33 // grepTimeout clamps a caller-supplied second count to a sane bound; 0 (omitted)
34 // falls back to the default so a pathological walk can't hang for minutes.
35 func grepTimeout(sec int) time.Duration {
36 switch {
37 case sec <= 0:
38 return grepDefaultTimeout
39 case time.Duration(sec)*time.Second > grepMaxTimeout:
40 return grepMaxTimeout
41 default:
42 return time.Duration(sec) * time.Second
43 }
44 }
45
46 func formatGrep(ctx context.Context, out []string, truncated bool, to time.Duration) string {
47 timedOut := ctx.Err() == context.DeadlineExceeded
48 if len(out) == 0 {
49 if timedOut {
50 return fmt.Sprintf("(no matches; timed out after %s — narrow the path/pattern or raise timeout_seconds)", to)
51 }
52 return "(no matches)"
53 }
54 res := strings.Join(out, "\n")
55 switch {
56 case truncated:
57 res += fmt.Sprintf("\n... (truncated at %d matches)", grepMaxMatches)
58 case timedOut:
59 res += fmt.Sprintf("\n... (timed out after %s; results incomplete — narrow the path/pattern or raise timeout_seconds)", to)
60 }
61 return res
62 }
63
64 func init() { tool.RegisterBuiltin(grepTool{}) }
65
66 // grepTool searches files by regex. workDir, when non-empty, is the directory a
67 // relative path resolves against (see resolveIn). rg, when non-empty, is a
68 // ripgrep binary the search delegates to instead of the native Go scanner.
69 // forbidRoots lists directories the tool may not search inside.
70 // sb is the OS sandbox spec for the ripgrep subprocess, making forbid-read
71 // directories invisible to ripgrep instead of checking them in-process.
72 type grepTool struct {
73 workDir string
74 paths *PathResolver
75 rg string
76 forbidRoots []string
77 sb sandbox.Spec
78 sessionTemp *sessiontemp.Manager
79 }
80
81 func (grepTool) Name() string { return "grep" }
82
83 func (g grepTool) Description() string {
84 if g.rg != "" {
85 return "Search for a regular expression in a file, or recursively under a directory — ripgrep-backed, so it honors .gitignore. Returns matching lines as path:line:text, capped at 200 matches."
86 }
87 return "Search for a regular expression in a file, or recursively under a directory (skips hidden files and files matched by .gitignore). Returns matching lines as path:line:text, capped at 200 matches."
88 }
89
90 func (grepTool) Schema() json.RawMessage {
91 return json.RawMessage(`{"type":"object","properties":{"pattern":{"type":"string","description":"Regular expression (RE2 syntax)"},"path":{"type":"string","description":"File or directory to search (default \".\")"},"timeout_seconds":{"type":"integer","description":"Abort and return partial matches after this many seconds (default 30, max 300). Raise it for a large tree; lower it for a quick probe.","minimum":1}},"required":["pattern"]}`)
92 }
93
94 func (grepTool) ReadOnly() bool { return true }
95
96 // SnipHint keeps a long head of matches and a short tail: the first matches are
97 // the ones the model usually acts on, the tail just confirms scope.
98 func (grepTool) SnipHint() tool.SnipHint {
99 return tool.SnipHint{Head: 80, Tail: 8, HeadChars: 10000, TailChars: 1000}
100 }
101
102 func (g grepTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
103 var p struct {
104 Pattern string `json:"pattern"`
105 Path string `json:"path"`
106 TimeoutSeconds int `json:"timeout_seconds"`
107 }
108 if err := json.Unmarshal(args, &p); err != nil {
109 return "", fmt.Errorf("invalid args: %w", err)
110 }
111 if p.Pattern == "" {
112 return "", fmt.Errorf("pattern is required")
113 }
114 if p.Path == "" {
115 p.Path = "."
116 }
117 rp := resolveReadablePath(g.workDir, p.Path, g.paths)
118 p.Path = rp.Path
119
120 to := grepTimeout(p.TimeoutSeconds)
121 ctx, cancel := context.WithTimeout(ctx, to)
122 defer cancel()
123
124 info, err := os.Stat(p.Path)
125 if err != nil {
126 if rp.External {
127 return "", fmt.Errorf("grep %s: %s", rp.DisplayPath, rp.ErrorText(err))
128 }
129 return "", fmt.Errorf("grep %s: %w", rp.DisplayPath, err)
130 }
131 if confineRead(g.forbidRoots, p.Path) {
132 if info.IsDir() {
133 return formatGrep(ctx, nil, false, to), nil
134 }
135 err := &os.PathError{Op: "stat", Path: p.Path, Err: os.ErrNotExist}
136 if rp.External {
137 return "", fmt.Errorf("grep %s: %s", rp.DisplayPath, rp.ErrorText(err))
138 }
139 return "", err
140 }
141
142 if g.rg != "" {
143 out, wrapped, err := g.runRipgrep(ctx, p.Pattern, p.Path, to, rp)
144 if len(g.forbidRoots) == 0 || wrapped {
145 return out, err
146 }
147 // Without an OS sandbox, ripgrep can walk into forbid-read roots. Fall
148 // back to the native scanner, which prunes those roots in-process.
149 }
150
151 return g.runNative(ctx, p.Pattern, p.Path, info, to, rp)
152 }
153
154 func (g grepTool) runNative(ctx context.Context, pattern, path string, info os.FileInfo, to time.Duration, rp ResolvedPath) (string, error) {
155 re, err := regexp.Compile(pattern)
156 if err != nil {
157 return "", fmt.Errorf("invalid pattern: %w", err)
158 }
159
160 var out []string
161 truncated := false
162
163 // Reused across the serial walk so each file doesn't re-allocate ~72 KiB.
164 peekBuf := make([]byte, 8*1024)
165 scanBuf := make([]byte, 0, 64*1024)
166
167 // searchFile returns io.EOF as a sentinel once the cap is reached.
168 searchFile := func(file string) error {
169 if confineRead(g.forbidRoots, file) {
170 return nil
171 }
172 f, err := os.Open(file)
173 if err != nil {
174 return nil // skip unreadable files
175 }
176 defer f.Close()
177
178 // Peek the first 8 KiB to reject binaries cheaply without reading
179 // the entire file into memory. Check BOM first (UTF-16 files have
180 // 0x00 for ASCII), then NUL.
181 n, _ := io.ReadFull(f, peekBuf)
182 peek := peekBuf[:n]
183
184 bomKind := fileenc.DetectQuick(peek)
185 if bomKind != fileenc.UTF16LE && bomKind != fileenc.UTF16BE && bomKind != fileenc.UTF8BOM {
186 if bytes.IndexByte(peek, 0) >= 0 {
187 return nil // binary, skip
188 }
189 }
190
191 // Detect encoding from the peek alone — sufficient for the
192 // UTF-8 vs GB18030 distinction (utf8.Valid on 8 KiB is reliable).
193 // Then stream the rest through a decoder so the 200-match cap can
194 // stop reading early instead of buffering the entire file.
195 enc, _ := fileenc.Detect(peek)
196
197 var src io.Reader
198 if enc == fileenc.UTF16LE || enc == fileenc.UTF16BE {
199 // UTF-16 needs full-file decode (multi-byte units span the
200 // whole stream). These files are rare in grep targets.
201 rest, err := io.ReadAll(f)
202 if err != nil {
203 return nil
204 }
205 all := append(peek, rest...)
206 src = bytes.NewReader(fileenc.Decode(all, enc))
207 } else {
208 // Non-BOM path: stream through the decoder so the scanner can
209 // stop as soon as the cap is reached without buffering the file.
210 dec := fileenc.Decoder(enc)
211 if dec != nil {
212 src = transform.NewReader(io.MultiReader(bytes.NewReader(peek), f), dec)
213 } else {
214 // UTF-8 or LossyUTF8 — no transformation needed.
215 src = io.MultiReader(bytes.NewReader(peek), f)
216 }
217 }
218
219 sc := bufio.NewScanner(src)
220 sc.Buffer(scanBuf, 1024*1024)
221 ln := 0
222 for sc.Scan() {
223 ln++
224 line := sc.Text()
225 if strings.IndexByte(line, 0) >= 0 {
226 return nil // looks binary, skip the file
227 }
228 if re.MatchString(line) {
229 out = append(out, fmt.Sprintf("%s:%d:%s", rp.DisplayFor(file), ln, line))
230 if len(out) >= grepMaxMatches {
231 truncated = true
232 return io.EOF
233 }
234 }
235 }
236 return nil
237 }
238
239 if info.IsDir() {
240 ig := newWalkIgnorer(path, g.forbidRoots)
241 _ = filepath.WalkDir(path, func(path string, d os.DirEntry, err error) error {
242 if ctx.Err() != nil {
243 return ctx.Err() // abort promptly on cancel — a huge tree is interruptible
244 }
245 if err != nil {
246 return nil
247 }
248 if d.IsDir() {
249 if ig.skip(path, d.Name(), true) {
250 return filepath.SkipDir
251 }
252 ig.enter(path)
253 return nil
254 }
255 if ig.skip(path, d.Name(), false) {
256 return nil
257 }
258 if searchFile(path) == io.EOF {
259 return filepath.SkipAll
260 }
261 return nil
262 })
263 } else {
264 _ = searchFile(path)
265 }
266
267 return formatGrep(ctx, out, truncated, to), nil
268 }
269
270 // runRipgrep delegates the search to ripgrep, which already emits
271 // path:line:text with these flags and honors .gitignore. Output is streamed and
272 // capped at grepMaxMatches so a flood of hits can't blow up memory.
273 // The ripgrep subprocess is wrapped in the OS sandbox so forbid-read
274 // directories are invisible to it.
275 func (g grepTool) runRipgrep(ctx context.Context, pattern, path string, to time.Duration, rp ResolvedPath) (string, bool, error) {
276 // Build the ripgrep argv and wrap it in the OS sandbox so forbid-read
277 // directories are invisible to the ripgrep subprocess.
278 args := []string{
279 g.rg,
280 "--no-heading", "--line-number", "--with-filename", "--color", "never",
281 }
282 if secrets.ProtectSensitiveFiles() {
283 // Mirror sensitiveReadPath for the subprocess: ripgrep cannot call
284 // back into confineRead, so the denylist rides along as glob excludes.
285 args = append(args,
286 "--glob", "!.env",
287 "--glob", "!.git-credentials",
288 "--glob", "!.netrc",
289 "--glob", "!*.pem",
290 "--glob", "!*.key",
291 "--glob", "!*.p12",
292 "--glob", "!*.pfx",
293 "--glob", "!.ssh/**",
294 )
295 }
296 args = append(args, "--regexp", pattern, "--", path)
297
298 var lease *sessiontemp.Lease
299 sessionDir := ""
300 if m := g.sessionTempManager(ctx); m != nil {
301 l, err := m.Acquire()
302 if err != nil {
303 return "", false, fmt.Errorf("session temporary directory: %w", err)
304 }
305 lease = l
306 sessionDir = l.Dir()
307 defer lease.Release()
308 }
309 prepared := sandbox.PrepareArgs(g.sb, args, sessionDir)
310 argv, wrapped := prepared.Argv, prepared.Wrapped
311 if len(g.forbidRoots) > 0 && !wrapped {
312 return "", wrapped, nil
313 }
314
315 cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
316 cmd.Env = applyEnvOverrides(secrets.ProcessEnv(), prepared.EnvOverrides)
317 proc.HideWindow(cmd)
318 stdout, err := cmd.StdoutPipe()
319 if err != nil {
320 return "", wrapped, err
321 }
322 var stderr bytes.Buffer
323 cmd.Stderr = &stderr
324 if err := cmd.Start(); err != nil {
325 return "", wrapped, fmt.Errorf("ripgrep: %w", err)
326 }
327
328 var out []string
329 truncated := false
330 sc := bufio.NewScanner(stdout)
331 sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
332 for sc.Scan() {
333 out = append(out, displayRipgrepLine(sc.Text(), rp))
334 if len(out) >= grepMaxMatches {
335 truncated = true
336 break
337 }
338 }
339 if truncated {
340 _ = cmd.Process.Kill()
341 }
342 _, _ = io.Copy(io.Discard, stdout) // drain to EOF so Wait neither blocks nor races the reader
343 _ = cmd.Wait()
344
345 if len(out) == 0 && ctx.Err() != context.DeadlineExceeded {
346 // ripgrep exits 1 with no output for "no matches"; a real failure (bad
347 // pattern, unreadable path) writes a message to stderr.
348 if msg := strings.TrimSpace(stderr.String()); msg != "" {
349 if rp.External {
350 msg = rp.ErrorText(fmt.Errorf("%s", msg))
351 }
352 return "", wrapped, fmt.Errorf("ripgrep: %s", msg)
353 }
354 }
355 return formatGrep(ctx, out, truncated, to), wrapped, nil
356 }
357
358 func (g grepTool) sessionTempManager(ctx context.Context) *sessiontemp.Manager {
359 if m := sessiontemp.FromContext(ctx); m != nil {
360 return m
361 }
362 return g.sessionTemp
363 }
364
365 func displayRipgrepLine(line string, rp ResolvedPath) string {
366 if !rp.External || !strings.HasPrefix(line, rp.Root) {
367 return line
368 }
369 for i := len(rp.Root); i < len(line); i++ {
370 if line[i] != ':' || i+1 >= len(line) || line[i+1] < '0' || line[i+1] > '9' {
371 continue
372 }
373 j := i + 1
374 for j < len(line) && line[j] >= '0' && line[j] <= '9' {
375 j++
376 }
377 if j >= len(line) || line[j] != ':' {
378 continue
379 }
380 return rp.DisplayFor(line[:i]) + line[i:]
381 }
382 return line
383 }
384
385 // SearchSpec configures the grep tool's engine. A non-empty RgPath makes grep
386 // delegate to that ripgrep binary; empty uses the native Go scanner.
387 type SearchSpec struct {
388 RgPath string
389 }
390
391 // ResolveSearch picks the grep engine from config. "native" forces the Go
392 // scanner; "rg" requires ripgrep (warns and falls back to native if absent);
393 // "auto"/"" uses ripgrep when found, else native. rgPath overrides the PATH
394 // lookup. warn (may be nil) receives the fall-back notice for engine="rg".
395 func ResolveSearch(engine, rgPath string, warn io.Writer) SearchSpec {
396 find := func() string {
397 if rgPath != "" {
398 if fi, err := os.Stat(rgPath); err == nil && !fi.IsDir() {
399 return rgPath
400 }
401 return ""
402 }
403 if p, err := exec.LookPath("rg"); err == nil {
404 return p
405 }
406 return ""
407 }
408 switch strings.ToLower(strings.TrimSpace(engine)) {
409 case "native":
410 return SearchSpec{}
411 case "rg":
412 if p := find(); p != "" {
413 return SearchSpec{RgPath: p}
414 }
415 if warn != nil {
416 fmt.Fprintln(warn, `warning: [tools.search] engine="rg" but ripgrep (rg) was not found; using the native search engine`)
417 }
418 return SearchSpec{}
419 default: // "auto", ""
420 return SearchSpec{RgPath: find()}
421 }
422 }
423
424 // ConfineSearch returns the grep built-in bound to a resolved search engine,
425 // os sandbox spec for the ripgrep subprocess, and forbid-read roots for the
426 // native scanner, overriding the native instance registered at init.
427 // Session-private temporary directories are bound via BindSessionTemp or
428 // Workspace.SessionTemp.
429 func ConfineSearch(spec SearchSpec, sb sandbox.Spec, forbidRoots []string) tool.Tool {
430 return grepTool{rg: spec.RgPath, sb: sb, forbidRoots: forbidRoots}
431 }
432
432 lines GO