Now switchable, detects terminal. Don't be weird with tabs. Known issues are not being themeable and bold match output stopping if colours are used inside.
310 lines
6.5 KiB
Go
310 lines
6.5 KiB
Go
/*
|
|
gg is a recursive grep written in Go, with some shortcuts for everyday use.
|
|
*/
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// TODO:
|
|
// - bold of escaped output doesn't work
|
|
// - binary file detection
|
|
// - long-line / minified-file detection
|
|
// - ignore files by extension (or glob?)
|
|
|
|
func main() {
|
|
if err := rootCmd.Execute(); err != nil {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
var rootCmd = &cobra.Command{
|
|
Use: "gg pattern [path1 [path2 …]]",
|
|
Short: "gg is a recursive grep",
|
|
Long: `gg is a recursive grep. Given a regexp (or fixed pattern) it will search for
|
|
the pattern recursively in the current working directory. It will print a
|
|
coloured header per file along with the matching line and pattern.
|
|
|
|
It is possible to scan specific files or directories, rather than the default
|
|
current working directory. To do this, simply specify the path(s) as arguments
|
|
following the pattern.
|
|
|
|
It is possible to scan for multiple patterns using the -e (or -Q) argument,
|
|
which can be repeated multiple times. -e specifies a regular expression and
|
|
-Q a fixed pattern. When using either flag, any non-flag arguments are treated
|
|
as paths to scan.
|
|
|
|
Search defaults to case-sensitive but the -i flag may be passed to make regular
|
|
expression searches case-insensitive. Alternatively, the "(?i)" construct may be
|
|
added to a regular expression to make that specific expression case insensitive.
|
|
Fixed pattern matches are always case-sensitive.`,
|
|
|
|
RunE: run,
|
|
}
|
|
|
|
var (
|
|
searchRegexp []string
|
|
regexps []*regexp.Regexp
|
|
searchFixed []string
|
|
searchBytes [][]byte
|
|
searchPath []string
|
|
ignoreList []string
|
|
ignoreMap map[string]struct{}
|
|
ignoreCase bool
|
|
noColour bool
|
|
display *Display
|
|
matchedAny bool
|
|
)
|
|
|
|
func init() {
|
|
rootCmd.Flags().StringSliceVarP(&searchRegexp, "grep", "e", nil, "pattern to match (regular expression)")
|
|
rootCmd.Flags().StringSliceVarP(&searchFixed, "fixed", "Q", nil, "pattern to match (fixed string)")
|
|
rootCmd.Flags().StringSliceVarP(&ignoreList, "exclude", "x", []string{".git"}, "files/directories to exclude")
|
|
rootCmd.Flags().BoolVarP(&ignoreCase, "ignore-case", "i", false, "make all searches case insensitive")
|
|
rootCmd.Flags().BoolVarP(&noColour, "no-colour", "C", false, "disable colour output")
|
|
}
|
|
|
|
func run(c *cobra.Command, args []string) error {
|
|
display = NewDisplay(noColour)
|
|
|
|
if len(searchRegexp) == 0 && len(searchFixed) == 0 {
|
|
if len(args) == 0 {
|
|
return errors.New("no pattern specified")
|
|
}
|
|
searchRegexp = append(searchRegexp, args[0])
|
|
args = args[1:]
|
|
}
|
|
|
|
searchPath = args
|
|
if len(searchPath) == 0 {
|
|
searchPath = append(searchPath, ".")
|
|
}
|
|
|
|
ignoreMap = make(map[string]struct{}, len(ignoreList))
|
|
for _, i := range ignoreList {
|
|
ignoreMap[i] = struct{}{}
|
|
}
|
|
|
|
for _, r := range searchRegexp {
|
|
if ignoreCase {
|
|
r = "(?i)" + r
|
|
}
|
|
re, err := regexp.Compile(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
regexps = append(regexps, re)
|
|
}
|
|
|
|
for _, s := range searchFixed {
|
|
searchBytes = append(searchBytes, []byte(s))
|
|
}
|
|
|
|
var errs []error
|
|
for _, path := range searchPath {
|
|
if err := search(path); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func recurse(path string) error {
|
|
d, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var errs []error
|
|
for _, de := range d {
|
|
name := de.Name()
|
|
fullPath := filepath.Join(path, name)
|
|
|
|
if _, ignored := ignoreMap[name]; ignored {
|
|
continue
|
|
}
|
|
|
|
if err := search(fullPath); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func search(path string) error {
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch {
|
|
case st.IsDir():
|
|
return recurse(path)
|
|
|
|
case !st.Mode().IsRegular(),
|
|
st.Size() == 0:
|
|
return nil
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
fullData, err := unix.Mmap(int(f.Fd()), 0, int(st.Size()), unix.PROT_READ, unix.MAP_PRIVATE)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unix.Munmap(fullData)
|
|
|
|
var printedHeader bool
|
|
|
|
var (
|
|
data = fullData
|
|
lineNum int
|
|
b, b2 strings.Builder
|
|
)
|
|
for len(data) > 0 {
|
|
eol := bytes.IndexByte(data, '\n')
|
|
lineNum++
|
|
var line []byte
|
|
if eol == -1 {
|
|
line = data
|
|
data = nil
|
|
} else {
|
|
line = data[:eol]
|
|
data = data[eol+1:]
|
|
}
|
|
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
|
|
loc := matches(line)
|
|
if loc == nil {
|
|
continue
|
|
}
|
|
|
|
if !printedHeader {
|
|
printedHeader = true
|
|
if !matchedAny {
|
|
matchedAny = true
|
|
} else {
|
|
fmt.Println("")
|
|
}
|
|
fmt.Println(display.Filename(path))
|
|
}
|
|
|
|
b.Reset()
|
|
fmt.Fprintf(&b, "%4d: ", display.LineNumber(lineNum))
|
|
|
|
if loc[0] < 128 {
|
|
escape(&b, line[0:loc[0]])
|
|
} else {
|
|
start := loc[0] - 128
|
|
for i := 0; i < 5; i++ {
|
|
if utf8.RuneStart(line[start]) {
|
|
break
|
|
}
|
|
start++
|
|
}
|
|
|
|
b.WriteString(display.TruncatedBytes(start).String())
|
|
b.WriteString(display.TruncatedMarker().String())
|
|
escape(&b, line[start:loc[0]])
|
|
}
|
|
|
|
if loc[1]-loc[0] < 128 {
|
|
b2.Reset()
|
|
escape(&b2, line[loc[0]:loc[1]])
|
|
b.WriteString(display.Match(b2.String()).String())
|
|
|
|
if loc[1]+128 > len(line) {
|
|
escape(&b, line[loc[1]:])
|
|
} else {
|
|
end := loc[1] + 128
|
|
for i := 0; i < 5; i++ {
|
|
if utf8.RuneStart(line[end]) {
|
|
break
|
|
}
|
|
end--
|
|
}
|
|
escape(&b, line[loc[1]:end])
|
|
b.WriteString(display.TruncatedBytes(len(line) - end).String())
|
|
b.WriteString(display.TruncatedMarker().String())
|
|
}
|
|
|
|
} else {
|
|
end := loc[1]
|
|
for i := 0; i < 5; i++ {
|
|
if utf8.RuneStart(line[end]) {
|
|
break
|
|
}
|
|
end--
|
|
}
|
|
|
|
b2.Reset()
|
|
escape(&b2, line[loc[0]:end])
|
|
b.WriteString(display.Match(b2.String()).String())
|
|
b.WriteString(display.TruncatedMarker().String())
|
|
b.WriteString(display.TruncatedBytes(len(line) - end).String())
|
|
|
|
}
|
|
|
|
b.WriteRune('\n')
|
|
fmt.Print(b.String())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func matches(data []byte) (loc []int) {
|
|
for _, re := range regexps {
|
|
loc := re.FindIndex(data)
|
|
if loc != nil {
|
|
return loc
|
|
}
|
|
}
|
|
for _, s := range searchBytes {
|
|
pos := bytes.Index(data, s)
|
|
if pos != -1 {
|
|
return []int{pos, pos + len(s)}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func escape(b *strings.Builder, s []byte) {
|
|
for len(s) > 0 {
|
|
r, size := utf8.DecodeRune(s)
|
|
s = s[size:]
|
|
|
|
switch {
|
|
case r == utf8.RuneError && size == 1:
|
|
b.WriteString(display.BadUTF8Char().String())
|
|
|
|
case r == '\r':
|
|
b.WriteString(display.CarriageReturn().String())
|
|
|
|
case r == '\t',
|
|
unicode.IsPrint(r):
|
|
b.WriteRune(r)
|
|
|
|
default:
|
|
b.WriteString(display.UnprintableChar().String())
|
|
}
|
|
}
|
|
}
|