Building a Text Replace Tool: Regex and String Processing
Building a Text Replace Tool: Regex and String Processing#
Recently, I needed to implement a batch replace feature supporting both plain text and regex modes. Seemed simple, but I ran into some interesting gotchas. Here’s what I learned.
Core Requirements#
A text replace tool typically needs:
- Plain text replace: Direct string search and replace
- Regex replace: Pattern matching with regular expressions
- Case sensitivity: Respect or ignore case
- Replace all vs. first: Replace all matches or just the first one
The Plain Text Trap#
The simplest approach is String.prototype.replace():
const result = input.replace(searchText, replaceText)
But this only replaces the first match. To replace all, many reach for regex:
const regex = new RegExp(searchText, 'g')
const result = input.replace(regex, replaceText)
Here’s the trap: If searchText contains regex special characters (., *, ?, $, etc.), it breaks or matches incorrectly.
The Fix: Escape Special Characters#
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const escaped = escapeRegExp(searchText)
const regex = new RegExp(escaped, 'gi')
const result = input.replace(regex, replaceText)
\\$& means “prepend a backslash to the entire matched string.”
Implementing Case Sensitivity#
For case-insensitive matching, don’t just use toLowerCase()—you’ll lose the original formatting.
Approach 1: Regex i Flag#
const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(escaped, flags)
const result = input.replace(regex, replaceText)
The regex engine preserves the original case automatically.
Approach 2: Without Regex#
If you want to avoid regex for plain text:
if (caseSensitive) {
result = input.split(searchText).join(replaceText)
} else {
// Still need regex for case-insensitive
const regex = new RegExp(escaped, 'gi')
result = input.replace(regex, replaceText)
}
Regex Mode#
When the user chooses regex mode, construct the RegExp directly:
const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(searchText, flags)
const result = input.replace(regex, replaceText)
Handle errors gracefully:
try {
const regex = new RegExp(searchText, flags)
// execute replace
} catch (error) {
// Invalid regex, notify user
console.error('Invalid regex:', error.message)
}
Common errors: unclosed brackets, invalid quantifiers, invalid escape sequences.
Replacing Only the First Match#
replace() defaults to first-match-only (for non-regex or regex without g flag). But with the g flag, it replaces all.
Solution:
// No g flag
const flags = caseSensitive ? '' : 'i'
const regex = new RegExp(searchText, flags)
const result = input.replace(regex, replaceText)
Or more explicit control:
if (replaceAll) {
const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(searchText, flags)
result = input.replace(regex, replaceText)
} else {
const flags = caseSensitive ? '' : 'i'
const regex = new RegExp(searchText, flags)
result = input.replace(regex, replaceText)
}
Counting Matches#
Showing match count is helpful for users:
function countMatches(input, searchText, useRegex, caseSensitive) {
if (!searchText) return 0
if (useRegex) {
const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(searchText, flags)
return (input.match(regex) || []).length
} else {
const escaped = escapeRegExp(searchText)
const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(escaped, flags)
return (input.match(regex) || []).length
}
}
Note: String.prototype.match() returns null, so default to an empty array.
Complete Implementation#
interface ReplaceOptions {
input: string
searchText: string
replaceText: string
useRegex: boolean
caseSensitive: boolean
replaceAll: boolean
}
function replaceText(options: ReplaceOptions): { result: string; count: number } {
const { input, searchText, replaceText, useRegex, caseSensitive, replaceAll } = options
if (!input || !searchText) {
return { result: input, count: 0 }
}
try {
const gFlag = replaceAll ? 'g' : ''
const iFlag = caseSensitive ? '' : 'i'
const flags = gFlag + iFlag
const pattern = useRegex ? searchText : escapeRegExp(searchText)
const regex = new RegExp(pattern, flags)
const count = (input.match(new RegExp(pattern, 'g' + iFlag)) || []).length
const result = input.replace(regex, replaceText)
return { result, count }
} catch (error) {
return { result: input, count: 0 }
}
}
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
Performance Optimization#
For large text, recalculating on every keystroke is slow. Use useMemo to cache results:
const result = useMemo(() => {
return replaceText({ input, searchText, replaceText, useRegex, caseSensitive, replaceAll })
}, [input, searchText, replaceText, useRegex, caseSensitive, replaceAll])
Or debounce:
const debouncedReplace = useMemo(
() => debounce((value: string) => {
// execute replace logic
}, 300),
[]
)
Edge Cases#
Watch out for:
- Empty search text: Return original text
- Empty replacement: Equivalent to deleting matches
- Capture group references:
$1,$2backreferences in regex - Special replacement patterns:
$&(match),$\`` (before match),$’` (after match) - Cross-platform newlines:
\r\nvs\n
The Result#
Based on these ideas, I built: Text Replace Tool
Features:
- Plain text and regex modes
- Case sensitivity toggle
- Replace all or first match
- Real-time match count
- Friendly error messages
The implementation isn’t complex, but getting the details right takes effort. Hope this helps.
Related: Regex Tester | String Escape Tool