From 695a0f00cf4220be26281b5a7685c07d8a0f1037 Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Sun, 31 May 2026 19:39:04 +0800 Subject: [PATCH 1/2] feat: add shared regex match/replace engine Introduce Pattern with literal mode (QuoteMeta) wired into content and rename paths so capture-group replacement can land without changing CLI behavior today. Fixes #37 --- find_replace.go | 31 +++++++++++++-- pattern.go | 42 ++++++++++++++++++++ pattern_test.go | 102 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 pattern.go create mode 100644 pattern_test.go diff --git a/find_replace.go b/find_replace.go index 9bdbe79..2bebc25 100644 --- a/find_replace.go +++ b/find_replace.go @@ -7,7 +7,6 @@ import ( "log" "os" "path/filepath" - "strings" "sync" ) @@ -16,6 +15,7 @@ import ( type findReplace struct { find string replace string + 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 +57,19 @@ 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, true) + 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. // @@ -83,6 +96,10 @@ func run(args []string, stderr io.Writer) int { } fr := findReplace{find: args[1], replace: args[2]} + 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 +195,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 +220,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) } diff --git a/pattern.go b/pattern.go new file mode 100644 index 0000000..9edddba --- /dev/null +++ b/pattern.go @@ -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) +} diff --git a/pattern_test.go b/pattern_test.go new file mode 100644 index 0000000..09c21c4 --- /dev/null +++ b/pattern_test.go @@ -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\w+) (?P\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") + } +} From 6c2aa4962a94f43c6ead51ff503bc140967a4f74 Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Sun, 31 May 2026 19:45:47 +0800 Subject: [PATCH 2/2] feat: add -e/--regex opt-in pattern mode Expose regex matching via -e/--regex while keeping literal mode as the default. Invalid patterns fail at startup with a compile error. Depends on #37; merge after #35 and #36 land. v2.0.0 docs/version bump follows separately. Fixes #38 --- find_replace.go | 32 ++++++++++++++--- find_replace_test.go | 86 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/find_replace.go b/find_replace.go index 2bebc25..949395a 100644 --- a/find_replace.go +++ b/find_replace.go @@ -15,6 +15,7 @@ import ( type findReplace struct { find string replace string + regex bool pattern *Pattern // errs accumulates non-fatal errors that occurred during a walk. The @@ -57,12 +58,11 @@ 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, true) + p, err := Compile(fr.find, fr.replace, !fr.regex) if err != nil { return err } @@ -82,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 + 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 @@ -90,12 +111,13 @@ 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 diff --git a/find_replace_test.go b/find_replace_test.go index 9a54564..06a84de 100644 --- a/find_replace_test.go +++ b/find_replace_test.go @@ -641,11 +641,95 @@ func TestRun_BadArgCountPrintsUsage(t *testing.T) { if got == 0 { t.Errorf("run = 0; want non-zero") } - if !strings.Contains(stderr.String(), "Usage: find-replace") { + if !strings.Contains(stderr.String(), "usage: find-replace") { t.Errorf("stderr = %q; want it to contain a usage line", stderr.String()) } } +func TestParseRunArgs_RegexFlag(t *testing.T) { + tests := []struct { + args []string + wantFind string + wantReplace string + wantRegex bool + wantErr bool + }{ + {[]string{"find-replace", "a", "b"}, "a", "b", false, false}, + {[]string{"find-replace", "-e", "a", "b"}, "a", "b", true, false}, + {[]string{"find-replace", "--regex", "a", "b"}, "a", "b", true, false}, + {[]string{"find-replace", "-e"}, "", "", false, true}, + {[]string{"find-replace", "-e", "only"}, "", "", false, true}, + } + for _, tc := range tests { + find, replace, regex, err := parseRunArgs(tc.args) + if tc.wantErr { + if err == nil { + t.Errorf("parseRunArgs(%v) err = nil; want error", tc.args) + } + continue + } + if err != nil { + t.Fatalf("parseRunArgs(%v) err = %v", tc.args, err) + } + if find != tc.wantFind || replace != tc.wantReplace || regex != tc.wantRegex { + t.Errorf("parseRunArgs(%v) = (%q, %q, %v); want (%q, %q, %v)", + tc.args, find, replace, regex, tc.wantFind, tc.wantReplace, tc.wantRegex) + } + } +} + +func TestRun_InvalidRegexExitsNonZero(t *testing.T) { + var stderr bytes.Buffer + got := run([]string{"find-replace", "-e", "[unclosed", "x"}, &stderr) + if got == 0 { + t.Fatalf("run = 0; want non-zero") + } + if !strings.Contains(stderr.String(), "compile pattern") { + t.Errorf("stderr = %q; want compile error", stderr.String()) + } +} + +func TestRun_RegexModeRewritesContent(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "alpha.txt"), []byte("foo123bar"), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + withWorkingDir(t, dir) + + var stderr bytes.Buffer + got := run([]string{"find-replace", "-e", `\d+`, "NUM"}, &stderr) + if got != 0 { + t.Fatalf("run = %d; want 0 (stderr: %q)", got, stderr.String()) + } + content, err := os.ReadFile(filepath.Join(dir, "alpha.txt")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(content) != "fooNUMbar" { + t.Errorf("content = %q; want fooNUMbar", string(content)) + } +} + +func TestRun_RegexModeRenamesFiles(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "item_test.go"), []byte("package main"), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + withWorkingDir(t, dir) + + var stderr bytes.Buffer + got := run([]string{"find-replace", "-e", `(\w+)_test\.go`, "$1.test.go"}, &stderr) + if got != 0 { + t.Fatalf("run = %d; want 0 (stderr: %q)", got, stderr.String()) + } + if _, err := os.Stat(filepath.Join(dir, "item.test.go")); err != nil { + t.Fatalf("Stat item.test.go: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "item_test.go")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("item_test.go still exists: %v", err) + } +} + // withWorkingDir chdirs to dir for the duration of the test and restores the // previous working directory at cleanup. func withWorkingDir(t *testing.T, dir string) {