Find & Replace — PHP 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 PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
/**
* Find & replace with literal or regex matching, $-substitution
* ($1 backrefs, $&, $$), case sensitivity, whole-word, and global modes.
*
* Language: PHP
* 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 (preg_quote)
* and matched verbatim; an isRegex find is compiled as-is (its `/` delimiter
* escaped so arbitrary patterns embed safely). \b wraps the pattern when
* wholeWord is set, and the trailing pattern flags compose i
* (case-insensitive) and m (multiline, regex mode only) — the JS flag bits.
* Invalid patterns are reported as an error string and an empty find is a
* no-op; preg never throws on bad input.
*
* Replacement $-substitution is implemented in expandReplacement (not via
* preg's native $-syntax) 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 not supported.
*/
declare(strict_types=1);
const DEFAULT_OPTIONS = [
'isRegex' => false,
'caseSensitive' => true,
'wholeWord' => false,
'global' => true,
'multiline' => false,
];
/**
* Escape the chosen PCRE delimiter (`/`) when it appears unescaped in a
* user-supplied regex, so the pattern embeds safely inside /.../.
*/
function escapeRegexDelimiter(string $pattern): string
{
return preg_replace('~(?<!\\\\)/~', '\\\\/', $pattern);
}
/**
* Compile the find expression into a /pattern/flags string, applying
* whole-word + flag modifiers. Returns ['pattern' => string] on success or
* ['error' => string] on invalid syntax (the lib's buildRegex return).
*/
function buildRegex(string $find, array $o): array
{
$inner = $o['isRegex']
? escapeRegexDelimiter($find)
: preg_quote($find, '/'); // preg_quote also escapes the delimiter.
if ($o['wholeWord']) {
$inner = '\\b' . $inner . '\\b';
}
$flags = '';
if (!$o['caseSensitive']) {
$flags .= 'i';
}
if ($o['isRegex'] && $o['multiline']) {
$flags .= 'm';
}
$pattern = '/' . $inner . '/' . $flags;
// Compile-check: preg_match on '' returns false on an invalid pattern.
if (@preg_match($pattern, '') === false) {
$err = error_get_last();
return ['error' => $err['message'] ?? 'invalid regular expression'];
}
return ['pattern' => $pattern];
}
/**
* 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).
*
* Operates byte-wise; UTF-8 continuation bytes never collide with the ASCII
* $ / digit checks, so multibyte subjects are copied through faithfully.
*/
function expandReplacement(string $template, string $whole, array $groups, int $numGroups): string
{
$out = '';
$len = strlen($template);
$i = 0;
while ($i < $len) {
$c = $template[$i];
if ($c !== '$') {
$out .= $c;
$i++;
continue;
}
$n = ($i + 1 < $len) ? $template[$i + 1] : '';
if ($n === '$') {
$out .= '$';
$i += 2;
} elseif ($n === '&') {
$out .= $whole;
$i += 2;
} elseif ($n >= '0' && $n <= '9') {
$d1 = (int) $n;
// Greedily try a second digit ($nn), matching JS.
if ($i + 2 < $len && $template[$i + 2] >= '0' && $template[$i + 2] <= '9') {
$d2 = $d1 * 10 + (int) $template[$i + 2];
if ($d2 >= 1 && $d2 <= $numGroups) {
$out .= $groups[$d2];
$i += 3;
continue;
}
}
if ($d1 >= 1 && $d1 <= $numGroups) {
$out .= $groups[$d1];
$i += 2;
} else {
$out .= '$' . $n;
$i += 2;
}
} else {
$out .= '$';
$i++;
}
}
return $out;
}
/**
* Replace occurrences of $find with $replacement. Never throws.
*
* @return array{result:string,matches:int,error:?string}
*/
function findReplace(string $input, string $find, string $replacement, array $optsIn = []): array
{
$o = array_merge(DEFAULT_OPTIONS, $optsIn);
if ($find === '') {
return ['result' => $input, 'matches' => 0, 'error' => null];
}
$built = buildRegex($find, $o);
if (isset($built['error'])) {
return ['result' => $input, 'matches' => 0, 'error' => $built['error']];
}
$pattern = $built['pattern'];
// PREG_UNMATCHED_AS_NULL fills every capture slot (matched or not), so
// count($probe) - 1 is the pattern's true group count.
$matched = @preg_match($pattern, $input, $probe, PREG_UNMATCHED_AS_NULL) === 1;
$numGroups = $matched ? (count($probe) - 1) : 0;
$expander = static function (array $m) use ($replacement, $numGroups): string {
$groups = [$m[0]];
for ($n = 1; $n <= $numGroups; $n++) {
$groups[] = $m[$n] ?? '';
}
return expandReplacement($replacement, $m[0], $groups, $numGroups);
};
$limit = $o['global'] ? -1 : 1;
$result = preg_replace_callback(
$pattern,
$expander,
$input,
$limit,
$_,
PREG_UNMATCHED_AS_NULL
);
if ($o['global']) {
$matches = (int) preg_match_all($pattern, $input);
} else {
$matches = $matched ? (1 + $numGroups) : 0; // JS String.match quirk.
}
return ['result' => (string) $result, 'matches' => $matches, 'error' => null];
}
// CLI demo: `php find-replace.php`.
$r = findReplace('Hello World world', 'world', 'Universe', ['caseSensitive' => false]);
if ($r['error'] !== null) {
echo "error: ", $r['error'], PHP_EOL;
} else {
echo "{$r['result']} ({$r['matches']} matches)", PHP_EOL;
}
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 →