Add grep and server read/write/edit tools for agents

This commit is contained in:
Ian Gulliver
2026-10-20 20:22:54 -07:00
parent 35f59246f7
commit 6ec3cafde5
9 changed files with 717 additions and 66 deletions
+267
View File
@@ -0,0 +1,267 @@
package grep
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
)
const (
keep = 64 << 10
lineKeep = 500
lineMax = 1 << 20
sniff = 8000
)
type Options struct {
Glob string
IgnoreCase bool
FilesOnly bool
}
type search struct {
ctx context.Context
re *regexp.Regexp
opts Options
given string
root string
base string
patterns []gitignore.Pattern
reader *bufio.Reader
out strings.Builder
full bool
}
func Search(ctx context.Context, pattern, dir, path string, opts Options) (string, error) {
expr := pattern
if opts.IgnoreCase {
expr = "(?i)" + expr
}
re, err := regexp.Compile(expr)
if err != nil {
return "", fmt.Errorf("pattern: %w", err)
}
_, err = filepath.Match(opts.Glob, "")
if err != nil {
return "", fmt.Errorf("glob %q: %w", opts.Glob, err)
}
root := path
if !filepath.IsAbs(root) {
root = filepath.Join(dir, root)
}
root, err = filepath.Abs(root)
if err != nil {
return "", err
}
info, err := os.Stat(root)
if err != nil {
return "", err
}
top := root
if !info.IsDir() {
top = filepath.Dir(root)
}
s := &search{ctx: ctx, re: re, opts: opts, given: path, root: root, base: repoRoot(top)}
s.load(filepath.Join(gitDir(s.base), "info", "exclude"), nil)
rel, err := filepath.Rel(s.base, top)
if err != nil {
return "", err
}
parts := split(rel)
for i := range parts {
s.load(filepath.Join(s.base, filepath.Join(parts[:i]...), ".gitignore"), parts[:i])
}
if len(parts) > 0 && gitignore.NewMatcher(s.patterns).Match(parts, true) {
s.patterns = nil
}
err = filepath.WalkDir(root, s.visit)
if err != nil {
return "", err
}
if s.full {
s.out.WriteString("(output cut off at 64 KiB; narrow the pattern, path or glob)\n")
}
if s.out.Len() == 0 {
return "No matches.", nil
}
return s.out.String(), nil
}
func repoRoot(dir string) string {
for d := dir; ; d = filepath.Dir(d) {
_, err := os.Lstat(filepath.Join(d, ".git"))
if err == nil {
return d
}
if filepath.Dir(d) == d {
return dir
}
}
}
func gitDir(base string) string {
dot := filepath.Join(base, ".git")
data, err := os.ReadFile(dot)
if err != nil {
return dot
}
dir, ok := strings.CutPrefix(strings.TrimSpace(string(data)), "gitdir: ")
if !ok {
return dot
}
if !filepath.IsAbs(dir) {
dir = filepath.Join(base, dir)
}
common, err := os.ReadFile(filepath.Join(dir, "commondir"))
if err != nil {
return dir
}
c := strings.TrimSpace(string(common))
if !filepath.IsAbs(c) {
c = filepath.Join(dir, c)
}
return c
}
func split(rel string) []string {
if rel == "." {
return nil
}
return strings.Split(filepath.ToSlash(rel), "/")
}
func (s *search) load(file string, domain []string) {
data, err := os.ReadFile(file)
if err != nil {
return
}
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimSuffix(line, "\r")
if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" {
continue
}
s.patterns = append(s.patterns, gitignore.ParsePattern(line, domain))
}
}
func (s *search) visit(p string, d fs.DirEntry, err error) error {
if err != nil {
if p == s.root {
return err
}
return nil
}
if s.ctx.Err() != nil {
return s.ctx.Err()
}
if s.full {
return fs.SkipAll
}
rel, err := filepath.Rel(s.base, p)
if err != nil {
return err
}
parts := split(rel)
if p != s.root && (d.Name() == ".git" || gitignore.NewMatcher(s.patterns).Match(parts, d.IsDir())) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
if d.IsDir() {
s.load(filepath.Join(p, ".gitignore"), parts)
return nil
}
if !d.Type().IsRegular() {
return nil
}
if s.opts.Glob != "" {
ok, _ := filepath.Match(s.opts.Glob, d.Name())
if !ok {
return nil
}
}
return s.file(p)
}
func (s *search) file(p string) error {
f, err := os.Open(p)
if err != nil {
return nil
}
defer f.Close()
if s.reader == nil {
s.reader = bufio.NewReaderSize(f, lineMax)
}
r := s.reader
r.Reset(f)
head, _ := r.Peek(sniff)
if bytes.IndexByte(head, 0) >= 0 {
return nil
}
shown := s.given
if p != s.root {
rel, err := filepath.Rel(s.root, p)
if err != nil {
return err
}
shown = filepath.Join(s.given, rel)
}
for n := 1; ; n++ {
if s.ctx.Err() != nil {
return s.ctx.Err()
}
line, err := r.ReadSlice('\n')
if errors.Is(err, bufio.ErrBufferFull) {
s.write(fmt.Sprintf("%s:%d: line over 1 MiB, not searched\n", shown, n))
for errors.Is(err, bufio.ErrBufferFull) {
if s.ctx.Err() != nil {
return s.ctx.Err()
}
_, err = r.ReadSlice('\n')
}
if err != nil || s.full {
return nil
}
continue
}
if len(line) == 0 {
return nil
}
line = bytes.TrimSuffix(bytes.TrimSuffix(line, []byte("\n")), []byte("\r"))
if s.re.Match(line) {
if s.opts.FilesOnly {
s.write(shown + "\n")
return nil
}
text := string(line)
if len(text) > lineKeep {
text = strings.ToValidUTF8(text[:lineKeep], "") + "…"
}
s.write(fmt.Sprintf("%s:%d:%s\n", shown, n, text))
}
if err != nil || s.full {
return nil
}
}
}
func (s *search) write(text string) {
if s.out.Len()+len(text) > keep {
s.full = true
return
}
s.out.WriteString(text)
}