Skip to content

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 →