Skip to content

Find & Replace — Kotlin 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 Kotlin 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: Kotlin (1.9+, JVM — java.util.regex via the Kotlin stdlib)
// 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 RegexOption composes IGNORE_CASE and MULTILINE (the
// JS i / m flags). PatternSyntaxException 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 Java's \1)
// 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 unsupported.

import java.util.regex.PatternSyntaxException

/** Options mirror the TypeScript lib's FindReplaceOptions field for field. */
data class FindReplaceOptions(
    val isRegex: Boolean = false,
    val caseSensitive: Boolean = true,
    val wholeWord: Boolean = false,
    val global: Boolean = true,
    val multiline: Boolean = false,
)

data class FindReplaceResult(
    val result: String,
    val matches: Int,
    val error: String? = null,
)

private fun Char.isAsciiDigit(): Boolean = this in '0'..'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).
 * groups[n] returns "" for a group that did not participate, as in JS. */
fun expandMatch(template: String, match: MatchResult): String {
    val numGroups = match.groups.size - 1
    fun group(n: Int): String = match.groups[n]?.value ?: ""
    val out = StringBuilder()
    var i = 0
    while (i < template.length) {
        val c = template[i]
        if (c != '$') {
            out.append(c)
            i++
            continue
        }
        val n = if (i + 1 < template.length) template[i + 1] else null
        when {
            n == '$' -> {
                out.append('$')
                i += 2
            }
            n == '&' -> {
                out.append(group(0))
                i += 2
            }
            n != null && n.isAsciiDigit() -> {
                val d1 = n - '0'
                // Greedily try a second digit ($nn), matching JS.
                if (i + 2 < template.length && template[i + 2].isAsciiDigit()) {
                    val d2 = d1 * 10 + (template[i + 2] - '0')
                    if (d2 in 1..numGroups) {
                        out.append(group(d2))
                        i += 3
                        continue
                    }
                }
                if (d1 in 1..numGroups) {
                    out.append(group(d1))
                    i += 2
                } else {
                    out.append('$').append(n)
                    i += 2
                }
            }
            else -> {
                out.append('$')
                i++
            }
        }
    }
    return out.toString()
}

fun findReplace(
    input: String,
    find: String,
    replacement: String,
    options: FindReplaceOptions = FindReplaceOptions(),
): FindReplaceResult {
    if (find.isEmpty()) return FindReplaceResult(input, 0) // empty find is a no-op

    val pattern = if (options.isRegex) find else Regex.escape(find)
    val wrapped = if (options.wholeWord) "\\b$pattern\\b" else pattern
    val opts = buildSet {
        if (!options.caseSensitive) add(RegexOption.IGNORE_CASE)
        if (options.isRegex && options.multiline) add(RegexOption.MULTILINE)
    }
    val re = try {
        Regex(wrapped, opts)
    } catch (e: PatternSyntaxException) {
        return FindReplaceResult(input, 0, e.message)
    }

    val out = StringBuilder()
    var matches = 0
    var numGroups = 0
    var last = 0
    // findAll guarantees forward progress on empty matches.
    for (m in re.findAll(input)) {
        numGroups = m.groups.size - 1
        out.append(input, last, m.range.first)
        out.append(expandMatch(replacement, m))
        last = m.range.last + 1
        matches++
        if (!options.global) break
    }
    out.append(input, last, input.length)

    if (matches != 0 && !options.global) {
        matches = 1 + numGroups // JS String.match length quirk
    }
    return FindReplaceResult(out.toString(), matches)
}

fun main() {
    val r = findReplace(
        "Hello World world", "world", "Universe",
        FindReplaceOptions(caseSensitive = false),
    )
    println(r.error?.let { "error: $it" } ?: "${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 →