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) }