Find & Replace — Go source
Find and replace text with literal or regular-expression matching, global replace, case sensitivity, whole-word, and capture-group substitution. Live match counter.
This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package findreplace is the Go twin of CosmoDev's src/lib/findReplace.ts (dual
// source: the web lib is TypeScript, the CLI lib is Go — kept in lock-step).
// Pure + deterministic, never panics. The table-driven tests in
// find-replace_test.go share vectors with src/lib/findReplace.test.ts so the two
// implementations are held to the same contract.
//
// Behavior mirrors the TS lib exactly: literal or regex matching, optional
// case-insensitivity, whole-word boundaries, global vs first-only replacement,
// the regex multiline flag, JavaScript-style capture-group substitution, and
// never-throw error reporting.
package findreplace
import (
"regexp"
"strings"
)
// regExpMeta are the regexp metacharacters that must be backslash-escaped when
// matching a pattern literally. The same set as escapeRegExp in
// src/lib/findReplace.ts: . * + ? ^ $ { } ( ) | [ ] \
var regExpMeta = regexp.MustCompile(`[.*+?^${}()|[\]\\]`)
// EscapeRegExp escapes the regexp metacharacters in s so it matches literally.
// It is the Go twin of escapeRegExp() in src/lib/findReplace.ts.
func EscapeRegExp(s string) string {
return regExpMeta.ReplaceAllStringFunc(s, func(m string) string {
return "\\" + m
})
}
// Options configures FindReplace and CountMatches. The zero value (Options{})
// matches the TS lib's defaultOptions: isRegex=false, caseSensitive=true,
// wholeWord=false, global=true, multiline=false.
//
// CaseSensitive and Global are *bool so a nil value means the TS default (both
// true) while an explicit &false is honored — exactly like the TS lib's
// Partial<FindReplaceOptions> merge where omitted fields fall back to
// defaultOptions. The remaining flags already default to false on their own, so
// plain bools suffice for them.
type Options struct {
IsRegex bool
CaseSensitive *bool // nil → true (default); &false → case-insensitive
WholeWord bool
Global *bool // nil → true (default); &false → first match only
Multiline bool // regex mode only (mirrors the TS multiline flag)
}
// FindReplaceResult is the outcome of FindReplace. It is the Go twin of
// FindReplaceResult in src/lib/findReplace.ts. Error is the empty string when
// there is no error (TS uses error: string | null).
type FindReplaceResult struct {
Result string
Matches int
Error string
}
// CountResult is the outcome of CountMatches. It is the Go twin of the inline
// { matches, error } return type of countMatches() in src/lib/findReplace.ts.
// Error is the empty string when there is no error.
type CountResult struct {
Matches int
Error string
}
// caseSensitive resolves opts.CaseSensitive, defaulting to true (the TS default)
// when nil.
func caseSensitive(opts Options) bool {
if opts.CaseSensitive != nil {
return *opts.CaseSensitive
}
return true
}
// global resolves opts.Global, defaulting to true (the TS default) when nil.
func global(opts Options) bool {
if opts.Global != nil {
return *opts.Global
}
return true
}
// buildRegex compiles the find pattern into a *regexp.Regexp according to opts,
// applying the same escaping, whole-word wrapping, and flags as the TS lib's
// buildRegex. It returns an error (never a panic) when the pattern is invalid.
// JS flags map to RE2 inline groups: 'i' (case-insensitive) → (?i), 'm'
// (multiline) → (?m). The JS 'g' flag has no RE2 equivalent — global vs
// first-only is handled by the caller.
func buildRegex(find string, opts Options) (*regexp.Regexp, error) {
pattern := find
if !opts.IsRegex {
pattern = EscapeRegExp(find)
}
if opts.WholeWord {
pattern = `\b` + pattern + `\b`
}
flags := ""
if !caseSensitive(opts) {
flags += "i"
}
if opts.IsRegex && opts.Multiline {
flags += "m"
}
if flags != "" {
pattern = "(?" + flags + ")" + pattern
}
return regexp.Compile(pattern)
}
// matchCount mirrors String.prototype.match(regex).length semantics, which is
// what both countMatches() and findReplace() use to count matches in the TS lib:
//
// - With the global flag it counts every full match.
// - Without it, a present match yields 1 + (number of capturing groups) and no
// match yields 0 — exactly what str.match() returns for a non-global regexp
// (index 0 is the full match, followed by one slot per capture group).
func matchCount(re *regexp.Regexp, input string, isGlobal bool) int {
if isGlobal {
return len(re.FindAllString(input, -1))
}
if re.FindStringIndex(input) != nil {
return 1 + re.NumSubexp()
}
return 0
}
// CountMatches counts how many times find occurs in input under opts. It is the
// Go twin of countMatches() in src/lib/findReplace.ts. An empty find yields zero
// matches and no error; an invalid regex yields zero matches and a non-empty
// Error. It never panics.
func CountMatches(input, find string, opts Options) CountResult {
if find == "" {
return CountResult{Matches: 0}
}
re, err := buildRegex(find, opts)
if err != nil {
return CountResult{Matches: 0, Error: err.Error()}
}
return CountResult{Matches: matchCount(re, input, global(opts))}
}
// FindReplace replaces occurrences of find in input with replacement under opts.
// It is the Go twin of findReplace() in src/lib/findReplace.ts. An empty find is
// a no-op; an invalid regex leaves input unchanged and reports a non-empty
// Error. The replacement string follows JavaScript substitution rules: $$, $&,
// $`, $', and $1..$99 (capture groups). It never panics.
func FindReplace(input, find, replacement string, opts Options) FindReplaceResult {
if find == "" {
return FindReplaceResult{Result: input, Matches: 0}
}
re, err := buildRegex(find, opts)
if err != nil {
return FindReplaceResult{Result: input, Matches: 0, Error: err.Error()}
}
isGlobal := global(opts)
return FindReplaceResult{
Result: applyReplace(re, input, replacement, isGlobal),
Matches: matchCount(re, input, isGlobal),
}
}
// applyReplace mirrors String.prototype.replace(re, replacement): with the
// global flag every non-overlapping left-to-right match is replaced; without it
// only the first match is. The replacement is expanded using JS substitution
// rules via expandReplacement.
func applyReplace(re *regexp.Regexp, input, replacement string, isGlobal bool) string {
limit := -1 // all matches
if !isGlobal {
limit = 1
}
matches := re.FindAllStringSubmatchIndex(input, limit)
if matches == nil {
return input
}
var b strings.Builder
last := 0
for _, m := range matches {
b.WriteString(input[last:m[0]]) // text before the match
expandReplacement(&b, replacement, input, m)
last = m[1]
}
b.WriteString(input[last:]) // trailing text
return b.String()
}
// expandReplacement writes replacement to b, expanding JavaScript
// String.prototype.replace substitution tokens against the current match:
//
// - "$$" → "$"
// - "$&" → the matched substring (group 0)
// - "$`" → the portion of input before the match
// - "$'" → the portion of input after the match
// - "$n"/"$nn" → the nth/nnth capturing group (1-indexed). A two-digit
// reference is used only when it does not exceed the group count; otherwise
// the single-digit form is used. Out-of-range or "$0" leave a literal "$"
// followed by the digit(s), matching JS GetSubstitution.
//
// Unmatched optional groups expand to the empty string.
func expandReplacement(b *strings.Builder, replacement, input string, m []int) {
matched := input[m[0]:m[1]]
numGroups := len(m)/2 - 1
// capture returns the text of the nth (1-indexed) capturing group and whether
// n refers to a defined group slot. An unmatched group (indices -1, -1)
// yields "" — mirroring JS undefined → "".
capture := func(n int) string {
start, end := m[2*n], m[2*n+1]
if start < 0 {
return ""
}
return input[start:end]
}
i := 0
for i < len(replacement) {
c := replacement[i]
if c != '$' {
b.WriteByte(c)
i++
continue
}
// c == '$'
if i+1 >= len(replacement) {
b.WriteByte('$') // trailing "$" is literal
i++
continue
}
next := replacement[i+1]
switch {
case next == '$':
b.WriteByte('$')
i += 2
case next == '&':
b.WriteString(matched)
i += 2
case next == '`':
b.WriteString(input[:m[0]])
i += 2
case next == '\'':
b.WriteString(input[m[1]:])
i += 2
case next >= '0' && next <= '9':
n1 := int(next - '0')
n := n1
advance := 2
// Prefer a two-digit reference when it is a valid group index.
if i+2 < len(replacement) && replacement[i+2] >= '0' && replacement[i+2] <= '9' {
n2 := n1*10 + int(replacement[i+2]-'0')
if n2 <= numGroups {
n = n2
advance = 3
}
}
if n >= 1 && n <= numGroups {
b.WriteString(capture(n))
i += advance
} else {
// Out of range (including "$0"): literal "$"; the digit(s) are
// emitted on the following iterations.
b.WriteByte('$')
i++
}
default:
// "$" followed by a non-special character: literal "$".
b.WriteByte('$')
i++
}
}
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →