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