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 →