Add grep and server read/write/edit tools for agents
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user