Skip to content

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 →