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] 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") + } +}