Files
2026-07-13 13:00:08 +08:00

416 lines
13 KiB
Go

package builtin
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"golang.org/x/text/transform"
fileenc "reasonix/internal/fileutil/encoding"
"reasonix/internal/proc"
"reasonix/internal/sandbox"
"reasonix/internal/secrets"
"reasonix/internal/tool"
)
const (
grepMaxMatches = 200
grepDefaultTimeout = 30 * time.Second
grepMaxTimeout = 300 * time.Second
)
// grepTimeout clamps a caller-supplied second count to a sane bound; 0 (omitted)
// falls back to the default so a pathological walk can't hang for minutes.
func grepTimeout(sec int) time.Duration {
switch {
case sec <= 0:
return grepDefaultTimeout
case time.Duration(sec)*time.Second > grepMaxTimeout:
return grepMaxTimeout
default:
return time.Duration(sec) * time.Second
}
}
func formatGrep(ctx context.Context, out []string, truncated bool, to time.Duration) string {
timedOut := ctx.Err() == context.DeadlineExceeded
if len(out) == 0 {
if timedOut {
return fmt.Sprintf("(no matches; timed out after %s — narrow the path/pattern or raise timeout_seconds)", to)
}
return "(no matches)"
}
res := strings.Join(out, "\n")
switch {
case truncated:
res += fmt.Sprintf("\n... (truncated at %d matches)", grepMaxMatches)
case timedOut:
res += fmt.Sprintf("\n... (timed out after %s; results incomplete — narrow the path/pattern or raise timeout_seconds)", to)
}
return res
}
func init() { tool.RegisterBuiltin(grepTool{}) }
// grepTool searches files by regex. workDir, when non-empty, is the directory a
// relative path resolves against (see resolveIn). rg, when non-empty, is a
// ripgrep binary the search delegates to instead of the native Go scanner.
// forbidRoots lists directories the tool may not search inside.
// sb is the OS sandbox spec for the ripgrep subprocess, making forbid-read
// directories invisible to ripgrep instead of checking them in-process.
type grepTool struct {
workDir string
paths *PathResolver
rg string
forbidRoots []string
sb sandbox.Spec
}
func (grepTool) Name() string { return "grep" }
func (g grepTool) Description() string {
if g.rg != "" {
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."
}
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."
}
func (grepTool) Schema() json.RawMessage {
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"]}`)
}
func (grepTool) ReadOnly() bool { return true }
// SnipHint keeps a long head of matches and a short tail: the first matches are
// the ones the model usually acts on, the tail just confirms scope.
func (grepTool) SnipHint() tool.SnipHint {
return tool.SnipHint{Head: 80, Tail: 8, HeadChars: 10000, TailChars: 1000}
}
func (g grepTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
TimeoutSeconds int `json:"timeout_seconds"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
if p.Pattern == "" {
return "", fmt.Errorf("pattern is required")
}
if p.Path == "" {
p.Path = "."
}
rp := resolveReadablePath(g.workDir, p.Path, g.paths)
p.Path = rp.Path
to := grepTimeout(p.TimeoutSeconds)
ctx, cancel := context.WithTimeout(ctx, to)
defer cancel()
info, err := os.Stat(p.Path)
if err != nil {
if rp.External {
return "", fmt.Errorf("grep %s: %s", rp.DisplayPath, rp.ErrorText(err))
}
return "", fmt.Errorf("grep %s: %w", rp.DisplayPath, err)
}
if confineRead(g.forbidRoots, p.Path) {
if info.IsDir() {
return formatGrep(ctx, nil, false, to), nil
}
err := &os.PathError{Op: "stat", Path: p.Path, Err: os.ErrNotExist}
if rp.External {
return "", fmt.Errorf("grep %s: %s", rp.DisplayPath, rp.ErrorText(err))
}
return "", err
}
if g.rg != "" {
out, wrapped, err := g.runRipgrep(ctx, p.Pattern, p.Path, to, rp)
if len(g.forbidRoots) == 0 || wrapped {
return out, err
}
// Without an OS sandbox, ripgrep can walk into forbid-read roots. Fall
// back to the native scanner, which prunes those roots in-process.
}
return g.runNative(ctx, p.Pattern, p.Path, info, to, rp)
}
func (g grepTool) runNative(ctx context.Context, pattern, path string, info os.FileInfo, to time.Duration, rp ResolvedPath) (string, error) {
re, err := regexp.Compile(pattern)
if err != nil {
return "", fmt.Errorf("invalid pattern: %w", err)
}
var out []string
truncated := false
// Reused across the serial walk so each file doesn't re-allocate ~72 KiB.
peekBuf := make([]byte, 8*1024)
scanBuf := make([]byte, 0, 64*1024)
// searchFile returns io.EOF as a sentinel once the cap is reached.
searchFile := func(file string) error {
if confineRead(g.forbidRoots, file) {
return nil
}
f, err := os.Open(file)
if err != nil {
return nil // skip unreadable files
}
defer f.Close()
// Peek the first 8 KiB to reject binaries cheaply without reading
// the entire file into memory. Check BOM first (UTF-16 files have
// 0x00 for ASCII), then NUL.
n, _ := io.ReadFull(f, peekBuf)
peek := peekBuf[:n]
bomKind := fileenc.DetectQuick(peek)
if bomKind != fileenc.UTF16LE && bomKind != fileenc.UTF16BE && bomKind != fileenc.UTF8BOM {
if bytes.IndexByte(peek, 0) >= 0 {
return nil // binary, skip
}
}
// Detect encoding from the peek alone — sufficient for the
// UTF-8 vs GB18030 distinction (utf8.Valid on 8 KiB is reliable).
// Then stream the rest through a decoder so the 200-match cap can
// stop reading early instead of buffering the entire file.
enc, _ := fileenc.Detect(peek)
var src io.Reader
if enc == fileenc.UTF16LE || enc == fileenc.UTF16BE {
// UTF-16 needs full-file decode (multi-byte units span the
// whole stream). These files are rare in grep targets.
rest, err := io.ReadAll(f)
if err != nil {
return nil
}
all := append(peek, rest...)
src = bytes.NewReader(fileenc.Decode(all, enc))
} else {
// Non-BOM path: stream. The peek bytes are prepended via
// io.MultiReader; the remaining bytes flow through a decoder
// pipe so the scanner can stop as soon as the cap is reached.
dec := fileenc.Decoder(enc)
if dec != nil {
head := append([]byte(nil), peek...) // goroutine can outlive an early return; don't alias the reused buffer
pr, pw := io.Pipe()
go func() {
_, _ = pw.Write(head)
io.Copy(pw, f) //nolint:errcheck
pw.Close()
}()
src = transform.NewReader(pr, dec)
} else {
// UTF-8 or LossyUTF8 — no transformation needed.
src = io.MultiReader(bytes.NewReader(peek), f)
}
}
sc := bufio.NewScanner(src)
sc.Buffer(scanBuf, 1024*1024)
ln := 0
for sc.Scan() {
ln++
line := sc.Text()
if strings.IndexByte(line, 0) >= 0 {
return nil // looks binary, skip the file
}
if re.MatchString(line) {
out = append(out, fmt.Sprintf("%s:%d:%s", rp.DisplayFor(file), ln, line))
if len(out) >= grepMaxMatches {
truncated = true
return io.EOF
}
}
}
return nil
}
if info.IsDir() {
ig := newWalkIgnorer(path, g.forbidRoots)
_ = filepath.WalkDir(path, func(path string, d os.DirEntry, err error) error {
if ctx.Err() != nil {
return ctx.Err() // abort promptly on cancel — a huge tree is interruptible
}
if err != nil {
return nil
}
if d.IsDir() {
if ig.skip(path, d.Name(), true) {
return filepath.SkipDir
}
ig.enter(path)
return nil
}
if ig.skip(path, d.Name(), false) {
return nil
}
if searchFile(path) == io.EOF {
return filepath.SkipAll
}
return nil
})
} else {
_ = searchFile(path)
}
return formatGrep(ctx, out, truncated, to), nil
}
// runRipgrep delegates the search to ripgrep, which already emits
// path:line:text with these flags and honors .gitignore. Output is streamed and
// capped at grepMaxMatches so a flood of hits can't blow up memory.
// The ripgrep subprocess is wrapped in the OS sandbox so forbid-read
// directories are invisible to it.
func (g grepTool) runRipgrep(ctx context.Context, pattern, path string, to time.Duration, rp ResolvedPath) (string, bool, error) {
// Build the ripgrep argv and wrap it in the OS sandbox so forbid-read
// directories are invisible to the ripgrep subprocess.
args := []string{
g.rg,
"--no-heading", "--line-number", "--with-filename", "--color", "never",
}
if secrets.ProtectSensitiveFiles() {
// Mirror sensitiveReadPath for the subprocess: ripgrep cannot call
// back into confineRead, so the denylist rides along as glob excludes.
args = append(args,
"--glob", "!.env",
"--glob", "!.git-credentials",
"--glob", "!.netrc",
"--glob", "!*.pem",
"--glob", "!*.key",
"--glob", "!*.p12",
"--glob", "!*.pfx",
"--glob", "!.ssh/**",
)
}
args = append(args, "--regexp", pattern, "--", path)
argv, wrapped := sandbox.CommandArgs(g.sb, args)
if len(g.forbidRoots) > 0 && !wrapped {
return "", wrapped, nil
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd.Env = secrets.ProcessEnv()
proc.HideWindow(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", wrapped, err
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return "", wrapped, fmt.Errorf("ripgrep: %w", err)
}
var out []string
truncated := false
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
out = append(out, displayRipgrepLine(sc.Text(), rp))
if len(out) >= grepMaxMatches {
truncated = true
break
}
}
if truncated {
_ = cmd.Process.Kill()
}
_, _ = io.Copy(io.Discard, stdout) // drain to EOF so Wait neither blocks nor races the reader
_ = cmd.Wait()
if len(out) == 0 && ctx.Err() != context.DeadlineExceeded {
// ripgrep exits 1 with no output for "no matches"; a real failure (bad
// pattern, unreadable path) writes a message to stderr.
if msg := strings.TrimSpace(stderr.String()); msg != "" {
if rp.External {
msg = rp.ErrorText(fmt.Errorf("%s", msg))
}
return "", wrapped, fmt.Errorf("ripgrep: %s", msg)
}
}
return formatGrep(ctx, out, truncated, to), wrapped, nil
}
func displayRipgrepLine(line string, rp ResolvedPath) string {
if !rp.External || !strings.HasPrefix(line, rp.Root) {
return line
}
for i := len(rp.Root); i < len(line); i++ {
if line[i] != ':' || i+1 >= len(line) || line[i+1] < '0' || line[i+1] > '9' {
continue
}
j := i + 1
for j < len(line) && line[j] >= '0' && line[j] <= '9' {
j++
}
if j >= len(line) || line[j] != ':' {
continue
}
return rp.DisplayFor(line[:i]) + line[i:]
}
return line
}
// SearchSpec configures the grep tool's engine. A non-empty RgPath makes grep
// delegate to that ripgrep binary; empty uses the native Go scanner.
type SearchSpec struct {
RgPath string
}
// ResolveSearch picks the grep engine from config. "native" forces the Go
// scanner; "rg" requires ripgrep (warns and falls back to native if absent);
// "auto"/"" uses ripgrep when found, else native. rgPath overrides the PATH
// lookup. warn (may be nil) receives the fall-back notice for engine="rg".
func ResolveSearch(engine, rgPath string, warn io.Writer) SearchSpec {
find := func() string {
if rgPath != "" {
if fi, err := os.Stat(rgPath); err == nil && !fi.IsDir() {
return rgPath
}
return ""
}
if p, err := exec.LookPath("rg"); err == nil {
return p
}
return ""
}
switch strings.ToLower(strings.TrimSpace(engine)) {
case "native":
return SearchSpec{}
case "rg":
if p := find(); p != "" {
return SearchSpec{RgPath: p}
}
if warn != nil {
fmt.Fprintln(warn, `warning: [tools.search] engine="rg" but ripgrep (rg) was not found; using the native search engine`)
}
return SearchSpec{}
default: // "auto", ""
return SearchSpec{RgPath: find()}
}
}
// ConfineSearch returns the grep built-in bound to a resolved search engine,
// os sandbox spec for the ripgrep subprocess, and forbid-read roots for the
// native scanner, overriding the native instance registered at init.
func ConfineSearch(spec SearchSpec, sb sandbox.Spec, forbidRoots []string) tool.Tool {
return grepTool{rg: spec.RgPath, sb: sb, forbidRoots: forbidRoots}
}