Skip to content

Find & Replace — C# 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 C# 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: C# (12 / .NET 8+, standard library only)
// Source:   CosmoDev polyglot showcase port of the find-replace tool,
//           ported from src/lib/findReplace.ts (the canonical TypeScript
//           implementation).
// License:  display source — part of CosmoDev's polyglot tool pages.
//
// Mirrors the live lib: a literal find string is Regex.Escape'd and matched
// verbatim; an isRegex find is compiled as-is. \b wraps the pattern when
// wholeWord is set, and RegexOptions composes IgnoreCase and Multiline (the
// JS i / m flags). ArgumentException from an invalid pattern is caught and
// returned as the error string (the lib never throws), and an empty find
// is a no-op.
//
// Replacement $-substitution is implemented in ExpandMatch (not Regex.Replace
// substitution syntax, whose ${name} and out-of-range behavior differ) 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 $' are intentionally unsupported.

using System;
using System.Text;
using System.Text.RegularExpressions;

/// Options mirror the TypeScript lib's FindReplaceOptions field for field.
public sealed class FindReplaceOptions
{
    public bool IsRegex { get; init; }
    public bool CaseSensitive { get; init; } = true;
    public bool WholeWord { get; init; }
    public bool Global { get; init; } = true;
    public bool Multiline { get; init; }
}

public sealed record FindReplaceResult(string Result, int Matches, string? Error);

public static class FindReplace
{
    /// Escape metacharacters for literals, wrap \b..\b for whole-word.
    static string BuildPattern(string find, FindReplaceOptions o)
    {
        var pattern = o.IsRegex ? find : Regex.Escape(find);
        return o.WholeWord ? $@"\b{pattern}\b" : pattern;
    }

    public static FindReplaceResult Apply(
        string input, string find, string replacement, FindReplaceOptions? options = null)
    {
        var o = options ?? new FindReplaceOptions();
        if (find.Length == 0)
        {
            return new FindReplaceResult(input, 0, null); // empty find is a no-op
        }

        var flags = RegexOptions.None;
        if (!o.CaseSensitive) flags |= RegexOptions.IgnoreCase;
        if (o.IsRegex && o.Multiline) flags |= RegexOptions.Multiline;

        Regex re;
        try
        {
            re = new Regex(BuildPattern(find, o), flags);
        }
        catch (ArgumentException e)
        {
            return new FindReplaceResult(input, 0, e.Message);
        }

        int numGroups = re.GetGroupNumbers().Length - 1;

        var sb = new StringBuilder();
        int matches = 0;
        int last = 0;
        // NextMatch guarantees forward progress on empty matches.
        for (var m = re.Match(input); m.Success; m = m.NextMatch())
        {
            sb.Append(input, last, m.Index - last);
            sb.Append(ExpandMatch(replacement, m, numGroups));
            last = m.Index + m.Length;
            matches++;
            if (!o.Global)
            {
                break;
            }
        }
        sb.Append(input, last, input.Length - last);

        if (matches != 0 && !o.Global)
        {
            matches = 1 + numGroups; // JS String.match length quirk
        }
        return new FindReplaceResult(sb.ToString(), matches, null);
    }

    static bool IsAsciiDigit(char c) => c is >= '0' and <= '9';

    /// 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).
    /// Group.Value is "" for a group that did not participate, as in JS.
    static string ExpandMatch(string template, Match m, int numGroups)
    {
        var sb = new StringBuilder();
        int i = 0;
        while (i < template.Length)
        {
            char c = template[i];
            if (c != '$')
            {
                sb.Append(c);
                i++;
                continue;
            }
            char n = i + 1 < template.Length ? template[i + 1] : '\0';
            if (n == '$')
            {
                sb.Append('$');
                i += 2;
            }
            else if (n == '&')
            {
                sb.Append(m.Value);
                i += 2;
            }
            else if (IsAsciiDigit(n))
            {
                int d1 = n - '0';
                // Greedily try a second digit ($nn), matching JS.
                if (i + 2 < template.Length && IsAsciiDigit(template[i + 2]))
                {
                    int d2 = d1 * 10 + (template[i + 2] - '0');
                    if (d2 >= 1 && d2 <= numGroups)
                    {
                        sb.Append(m.Groups[d2].Value);
                        i += 3;
                        continue;
                    }
                }
                if (d1 >= 1 && d1 <= numGroups)
                {
                    sb.Append(m.Groups[d1].Value);
                    i += 2;
                }
                else
                {
                    sb.Append('$').Append(n);
                    i += 2;
                }
            }
            else
            {
                sb.Append('$');
                i++;
            }
        }
        return sb.ToString();
    }
}

public static class FindReplaceDemo
{
    public static void Main()
    {
        var r = FindReplace.Apply(
            "Hello World world", "world", "Universe",
            new FindReplaceOptions { CaseSensitive = false });
        Console.WriteLine(r.Error is null
            ? $"{r.Result}  ({r.Matches} matches)"
            : $"error: {r.Error}");
    }
}

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 →