File: ./config.go
   1 package main
   2 
   3 import (
   4     "os"
   5     "regexp"
   6     "strings"
   7 )
   8 
   9 // config is the result of parsing cmd-line arguments
  10 type config struct {
  11     regex   *regexp.Regexp
  12     folders []string
  13 }
  14 
  15 // parseConfig parses cmd-line args into a config value
  16 func parseConfig() (config, error) {
  17     var cfg config
  18 
  19     if len(os.Args) < 2 {
  20         re, err := regexp.Compile(`.`)
  21         cfg.regex = re
  22         cfg.folders = []string{`.`}
  23         return cfg, err
  24     }
  25 
  26     // if no folders were given, search the current one
  27     if len(os.Args) == 2 {
  28         cfg.folders = []string{`.`}
  29     } else {
  30         cfg.folders = os.Args[2:]
  31     }
  32 
  33     s := os.Args[1]
  34     // force case-insensitive matching
  35     if !strings.HasPrefix(s, `(?i)`) {
  36         s = `(?i)` + s
  37     }
  38 
  39     if len(s) == 0 {
  40         s = `.`
  41     }
  42 
  43     re, err := regexp.Compile(s)
  44     cfg.regex = re
  45     return cfg, err
  46 }
  47 
  48 // searchConfig groups some of the key parameters to funcs search and searchTop
  49 type searchConfig struct {
  50     regex   *regexp.Regexp
  51     results chan any
  52 }

     File: ./go.mod
   1 module wi
   2 
   3 go 1.18

     File: ./info.txt
   1 wi [regex] [folders...]
   2 
   3 Where Is is an app which finds files and folders by name, using an RE2-syntax
   4 case-insensitive regular expression, searching in all the folders given after
   5 it. If no search folders are given, the current one is used.
   6 
   7 Matching folder names are always shown ending in a slash, unlike matching
   8 files. Also, path separators are always forward slashes (/), even when
   9 running on Windows.
  10 
  11 The RE2 regular expression syntax is very similar to the other commonly-used
  12 alternatives, and is described at https://github.com/google/re2/wiki/Syntax;
  13 once more, remember that this app puts `(?i)` in front of your expression if
  14 it's not already there, so that matches are case-insensitive.

     File: ./main.go
   1 package main
   2 
   3 import (
   4     "errors"
   5     "fmt"
   6     "os"
   7     "sync"
   8 
   9     _ "embed"
  10 )
  11 
  12 //go:embed info.txt
  13 var info string
  14 
  15 func main() {
  16     if len(os.Args) < 2 {
  17         fmt.Fprint(os.Stderr, info)
  18         os.Exit(0)
  19     }
  20 
  21     if len(os.Args) == 2 {
  22         switch os.Args[1] {
  23         case `-h`, `--h`, `-help`, `--help`:
  24             fmt.Fprint(os.Stderr, info)
  25             os.Exit(0)
  26         }
  27     }
  28 
  29     cfg, err := parseConfig()
  30     if err != nil {
  31         showError(err)
  32         os.Exit(1)
  33     }
  34 
  35     // ensure all names given are actual folders, showing errors for
  36     // all which aren't
  37     nerr := 0
  38     for _, v := range cfg.folders {
  39         err := checkFolder(v)
  40         if err != nil {
  41             showError(err)
  42             nerr++
  43         }
  44     }
  45 
  46     // quit only after all errors were shown, if there were any
  47     if nerr > 0 {
  48         const msg = `nothing was searched due to previous errors`
  49         showError(errors.New(msg))
  50         os.Exit(1)
  51     }
  52 
  53     err = run(cfg)
  54     if err != nil {
  55         showError(err)
  56         os.Exit(1)
  57     }
  58 }
  59 
  60 // checkFolder ensures the top-level path given to it is for a folder
  61 func checkFolder(path string) error {
  62     info, err := os.Stat(path)
  63     if err != nil {
  64         return err
  65     }
  66 
  67     if !info.IsDir() {
  68         return fmt.Errorf(`%q isn't a folder`, path)
  69     }
  70     return nil
  71 }
  72 
  73 // run dispatches tasks asynchronously, and shows results as they are found
  74 func run(cfg config) error {
  75     results := make(chan any)
  76     params := searchConfig{
  77         regex:   cfg.regex,
  78         results: results,
  79     }
  80 
  81     // find results asynchronously
  82     go func() {
  83         var wg sync.WaitGroup
  84         wg.Add(len(cfg.folders))
  85         defer close(results)
  86 
  87         for _, v := range cfg.folders {
  88             go searchTop(v, params, &wg)
  89         }
  90         wg.Wait()
  91     }()
  92 
  93     // show results synchronously, and as soon as they come
  94     avoid := make(map[string]struct{})
  95     for v := range results {
  96         err := showResult(v, avoid)
  97         if err != nil {
  98             // probably a closed pipe
  99             return nil
 100         }
 101     }
 102     return nil
 103 }
 104 
 105 // showResult shows/handles both kinds of results, as well as errors
 106 func showResult(v any, avoid map[string]struct{}) error {
 107     switch v := v.(type) {
 108     case string:
 109         s := string(v)
 110         _, ok := avoid[s]
 111         if ok {
 112             return nil
 113         }
 114         avoid[s] = struct{}{}
 115         _, err := fmt.Println(s)
 116         return err
 117 
 118     case error:
 119         showError(v)
 120         return nil
 121 
 122     default:
 123         // results channel should only get custom-typed strings or errors
 124         const fs = `internal error (type %T can't be a result)`
 125         return fmt.Errorf(fs, v)
 126     }
 127 }
 128 
 129 // showError ensures a consistent style for errors shown in this app
 130 func showError(err error) {
 131     const fs = "\x1b[31m%s\x1b[0m\n"
 132     fmt.Fprintf(os.Stderr, fs, err.Error())
 133 }

     File: ./matches.go
   1 package main
   2 
   3 import (
   4     "io/fs"
   5     "os"
   6     "path/filepath"
   7     "runtime"
   8     "strings"
   9     "sync"
  10 )
  11 
  12 // searchTop recursively searches the folder given for filename matches, by
  13 // dispatching surface-level subfolders to run concurrently: this considerably
  14 // speeds things up compared to calling func search directly
  15 func searchTop(path string, cfg searchConfig, wg *sync.WaitGroup) {
  16     defer wg.Done()
  17 
  18     entries, err := os.ReadDir(path)
  19     if err != nil {
  20         cfg.results <- err
  21         return
  22     }
  23 
  24     if cfg.regex.MatchString(path) {
  25         s := path
  26         if strings.Contains(s, "\\") {
  27             s = strings.ReplaceAll(s, "\\", `/`)
  28         }
  29         if !strings.HasSuffix(s, `/`) {
  30             s = s + `/`
  31         }
  32         cfg.results <- s
  33     }
  34 
  35     for _, v := range entries {
  36         s := filepath.Join(path, v.Name())
  37         if strings.Contains(s, "\\") {
  38             s = strings.ReplaceAll(s, "\\", `/`)
  39         }
  40 
  41         if v.IsDir() {
  42             wg.Add(1)
  43             go search(s, cfg, wg)
  44             // func search will check if the folder itself matches
  45             continue
  46         }
  47 
  48         if cfg.regex.MatchString(s) {
  49             if strings.Contains(s, "\\") {
  50                 s = strings.ReplaceAll(s, "\\", `/`)
  51             }
  52             cfg.results <- s
  53         }
  54     }
  55 }
  56 
  57 // search recursively searches the folder given for filename matches, reporting
  58 // both matches and errors via the channel given
  59 func search(top string, cfg searchConfig, wg *sync.WaitGroup) {
  60     var buf [256]byte
  61     path := buf[:0]
  62     win := runtime.GOOS == `windows`
  63     defer wg.Done()
  64 
  65     err := filepath.WalkDir(top, func(s string, e fs.DirEntry, err error) error {
  66         if err != nil {
  67             cfg.results <- err
  68             return nil
  69         }
  70 
  71         path = path[:0]
  72         path = append(path, s...)
  73 
  74         // ensure folder names always end with a slash
  75         if e.IsDir() && len(path) > 0 && path[len(path)-1] != '/' {
  76             path = append(path, '/')
  77         }
  78 
  79         // fix path backslashes on windows
  80         if win {
  81             for i, b := range path {
  82                 if b == '\\' {
  83                     path[i] = '/'
  84                 }
  85             }
  86         }
  87 
  88         if cfg.regex.Match(path) {
  89             cfg.results <- string(path)
  90         }
  91         return nil
  92     })
  93 
  94     if err != nil {
  95         cfg.results <- err
  96     }
  97 }