返回 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 // Detect encoding from the peek alone — sufficient for the
237 // UTF-8 vs GB18030 distinction (utf8.Valid on 8 KiB is reliable).
238 enc, _ = fileenc.Detect(peek)
239 }
240 }
241
242 var src io.Reader
243 // Stream through the decoder so the 200-match cap can stop reading
244 // early. x/text's UTF-16 decoder preserves split code units across reads.
245 dec := fileenc.Decoder(enc)
246 if dec != nil {
247 src = transform.NewReader(io.MultiReader(bytes.NewReader(peek), f), dec)
248 } else {
249 // UTF-8 or LossyUTF8 — no transformation needed.
250 src = io.MultiReader(bytes.NewReader(peek), f)
251 }
252
253 sc := bufio.NewScanner(src)
254 sc.Buffer(scanBuf, 1024*1024)
255 ln := 0
256 for sc.Scan() {
257 ln++
258 line := sc.Text()
259 if strings.IndexByte(line, 0) >= 0 {
260 return nil // looks binary, skip the file
261 }
262 if re.MatchString(line) {
263 out = append(out, fmt.Sprintf("%s:%d:%s", rp.DisplayFor(file), ln, line))
264 if len(out) >= grepMaxMatches {
265 truncated = true
266 return io.EOF
267 }
268 }
269 }
270 return nil
271 }
272
273 if info.IsDir() {
274 ig := newWalkIgnorer(path, g.forbidRoots)
275 _ = filepath.WalkDir(path, func(path string, d os.DirEntry, err error) error {
276 if ctx.Err() != nil {
277 return ctx.Err() // abort promptly on cancel — a huge tree is interruptible
278 }
279 if err != nil {
280 return nil
281 }
282 if d.IsDir() {
283 if ig.skip(path, d.Name(), true) {
284 return filepath.SkipDir
285 }
286 ig.enter(path)
287 return nil
288 }
289 if ig.skip(path, d.Name(), false) {
290 return nil
291 }
292 if errors.Is(searchFile(path), io.EOF) {
293 return filepath.SkipAll
294 }
295 return nil
296 })
297 } else {
298 _ = searchFile(path)
299 }
300
301 return formatGrep(ctx, out, truncated, to), nil
302 }
303
304 // runRipgrep delegates the search to ripgrep, which already emits
305 // path:line:text with these flags and honors .gitignore. Output is streamed and
306 // capped at grepMaxMatches so a flood of hits can't blow up memory.
307 // The ripgrep subprocess is wrapped in the OS sandbox so forbid-read
308 // directories are invisible to it.
309 func (g grepTool) runRipgrep(ctx context.Context, pattern, path string, to time.Duration, rp ResolvedPath) (string, bool, error) {
310 // Build the ripgrep argv and wrap it in the OS sandbox so forbid-read
311 // directories are invisible to the ripgrep subprocess.
312 args := []string{
313 g.rg,
314 "--no-heading", "--line-number", "--with-filename", "--color", "never",
315 }
316 if secrets.ProtectSensitiveFiles() {
317 // Mirror sensitiveReadPath for the subprocess: ripgrep cannot call
318 // back into confineRead, so the denylist rides along as glob excludes.
319 args = append(args,
320 "--glob", "!.env",
321 "--glob", "!.git-credentials",
322 "--glob", "!.netrc",
323 "--glob", "!*.pem",
324 "--glob", "!*.key",
325 "--glob", "!*.p12",
326 "--glob", "!*.pfx",
327 "--glob", "!.ssh/**",
328 )
329 }
330 args = append(args, "--regexp", pattern, "--", path)
331
332 var lease *sessiontemp.Lease
333 sessionDir := ""
334 if m := g.sessionTempManager(ctx); m != nil {
335 l, err := m.Acquire()
336 if err != nil {
337 return "", false, fmt.Errorf("session temporary directory: %w", err)
338 }
339 lease = l
340 sessionDir = l.Dir()
341 defer lease.Release()
342 }
343 prepared := sandbox.PrepareArgs(g.sb, args, sessionDir)
344 argv, wrapped := prepared.Argv, prepared.Wrapped
345 if len(g.forbidRoots) > 0 && !wrapped {
346 return "", wrapped, nil
347 }
348
349 cmd := proc.CommandContext(ctx, argv[0], argv[1:]...)
350 cmd.Env = applyEnvOverrides(secrets.ProcessEnv(), prepared.EnvOverrides)
351 proc.HideWindow(cmd)
352 stdout, err := cmd.StdoutPipe()
353 if err != nil {
354 return "", wrapped, err
355 }
356 var stderr bytes.Buffer
357 cmd.Stderr = &stderr
358 if err := cmd.Start(); err != nil {
359 return "", wrapped, fmt.Errorf("ripgrep: %w", err)
360 }
361
362 var out []string
363 truncated := false
364 sc := bufio.NewScanner(stdout)
365 sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
366 for sc.Scan() {
367 out = append(out, displayRipgrepLine(sc.Text(), rp))
368 if len(out) >= grepMaxMatches {
369 truncated = true
370 break
371 }
372 }
373 if truncated {
374 _ = cmd.Process.Kill()
375 }
376 _, _ = io.Copy(io.Discard, stdout) // drain to EOF so Wait neither blocks nor races the reader
377 _ = cmd.Wait()
378
379 if len(out) == 0 && ctx.Err() != context.DeadlineExceeded {
380 // ripgrep exits 1 with no output for "no matches"; a real failure (bad
381 // pattern, unreadable path) writes a message to stderr.
382 if msg := strings.TrimSpace(stderr.String()); msg != "" {
383 if rp.External {
384 msg = rp.ErrorText(fmt.Errorf("%s", msg))
385 }
386 return "", wrapped, fmt.Errorf("ripgrep: %s", msg)
387 }
388 }
389 return formatGrep(ctx, out, truncated, to), wrapped, nil
390 }
391
392 func (g grepTool) sessionTempManager(ctx context.Context) *sessiontemp.Manager {
393 if m := sessiontemp.FromContext(ctx); m != nil {
394 return m
395 }
396 return g.sessionTemp
397 }
398
399 func displayRipgrepLine(line string, rp ResolvedPath) string {
400 if !rp.External || !strings.HasPrefix(line, rp.Root) {
401 return line
402 }
403 for i := len(rp.Root); i < len(line); i++ {
404 if line[i] != ':' || i+1 >= len(line) || line[i+1] < '0' || line[i+1] > '9' {
405 continue
406 }
407 j := i + 1
408 for j < len(line) && line[j] >= '0' && line[j] <= '9' {
409 j++
410 }
411 if j >= len(line) || line[j] != ':' {
412 continue
413 }
414 return rp.DisplayFor(line[:i]) + line[i:]
415 }
416 return line
417 }
418
419 // SearchSpec configures the grep tool's engine. A non-empty RgPath makes grep
420 // delegate to that ripgrep binary; empty uses the native Go scanner.
421 type SearchSpec struct {
422 RgPath string
423 }
424
425 // ResolveSearch picks the grep engine from config. "native" forces the Go
426 // scanner; "rg" requires ripgrep (warns and falls back to native if absent);
427 // "auto"/"" uses ripgrep when found, else native. rgPath overrides the PATH
428 // lookup. warn (may be nil) receives the fall-back notice for engine="rg".
429 func ResolveSearch(engine, rgPath string, warn io.Writer) SearchSpec {
430 find := func() string {
431 if rgPath != "" {
432 if fi, err := os.Stat(rgPath); err == nil && !fi.IsDir() {
433 return rgPath
434 }
435 return ""
436 }
437 if p, err := exec.LookPath("rg"); err == nil {
438 return p
439 }
440 return ""
441 }
442 switch strings.ToLower(strings.TrimSpace(engine)) {
443 case "native":
444 return SearchSpec{}
445 case "rg":
446 if p := find(); p != "" {
447 return SearchSpec{RgPath: p}
448 }
449 if warn != nil {
450 fmt.Fprintln(warn, `warning: [tools.search] engine="rg" but ripgrep (rg) was not found; using the native search engine`)
451 }
452 return SearchSpec{}
453 default: // "auto", ""
454 return SearchSpec{RgPath: find()}
455 }
456 }
457
458 // ConfineSearch returns the grep built-in bound to a resolved search engine,
459 // os sandbox spec for the ripgrep subprocess, and forbid-read roots for the
460 // native scanner, overriding the native instance registered at init.
461 // Session-private temporary directories are bound via BindSessionTemp or
462 // Workspace.SessionTemp.
463 func ConfineSearch(spec SearchSpec, sb sandbox.Spec, forbidRoots []string) tool.Tool {
464 return grepTool{rg: spec.RgPath, sb: sb, forbidRoots: forbidRoots}
465 }
466
466 lines GO