├── go.mod └── main.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/phillip-england/bfs 2 | 3 | go 1.25.5 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "strings" 13 | ) 14 | 15 | type config struct { 16 | dryRun bool 17 | caseIns bool 18 | exts map[string]bool 19 | excludeDirs map[string]bool 20 | maxSize int64 21 | } 22 | 23 | func usage() { 24 | fmt.Fprintf(os.Stderr, "Usage:\n bfs [flags] \"search text\" \"replacement text\"\n\nFlags:\n") 25 | flag.PrintDefaults() 26 | } 27 | 28 | func main() { 29 | var ( 30 | dryRun = flag.Bool("n", false, "dry run (do not write changes)") 31 | caseIns = flag.Bool("i", false, "case-insensitive search (regex-based)") 32 | extStr = flag.String("ext", "", "only process these extensions (comma-separated, e.g. .go,.ts); empty = all") 33 | exclStr = flag.String("exclude", ".git,node_modules,dist,build,out,target,vendor", "exclude directory names (comma-separated)") 34 | maxSize = flag.Int64("max", 50*1024*1024, "max file size in bytes to process (default 50MB)") 35 | ) 36 | flag.Usage = usage 37 | flag.Parse() 38 | 39 | args := flag.Args() 40 | if len(args) != 3 { 41 | usage() 42 | os.Exit(2) 43 | } 44 | 45 | target := args[0] 46 | search := args[1] 47 | repl := args[2] 48 | 49 | if search == "" { 50 | fmt.Fprintln(os.Stderr, "error: search text must not be empty") 51 | os.Exit(2) 52 | } 53 | 54 | cfg := config{ 55 | dryRun: *dryRun, 56 | caseIns: *caseIns, 57 | exts: parseCommaSet(*extStr, true), 58 | excludeDirs: parseCommaSet(*exclStr, false), 59 | maxSize: *maxSize, 60 | } 61 | 62 | info, err := os.Stat(target) 63 | if err != nil { 64 | fmt.Fprintf(os.Stderr, "error: %v\n", err) 65 | os.Exit(1) 66 | } 67 | 68 | var changedFiles int 69 | 70 | if info.Mode().IsRegular() { 71 | changed, err := processFile(target, search, repl, cfg) 72 | if err != nil { 73 | fmt.Fprintf(os.Stderr, "error processing %s: %v\n", target, err) 74 | os.Exit(1) 75 | } 76 | if changed { 77 | changedFiles++ 78 | } 79 | } else if info.IsDir() { 80 | err := filepath.WalkDir(target, func(path string, d fs.DirEntry, walkErr error) error { 81 | if walkErr != nil { 82 | return walkErr 83 | } 84 | 85 | if d.IsDir() { 86 | if cfg.excludeDirs[strings.ToLower(d.Name())] { 87 | return fs.SkipDir 88 | } 89 | return nil 90 | } 91 | 92 | fi, err := d.Info() 93 | if err != nil { 94 | return err 95 | } 96 | if !fi.Mode().IsRegular() { 97 | return nil 98 | } 99 | if fi.Size() > cfg.maxSize { 100 | return nil 101 | } 102 | 103 | if len(cfg.exts) > 0 { 104 | ext := strings.ToLower(filepath.Ext(path)) 105 | if !cfg.exts[ext] { 106 | return nil 107 | } 108 | } 109 | 110 | changed, err := processFile(path, search, repl, cfg) 111 | if err != nil { 112 | fmt.Fprintf(os.Stderr, "warn: %s: %v\n", path, err) 113 | return nil 114 | } 115 | if changed { 116 | changedFiles++ 117 | } 118 | return nil 119 | }) 120 | if err != nil { 121 | fmt.Fprintf(os.Stderr, "error walking dir: %v\n", err) 122 | os.Exit(1) 123 | } 124 | } else { 125 | fmt.Fprintln(os.Stderr, "error: target must be a file or directory") 126 | os.Exit(2) 127 | } 128 | 129 | if cfg.dryRun { 130 | fmt.Printf("dry-run: %d file(s) would change\n", changedFiles) 131 | } else { 132 | fmt.Printf("done: %d file(s) changed\n", changedFiles) 133 | } 134 | } 135 | 136 | func parseCommaSet(s string, keepDot bool) map[string]bool { 137 | out := map[string]bool{} 138 | s = strings.TrimSpace(s) 139 | if s == "" { 140 | return out 141 | } 142 | parts := strings.Split(s, ",") 143 | for _, p := range parts { 144 | p = strings.TrimSpace(p) 145 | if p == "" { 146 | continue 147 | } 148 | p = strings.ToLower(p) 149 | if keepDot && !strings.HasPrefix(p, ".") { 150 | p = "." + p 151 | } 152 | out[p] = true 153 | } 154 | return out 155 | } 156 | 157 | func processFile(path, search, repl string, cfg config) (bool, error) { 158 | st, err := os.Stat(path) 159 | if err != nil { 160 | return false, err 161 | } 162 | if st.Size() > cfg.maxSize { 163 | return false, fmt.Errorf("skipping (size %d > max %d)", st.Size(), cfg.maxSize) 164 | } 165 | 166 | isBin, err := looksBinary(path) 167 | if err != nil { 168 | return false, err 169 | } 170 | if isBin { 171 | return false, fmt.Errorf("skipping binary-like file") 172 | } 173 | 174 | b, err := os.ReadFile(path) 175 | if err != nil { 176 | return false, err 177 | } 178 | 179 | var out []byte 180 | if cfg.caseIns { 181 | re, err := regexp.Compile("(?i)" + regexp.QuoteMeta(search)) 182 | if err != nil { 183 | return false, err 184 | } 185 | out = []byte(re.ReplaceAllString(string(b), repl)) 186 | } else { 187 | out = bytes.ReplaceAll(b, []byte(search), []byte(repl)) 188 | } 189 | 190 | if bytes.Equal(out, b) { 191 | return false, nil 192 | } 193 | 194 | if cfg.dryRun { 195 | fmt.Printf("would change: %s\n", path) 196 | return true, nil 197 | } 198 | 199 | if err := atomicWrite(path, out, st.Mode()); err != nil { 200 | return false, err 201 | } 202 | 203 | fmt.Printf("changed: %s\n", path) 204 | return true, nil 205 | } 206 | 207 | func looksBinary(path string) (bool, error) { 208 | f, err := os.Open(path) 209 | if err != nil { 210 | return false, err 211 | } 212 | defer f.Close() 213 | 214 | const peek = 8192 215 | buf := make([]byte, peek) 216 | n, err := f.Read(buf) 217 | if err != nil && err != io.EOF { 218 | return false, err 219 | } 220 | for i := 0; i < n; i++ { 221 | if buf[i] == 0 { 222 | return true, nil 223 | } 224 | } 225 | return false, nil 226 | } 227 | 228 | func createTempSibling(path string) (string, *os.File, error) { 229 | dir := filepath.Dir(path) 230 | base := filepath.Base(path) 231 | tmp, err := os.CreateTemp(dir, "."+base+".bfs-*") 232 | if err != nil { 233 | return "", nil, err 234 | } 235 | return tmp.Name(), tmp, nil 236 | } 237 | 238 | func atomicWrite(path string, data []byte, mode os.FileMode) error { 239 | tmpPath, tmpFile, err := createTempSibling(path) 240 | if err != nil { 241 | return err 242 | } 243 | 244 | // Always clean up temp on failure. 245 | defer func() { 246 | tmpFile.Close() 247 | _ = os.Remove(tmpPath) 248 | }() 249 | 250 | if _, err := tmpFile.Write(data); err != nil { 251 | return err 252 | } 253 | if err := tmpFile.Chmod(mode); err != nil { 254 | // best-effort 255 | } 256 | if err := tmpFile.Close(); err != nil { 257 | return err 258 | } 259 | return os.Rename(tmpPath, path) 260 | } 261 | --------------------------------------------------------------------------------