Skip to content

Find & Replace — Python 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 Python 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: Python
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 (re.escape) and
matched verbatim; an isRegex find is compiled as-is. ``\\b`` wraps the
pattern when wholeWord is set, and re.compile composes re.IGNORECASE and
re.MULTILINE (the JS i / m flags). Invalid patterns raise re.error, caught
and returned as an error string (the lib never throws), and an empty find
is a no-op.

Replacement $-substitution is implemented in expand_replacement (not via
re.sub's native ``\\1`` 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 unsupported.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Optional


@dataclass
class Options:
    """Mirror of the TypeScript lib's FindReplaceOptions.

    ``global_`` is suffixed because ``global`` is a Python keyword.
    """

    is_regex: bool = False
    case_sensitive: bool = True
    whole_word: bool = False
    global_: bool = True
    multiline: bool = False


@dataclass
class FindReplaceResult:
    result: str = ""
    matches: int = 0
    error: Optional[str] = None


def _build_regex(find: str, o: Options):
    """Compile find with flag + whole-word modifiers.

    Returns a compiled pattern, or the engine's error string on invalid
    syntax (the lib's buildRegex return).
    """
    pattern = find if o.is_regex else re.escape(find)
    if o.whole_word:
        pattern = rf"\b{pattern}\b"
    flags = 0
    if not o.case_sensitive:
        flags |= re.IGNORECASE
    if o.is_regex and o.multiline:
        flags |= re.MULTILINE
    try:
        return re.compile(pattern, flags)
    except re.error as e:
        return str(e)


def _expand(template: str, whole: str, groups: list, num_groups: int) -> str:
    """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).
    """
    out: list = []
    i, n = 0, len(template)
    while i < n:
        c = template[i]
        if c != "$":
            out.append(c)
            i += 1
            continue
        nxt = template[i + 1] if i + 1 < n else ""
        if nxt == "$":
            out.append("$")
            i += 2
        elif nxt == "&":
            out.append(whole)
            i += 2
        elif nxt.isdigit():
            d1 = int(nxt)
            # Greedily try a second digit ($nn), matching JS.
            if i + 2 < n and template[i + 2].isdigit():
                d2 = d1 * 10 + int(template[i + 2])
                if 1 <= d2 <= num_groups:
                    out.append(groups[d2])
                    i += 3
                    continue
            if 1 <= d1 <= num_groups:
                out.append(groups[d1])
                i += 2
            else:
                out.append("$" + nxt)
                i += 2
        else:
            out.append("$")
            i += 1
    return "".join(out)


def find_replace(
    input: str,
    find: str,
    replacement: str,
    opts: Optional[Options] = None,
) -> FindReplaceResult:
    """Replace occurrences of ``find`` with ``replacement``. Never throws."""
    o = opts or Options()
    if find == "":
        return FindReplaceResult(result=input, matches=0, error=None)

    built = _build_regex(find, o)
    if isinstance(built, str):
        return FindReplaceResult(result=input, matches=0, error=built)
    pattern = built

    num_groups = pattern.groups  # explicit capture-group count (an int)

    matched = pattern.search(input) is not None

    def repl(m: "re.Match") -> str:
        groups = [m.group(0)] + [
            (m.group(n) or "") for n in range(1, num_groups + 1)
        ]
        return _expand(replacement, groups[0], groups, num_groups)

    # count=0 -> replace all (global); count=1 -> first match only.
    result = pattern.sub(repl, input, count=0 if o.global_ else 1)

    if not matched:
        matches = 0
    elif o.global_:
        matches = len(pattern.findall(input))
    else:
        matches = 1 + num_groups  # JS String.match length quirk.

    return FindReplaceResult(result=result, matches=matches, error=None)


if __name__ == "__main__":
    r = find_replace(
        "Hello World world", "world", "Universe",
        Options(case_sensitive=False),
    )
    if r.error is not None:
        print(f"error: {r.error}")
    else:
        print(f"{r.result}  ({r.matches} 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 →