268 lines
5.2 KiB
Go
268 lines
5.2 KiB
Go
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)
|
||
|
|
}
|