package tools
import (
"bufio"
"bytes"
"context"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// ripgrep is the path to rg, or empty when it is not installed. It is a
// variable so tests can exercise the pure-Go fallback on a machine that has rg.
var ripgrep = func() string {
p, err := exec.LookPath("rg")
if err != nil {
return ""
}
return p
}()
// match is one hit, kept structured until output so results from either engine
// can be sorted the same way. ripgrep walks in parallel and returns files in a
// nondeterministic order; without this, the same search would produce different
// bytes on different machines, which would show up as eval noise and as prompt
// cache misses.
type match struct {
path string
line int
text string
}
func (m match) String() string { return fmt.Sprintf("%s:%d: %s", m.path, m.line, m.text) }
func sortMatches(ms []match) {
sort.Slice(ms, func(i, j int) bool {
if ms[i].path != ms[j].path {
return ms[i].path < ms[j].path
}
return ms[i].line < ms[j].line
})
}
// GrepArgs is the grep input.
type GrepArgs struct {
Pattern string `json:"pattern" jsonschema:"description=Regular expression to search for"`
Path string `json:"path,omitempty" jsonschema:"description=File or directory to search; defaults to the working root"`
Glob string `json:"glob,omitempty" jsonschema:"description=Only search files matching this glob, e.g. *.go"`
IgnoreCase bool `json:"ignore_case,omitempty" jsonschema:"description=Match case-insensitively"`
}
// NewGrep builds the grep tool. It shells out to ripgrep when available and
// falls back to a pure-Go walk otherwise; the output format is the same either
// way, so the model cannot tell which ran. extra names additional directories
// that may be searched but never written, such as a skill's own directory.
func NewGrep(root string, extra ...string) Tool {
return New("grep", strings.TrimSpace(`
Search file contents by regular expression. Returns "path:line: text" for each
match. Narrow with glob (e.g. "*.go") or path. This is how you find code you
have not been shown — reach for it before guessing at file names.`),
func(ctx context.Context, in GrepArgs) (string, error) {
if in.Pattern == "" {
return "", fmt.Errorf("pattern is required")
}
re, err := compile(in.Pattern, in.IgnoreCase)
if err != nil {
return "", err
}
base, err := jailWith(root, extra, resolveBase(root, in.Path))
if err != nil {
return "", err
}
if _, err := os.Stat(base); err != nil {
return "", fmt.Errorf("no such file or directory: %s", displayPath(root, base))
}
var matches []match
if ripgrep != "" {
matches, err = grepRipgrep(ctx, root, base, in)
} else {
matches, err = grepWalk(ctx, root, base, in, re)
}
if err != nil {
return "", err
}
if len(matches) == 0 {
where := displayPath(root, base)
if in.Glob != "" {
where += " matching " + in.Glob
}
return fmt.Sprintf("No matches for %q in %s.", in.Pattern, where), nil
}
sortMatches(matches)
shown := matches
if len(shown) > maxResults {
shown = shown[:maxResults]
}
lines := make([]string, 0, len(shown))
for _, m := range shown {
lines = append(lines, m.String())
}
out := strings.Join(lines, "\n")
if len(matches) > len(shown) {
out += fmt.Sprintf("\n\n(%d of %d matches shown; narrow the pattern, path or glob to see the rest)",
len(shown), len(matches))
}
return out, nil
})
}
// compile validates the pattern up front so a bad regex comes back as a clear
// error the model can fix, rather than as ripgrep's exit status.
func compile(pattern string, ignoreCase bool) (*regexp.Regexp, error) {
if ignoreCase {
pattern = "(?i)" + pattern
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid regular expression: %w", err)
}
return re, nil
}
func grepRipgrep(ctx context.Context, root, base string, in GrepArgs) ([]match, error) {
args := []string{"--line-number", "--no-heading", "--color=never", "--no-messages"}
if in.IgnoreCase {
args = append(args, "--ignore-case")
}
if in.Glob != "" {
args = append(args, "--glob", in.Glob)
}
for dir := range skipDirs {
args = append(args, "--glob", "!"+dir+"/")
}
args = append(args, "--regexp", in.Pattern, base)
cmd := exec.CommandContext(ctx, ripgrep, args...)
out, err := cmd.Output()
if err != nil {
// rg exits 1 when there are simply no matches; that is not a failure.
var exitErr *exec.ExitError
if !bytes.Contains(out, []byte(":")) && errorsAs(err, &exitErr) && exitErr.ExitCode() == 1 {
return nil, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if !errorsAs(err, &exitErr) {
return nil, fmt.Errorf("grep: %w", err)
}
}
var matches []match
sc := bufio.NewScanner(bytes.NewReader(out))
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
if m, ok := parseRipgrepLine(root, sc.Text()); ok {
matches = append(matches, m)
}
}
return matches, nil
}
// parseRipgrepLine splits "abs/path:12:text" into a match.
func parseRipgrepLine(root, line string) (match, bool) {
parts := strings.SplitN(line, ":", 3)
if len(parts) < 3 {
return match{}, false
}
n, err := strconv.Atoi(parts[1])
if err != nil {
return match{}, false
}
return match{path: displayPath(root, parts[0]), line: n, text: capLine(strings.TrimSpace(parts[2]))}, true
}
func grepWalk(ctx context.Context, root, base string, in GrepArgs, re *regexp.Regexp) ([]match, error) {
var matches []match
err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
if d.IsDir() {
if p != base && shouldSkip(d.Name(), in.Glob) {
return fs.SkipDir
}
return nil
}
if in.Glob != "" {
rel, relErr := filepath.Rel(base, p)
if relErr != nil || !matchPath(in.Glob, rel) {
return nil
}
}
data, readErr := os.ReadFile(p)
if readErr != nil || isBinary(data) {
return nil
}
for i, line := range strings.Split(string(data), "\n") {
if re.MatchString(line) {
matches = append(matches, match{
path: displayPath(root, p),
line: i + 1,
text: capLine(strings.TrimSpace(line)),
})
}
}
return nil
})
return matches, err
}
// isBinary treats a NUL byte in the first block as the signal, which is what
// grep and ripgrep both do.
func isBinary(data []byte) bool {
if len(data) > 8000 {
data = data[:8000]
}
return bytes.IndexByte(data, 0) >= 0
}
func errorsAs(err error, target **exec.ExitError) bool {
e, ok := err.(*exec.ExitError)
if ok {
*target = e
}
return ok
}