-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add -e/--regex opt-in pattern mode #101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,6 @@ import ( | |
| "log" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "sync" | ||
| ) | ||
|
|
||
|
|
@@ -16,6 +15,8 @@ import ( | |
| type findReplace struct { | ||
| find string | ||
| replace string | ||
| regex bool | ||
| pattern *Pattern | ||
|
|
||
| // errs accumulates non-fatal errors that occurred during a walk. The | ||
| // walker logs each error at the point of failure (preserving the | ||
|
|
@@ -57,6 +58,18 @@ func (a *errAccumulator) err() error { | |
| return errors.Join(a.errs...) | ||
| } | ||
|
|
||
| func (fr *findReplace) ensurePattern() error { | ||
| if fr.pattern != nil { | ||
| return nil | ||
| } | ||
| p, err := Compile(fr.find, fr.replace, !fr.regex) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| fr.pattern = p | ||
| return nil | ||
| } | ||
|
|
||
| // main processes command line arguments, builds the context struct, and begins | ||
| // the process of walking the current working directory. | ||
| // | ||
|
|
@@ -69,6 +82,27 @@ func main() { | |
| os.Exit(run(os.Args, os.Stderr)) | ||
| } | ||
|
|
||
| func parseRunArgs(args []string) (find, replace string, regex bool, err error) { | ||
| if len(args) < 3 { | ||
| return "", "", false, fmt.Errorf("usage: find-replace [-e|--regex] FIND REPLACE") | ||
| } | ||
| i := 1 | ||
| for i < len(args) { | ||
| switch args[i] { | ||
| case "-e", "--regex": | ||
| regex = true | ||
|
Comment on lines
+92
to
+93
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This adds the Useful? React with 👍 / 👎. |
||
| i++ | ||
| default: | ||
| goto done | ||
| } | ||
| } | ||
| done: | ||
| if len(args)-i != 2 { | ||
| return "", "", false, fmt.Errorf("usage: find-replace [-e|--regex] FIND REPLACE") | ||
| } | ||
| return args[i], args[i+1], regex, nil | ||
| } | ||
|
|
||
| // run is the testable body of main. It returns the process exit code: 0 on | ||
| // clean success, 1 if argument parsing failed or any traversal error was | ||
| // recorded. Output documented in the README (Renaming/Rewriting lines) still | ||
|
|
@@ -77,12 +111,17 @@ func run(args []string, stderr io.Writer) int { | |
| // Remove date/time from logging output. | ||
| log.SetFlags(0) | ||
|
|
||
| if len(args) != 3 { | ||
| fmt.Fprintln(stderr, "Usage: find-replace FIND REPLACE") | ||
| find, replace, regex, err := parseRunArgs(args) | ||
| if err != nil { | ||
| fmt.Fprintln(stderr, err) | ||
| return 1 | ||
| } | ||
|
|
||
| fr := findReplace{find: args[1], replace: args[2]} | ||
| fr := findReplace{find: find, replace: replace, regex: regex} | ||
| if err := fr.ensurePattern(); err != nil { | ||
| fmt.Fprintln(stderr, err) | ||
| return 1 | ||
| } | ||
|
|
||
| // Recursively explore the hierarchy depth first, rewrite files as needed, | ||
| // and rename files last (after we don't have to revisit them). | ||
|
|
@@ -178,7 +217,10 @@ func (fr *findReplace) HandleFile(f *File) error { | |
| // changes and (b) no file already exists at the destination. It returns an | ||
| // error if the destination is occupied or if the os.Rename itself fails. | ||
| func (fr *findReplace) RenameFile(f *File) error { | ||
| newBaseName := strings.ReplaceAll(f.Base(), fr.find, fr.replace) | ||
| if err := fr.ensurePattern(); err != nil { | ||
| return err | ||
| } | ||
| newBaseName := fr.pattern.Replace(f.Base()) | ||
| if f.Base() == newBaseName { | ||
| return nil | ||
| } | ||
|
|
@@ -200,13 +242,16 @@ func (fr *findReplace) RenameFile(f *File) error { | |
| // ReplaceContents rewrites the file at f if its contents contain the find | ||
| // string. Binary-looking files (where Read returns "") are skipped silently. | ||
| func (fr *findReplace) ReplaceContents(f *File) error { | ||
| if err := fr.ensurePattern(); err != nil { | ||
| return err | ||
| } | ||
| content, err := f.Read() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if !strings.Contains(content, fr.find) { | ||
| if !fr.pattern.Match(content) { | ||
| return nil | ||
| } | ||
| newContent := strings.ReplaceAll(content, fr.find, fr.replace) | ||
| newContent := fr.pattern.Replace(content) | ||
| return f.Write(newContent) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "regexp" | ||
| ) | ||
|
|
||
| // Pattern is the shared match/replace engine for file contents and renames. | ||
| // Literal mode escapes the pattern with regexp.QuoteMeta so existing | ||
| // find/replace semantics are preserved without exposing a regex flag yet. | ||
| type Pattern struct { | ||
| re *regexp.Regexp | ||
| replace string | ||
| literal bool | ||
| } | ||
|
|
||
| // Compile builds a Pattern. When literal is true, pattern is treated as a | ||
| // plain string; otherwise it is interpreted as RE2 syntax with $N capture | ||
| // references supported in replacement. | ||
| func Compile(pattern, replacement string, literal bool) (*Pattern, error) { | ||
| expr := pattern | ||
| if literal { | ||
| expr = regexp.QuoteMeta(pattern) | ||
| } | ||
| re, err := regexp.Compile(expr) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("compile pattern: %w", err) | ||
| } | ||
| return &Pattern{ | ||
| re: re, | ||
| replace: replacement, | ||
| literal: literal, | ||
| }, nil | ||
| } | ||
|
|
||
| func (p *Pattern) Match(s string) bool { | ||
| return p.re.MatchString(s) | ||
| } | ||
|
|
||
| func (p *Pattern) Replace(s string) string { | ||
| return p.re.ReplaceAllString(s, p.replace) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the default non-regex mode, replacements containing Useful? React with 👍 / 👎. |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestCompile_LiteralModeMatchesPlainString(t *testing.T) { | ||
| p, err := Compile("foo.bar", "X", true) | ||
| if err != nil { | ||
| t.Fatalf("Compile() err = %v", err) | ||
| } | ||
| if !p.Match("foo.bar") { | ||
| t.Error("Match() = false; want true for literal dot") | ||
| } | ||
| if p.Match("fooXbar") { | ||
| t.Error("Match() = true; want false") | ||
| } | ||
| if got := p.Replace("foo.bar baz"); got != "X baz" { | ||
| t.Errorf("Replace() = %q; want %q", got, "X baz") | ||
| } | ||
| } | ||
|
|
||
| func TestCompile_LiteralModeEquivalentToReplaceAll(t *testing.T) { | ||
| cases := []struct { | ||
| find, replace, input string | ||
| }{ | ||
| {"alpha", "beta", "alpha alpha"}, | ||
| {"(parens)", "[brackets]", "file (parens).txt"}, | ||
| {"$dollar", "cent", "cost $dollar"}, | ||
| {"a.b", "ab", "a.b.c"}, | ||
| } | ||
| for _, tc := range cases { | ||
| p, err := Compile(tc.find, tc.replace, true) | ||
| if err != nil { | ||
| t.Fatalf("Compile(%q): %v", tc.find, err) | ||
| } | ||
| want := strings.ReplaceAll(tc.input, tc.find, tc.replace) | ||
| if got := p.Replace(tc.input); got != want { | ||
| t.Errorf("Replace(%q, %q) on %q = %q; want %q", tc.find, tc.replace, tc.input, got, want) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestCompile_CaptureGroups(t *testing.T) { | ||
| p, err := Compile(`(\w+)@(\w+)\.(\w+)`, "$2.$1@$3", false) | ||
| if err != nil { | ||
| t.Fatalf("Compile() err = %v", err) | ||
| } | ||
| got := p.Replace("alice@example.com") | ||
| if got != "example.alice@com" { | ||
| t.Errorf("Replace() = %q; want example.alice@com", got) | ||
| } | ||
| } | ||
|
|
||
| func TestCompile_NamedCaptureGroup(t *testing.T) { | ||
| p, err := Compile(`(?P<first>\w+) (?P<last>\w+)`, "${last}, ${first}", false) | ||
| if err != nil { | ||
| t.Fatalf("Compile() err = %v", err) | ||
| } | ||
| got := p.Replace("Ada Lovelace") | ||
| if got != "Lovelace, Ada" { | ||
| t.Errorf("Replace() = %q; want Lovelace, Ada", got) | ||
| } | ||
| } | ||
|
|
||
| func TestCompile_ReplacementEscapes(t *testing.T) { | ||
| p, err := Compile(`(\d+)`, "$$1 cents", false) | ||
| if err != nil { | ||
| t.Fatalf("Compile() err = %v", err) | ||
| } | ||
| if got := p.Replace("42"); got != "$1 cents" { | ||
| t.Errorf("Replace() = %q; want %q", got, "$1 cents") | ||
| } | ||
| } | ||
|
|
||
| func TestCompile_InvalidPatternReturnsError(t *testing.T) { | ||
| _, err := Compile("[unclosed", "x", false) | ||
| if err == nil { | ||
| t.Fatal("Compile() err = nil; want error") | ||
| } | ||
| } | ||
|
|
||
| func BenchmarkPatternLiteralReplace(b *testing.B) { | ||
| input := strings.Repeat("alpha beta gamma ", 1000) | ||
| p, err := Compile("alpha", "beta", true) | ||
| if err != nil { | ||
| b.Fatal(err) | ||
| } | ||
| b.ResetTimer() | ||
| for i := 0; i < b.N; i++ { | ||
| _ = p.Replace(input) | ||
| } | ||
| } | ||
|
|
||
| func BenchmarkStringsReplaceAllLiteral(b *testing.B) { | ||
| input := strings.Repeat("alpha beta gamma ", 1000) | ||
| b.ResetTimer() | ||
| for i := 0; i < b.N; i++ { | ||
| _ = strings.ReplaceAll(input, "alpha", "beta") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
findReplaceis used directly withoutrun's eagerensurePatterncall, as the existing tests and benchmark constructors do,WalkDirstarts multiple goroutines and each can callensurePatternthroughHandleFile, racing onfr.pattern. This violates the repo's requiredgo test -race ./...discipline and can be fixed by compiling before fan-out or protecting initialization withsync.Once/a mutex.Useful? React with 👍 / 👎.