Skip to content

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 →