diff --git a/main.go b/main.go index 1d360b5..d3d4e04 100644 --- a/main.go +++ b/main.go @@ -47,9 +47,25 @@ to a regular expression to make that specific expression case insensitive. Files and directories can be excluded with the -x option. This supports bash-style globs with '*', '?', '[a-z]', '{this,that}', or '/**/' to match zero or more -directories. By default, .git and vim swap files are ignored. Symlinks named on -the command line are followed, but by default symlinks are not followed when -recursing into directories. -L allows them to be dereferenced.`, +directories. By default, .git and vim swap files are ignored. Similarly, -I +filters files to include. Examples: + + # ignore files/dirs with .js or .css suffix + gg -x '*.js' -x '*.css' pattern + + # only match files with .go suffix (any subdir) + gg -I '*.go' pattern + + # only match files whose parent dir is "stuff", but ignore "foo" subdir + gg -x ./foo -I 'stuff/*' pattern + + # only match .js files with a directory "things" in the path, but ignore + # .min.js (e.g. will match "foo/things/bar/my.js") + gg -I 'things/**/*.js' -x '*.min.js' pattern + +Symlinks named on the command line are followed, but by default symlinks are +not followed when recursing into directories. -L allows them to be +dereferenced.`, RunE: run, } @@ -60,6 +76,7 @@ var ( searchFixed []string searchPath []string excludeList []string + includeList []string ignoreCase bool noColour bool binaryFile notPlainTextFlag @@ -78,6 +95,7 @@ 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(&excludeList, "exclude", "x", []string{".git", ".*.swp"}, "files/directories to exclude") + rootCmd.Flags().StringSliceVarP(&includeList, "include", "I", nil, "files/directories to include") rootCmd.Flags().BoolVarP(&ignoreCase, "ignore-case", "i", false, "make all searches case insensitive") rootCmd.Flags().BoolVarP(&noColour, "no-colour", "C", false, "disable colour output") rootCmd.Flags().Var(&binaryFile, "binary", "what to do with binary files") @@ -86,12 +104,10 @@ func init() { } func run(c *cobra.Command, args []string) error { - // if we got past argument passing, then returned errors are runtime - // things (like file not found) that shouldn't trigger a usage message. - c.SilenceUsage = true - display = NewDisplay(noColour) + // if no -e or -Q flag is passed, then the first arg is taken to be + // the pattern to match if len(searchRegexp) == 0 && len(searchFixed) == 0 { if len(args) == 0 { return errors.New("no pattern specified") @@ -100,11 +116,18 @@ func run(c *cobra.Command, args []string) error { args = args[1:] } + // remaining arguments are treated as search paths; an empty list is + // taken to mean the CWD searchPath = args if len(searchPath) == 0 { searchPath = append(searchPath, ".") } + // if we got past argument passing, then returned errors are runtime + // things (like file not found) that shouldn't trigger a usage message. + c.SilenceUsage = true + + // for -x and -I, an undecorated pattern is treated as a suffix match for i, x := range excludeList { if !strings.HasPrefix(x, "**/") && !strings.HasPrefix(x, "./") { x = "**/" + x @@ -114,7 +137,17 @@ func run(c *cobra.Command, args []string) error { return fmt.Errorf("invalid exclude pattern %q", x) } } + for i, x := range includeList { + if !strings.HasPrefix(x, "**/") && !strings.HasPrefix(x, "./") { + x = "**/" + x + includeList[i] = x + } + if !doublestar.ValidatePattern(x) { + return fmt.Errorf("invalid include pattern %q", x) + } + } + // compile regular expressions for matching for _, r := range searchRegexp { if ignoreCase { r = "(?i)" + r @@ -125,7 +158,6 @@ func run(c *cobra.Command, args []string) error { } regexps = append(regexps, re) } - for _, r := range searchFixed { r = regexp.QuoteMeta(r) if ignoreCase { @@ -137,6 +169,7 @@ func run(c *cobra.Command, args []string) error { regexps = append(regexps, re) } + // search over named paths var errs []error for _, path := range searchPath { if err := search(path, true); err != nil { @@ -154,17 +187,11 @@ func recurse(path string) error { } var errs []error -NextFile: for _, de := range d { - name := de.Name() - fullPath := filepath.Join(path, name) - - for _, x := range excludeList { - if exclude, _ := doublestar.Match(x, fullPath); exclude { - continue NextFile - } + fullPath := filepath.Join(path, de.Name()) + if !shouldSearch(fullPath, de.IsDir()) { + continue } - if err := search(fullPath, followSymlinks); err != nil { errs = append(errs, err) } @@ -172,6 +199,40 @@ NextFile: return errors.Join(errs...) } +// shouldSearch matches the full path of the file against the include and +// exclude lists, returning true if we should consider the file/directory for +// searching and false if not. +func shouldSearch(fullPath string, isDir bool) bool { + // process the exclude list first + for _, x := range excludeList { + if exclude, _ := doublestar.Match(x, fullPath); exclude { + return false + } + } + + // if the include list is empty, everything is included + if len(includeList) == 0 { + return true + } + + for _, x := range includeList { + match, _ := doublestar.Match(x, fullPath) + fmt.Printf("[DEBUG] x=%q fullPath=%q isDir=%t match=%t\n", x, fullPath, isDir, match) + + // if it's a directory, and we have at least one recursive + // matcher, then search + if isDir && strings.HasPrefix(x, "**/") { + return true + } + + if include, _ := doublestar.Match(x, fullPath); include { + return true + } + } + + return false +} + func search(path string, deref bool) error { var ( st os.FileInfo