Find & Replace — Rust 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 Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! Find & replace with literal or regex matching, $-substitution
//! ($1 backrefs, $&, $$), case sensitivity, whole-word, and global modes.
//!
//! Language: Rust
//! CosmoDev polyglot showcase port of the `find-replace` tool.
//! Ported from src/lib/findReplace.ts — display source, part of CosmoDev's
//! polyglot tool pages.
//!
//! Mirrors the live lib: a literal find string is regex-escaped and matched
//! verbatim; an isRegex find is compiled as-is. `\b` wraps the pattern when
//! wholeWord is set, and RegexBuilder composes case-insensitivity and
//! multiline (the JS i / m flags). Invalid patterns are reported as an error
//! string instead of panicking, and an empty find is a no-op.
//!
//! Rust ships no regex engine in the standard library, so this port uses
//! the `regex` crate — the de facto Rust regex implementation — for
//! compilation and matching only (Cargo.toml: `regex = "1"`). Replacement
//! $-substitution is implemented in `expand_match` so it matches
//! JavaScript's String.replace exactly for the realistic cases: `$$` -> `$`,
//! `$&` -> whole match, `$1`..`$99` -> capture group (literal "$<digits>"
//! when out of range). JS's $` and $' (text before/after the match) are
//! intentionally unsupported.
use regex::{Captures, Regex, RegexBuilder};
/// Options mirror the TypeScript lib's FindReplaceOptions field for field.
/// `global_` is suffixed because `global` is a reserved identifier.
#[derive(Clone, Copy)]
struct Options {
is_regex: bool,
case_sensitive: bool,
whole_word: bool,
global_: bool,
multiline: bool,
}
impl Default for Options {
fn default() -> Self {
Options {
is_regex: false,
case_sensitive: true,
whole_word: false,
global_: true,
multiline: false,
}
}
}
struct FindReplaceResult {
result: String,
matches: usize,
error: Option<String>,
}
const META: &[char] = &[
'.', '*', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\',
];
/// Escape regex metacharacters so a literal find string matches verbatim.
fn escape_regexp(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if META.contains(&c) {
out.push('\\');
}
out.push(c);
}
out
}
/// Compile the find expression with flag + whole-word modifiers. On
/// invalid syntax the engine's error string is returned (the lib's error
/// path), never a panic.
fn build_regex(find: &str, o: Options) -> Result<Regex, String> {
let mut pattern = if o.is_regex {
find.to_string()
} else {
escape_regexp(find)
};
if o.whole_word {
pattern = format!("\\b{}\\b", pattern);
}
RegexBuilder::new(&pattern)
.case_insensitive(!o.case_sensitive)
.multi_line(o.is_regex && o.multiline)
.build()
.map_err(|e| e.to_string())
}
/// Apply JS String.replace $-substitution for one match.
/// `$$` -> "$"; `$&` -> whole match; `$1`..`$99` -> capture group N
/// (literal "$<digits>" when N is out of range, matching JS).
fn expand_match(template: &str, whole: &str, groups: &[String], num_groups: usize) -> String {
let chars: Vec<char> = template.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] != '$' {
out.push(chars[i]);
i += 1;
continue;
}
let Some(&n) = chars.get(i + 1) else {
out.push('$');
i += 1;
continue;
};
match n {
'$' => {
out.push('$');
i += 2;
}
'&' => {
out.push_str(whole);
i += 2;
}
d if d.is_ascii_digit() => {
let d1 = (d as u8 - b'0') as usize;
// Greedily try a second digit ($nn), matching JS.
if let Some(&d2c) = chars.get(i + 2) {
if d2c.is_ascii_digit() {
let d2 = d1 * 10 + (d2c as u8 - b'0') as usize;
if d2 >= 1 && d2 <= num_groups {
out.push_str(&groups[d2]);
i += 3;
continue;
}
}
}
if d1 >= 1 && d1 <= num_groups {
out.push_str(&groups[d1]);
i += 2;
} else {
out.push('$');
out.push(d);
i += 2;
}
}
_ => {
out.push('$');
i += 1;
}
}
}
out
}
/// Gather [whole, g1..gN] from a Captures match; unmatched groups -> "".
fn gather(caps: &Captures, num_groups: usize) -> Vec<String> {
let mut g = Vec::with_capacity(num_groups + 1);
g.push(caps.get(0).map(|m| m.as_str().to_string()).unwrap_or_default());
for n in 1..=num_groups {
g.push(caps.get(n).map(|m| m.as_str().to_string()).unwrap_or_default());
}
g
}
fn find_replace(input: &str, find: &str, replacement: &str, opts: Options) -> FindReplaceResult {
if find.is_empty() {
return FindReplaceResult { result: input.to_string(), matches: 0, error: None };
}
let re = match build_regex(find, opts) {
Ok(r) => r,
Err(e) => {
return FindReplaceResult {
result: input.to_string(),
matches: 0,
error: Some(e),
}
}
};
// captures_len includes the implicit whole-match group (index 0).
let num_groups = re.captures_len().saturating_sub(1);
let mut iter = re.captures_iter(input);
let Some(first) = iter.next() else {
return FindReplaceResult { result: input.to_string(), matches: 0, error: None };
};
let mut out = String::new();
let mut last = 0usize;
let m0 = first.get(0).expect("capture group 0 always present");
out.push_str(&input[last..m0.start()]);
let groups = gather(&first, num_groups);
out.push_str(&expand_match(replacement, &groups[0], &groups, num_groups));
last = m0.end();
if opts.global_ {
for caps in iter {
let m = caps.get(0).expect("capture group 0 always present");
out.push_str(&input[last..m.start()]);
let groups = gather(&caps, num_groups);
out.push_str(&expand_match(replacement, &groups[0], &groups, num_groups));
last = m.end();
}
}
out.push_str(&input[last..]);
// Non-global count mirrors JS String.match: 1 + (capture-group count).
let matches = if opts.global_ {
re.find_iter(input).count()
} else {
1 + num_groups
};
FindReplaceResult { result: out, matches, error: None }
}
fn main() {
let r = find_replace(
"Hello World world",
"world",
"Universe",
Options { case_sensitive: false, ..Default::default() },
);
match r.error {
Some(e) => eprintln!("error: {e}"),
None => println!("{} ({} matches)", r.result, r.matches),
}
}
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 →