Skip to content

Find & Replace — Swift 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 Swift 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: Swift (5.9+, Foundation's NSRegularExpression)
// 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 escaped with
// escapedPattern(for:) and matched verbatim; an isRegex find is compiled
// as-is. \b wraps the pattern when wholeWord is set, and NSRegularExpression
// composes .caseInsensitive and .anchorsMatchLines (the JS i / m flags).
// The throwing initializer's error is surfaced as the error string (the
// lib never traps), and an empty find is a no-op.
//
// Replacement $-substitution is implemented in expandMatch (not
// NSRegularExpression's template 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 $' are intentionally unsupported.
//
// NSRegularExpression works in UTF-16 space, so this port runs the loop on
// NSString ranges (the idiomatic Foundation dance) and converts once at
// the end.

import Foundation

/// Options mirror the TypeScript lib's FindReplaceOptions field for field.
struct FindReplaceOptions {
    var isRegex = false
    var caseSensitive = true
    var wholeWord = false
    var global = true
    var multiline = false
}

struct FindReplaceResult {
    var result: String
    var matches: Int
    var error: String?
}

/// Escape metacharacters for literals, wrap \b..\b for whole-word.
func buildPattern(_ find: String, _ o: FindReplaceOptions) -> String {
    var pattern = o.isRegex ? find : NSRegularExpression.escapedPattern(for: find)
    if o.wholeWord {
        pattern = "\\b\(pattern)\\b"
    }
    return pattern
}

func isAsciiDigit(_ c: Character) -> Bool {
    if let v = c.wholeNumberValue, c.isASCII, (0...9).contains(v) {
        return true
    }
    return false
}

/// 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).
/// A group that did not participate has location NSNotFound and yields "",
/// as in JS.
func expandMatch(_ template: String, _ m: NSTextCheckingResult, _ input: NSString) -> String {
    let numGroups = m.numberOfRanges - 1
    func group(_ n: Int) -> String {
        guard n <= numGroups else { return "" }
        let r = m.range(at: n)
        if r.location == NSNotFound { return "" }
        return input.substring(with: r)
    }
    let chars = Array(template)
    var out = ""
    var i = 0
    while i < chars.count {
        let c = chars[i]
        if c != "$" {
            out.append(c)
            i += 1
            continue
        }
        guard i + 1 < chars.count else {
            out.append("$")
            i += 1
            continue
        }
        let n = chars[i + 1]
        if n == "$" {
            out.append("$")
            i += 2
        } else if n == "&" {
            out.append(group(0))
            i += 2
        } else if isAsciiDigit(n), let d1 = n.wholeNumberValue {
            // Greedily try a second digit ($nn), matching JS.
            var two: Int?
            if i + 2 < chars.count, isAsciiDigit(chars[i + 2]),
               let d2c = chars[i + 2].wholeNumberValue {
                two = d1 * 10 + d2c
            }
            if let d2 = two, (1...numGroups).contains(d2) {
                out.append(group(d2))
                i += 3
                continue
            }
            if (1...numGroups).contains(d1) {
                out.append(group(d1))
                i += 2
            } else {
                out.append("$")
                out.append(n)
                i += 2
            }
        } else {
            out.append("$")
            i += 1
        }
    }
    return out
}

func findReplace(
    _ input: String,
    _ find: String,
    _ replacement: String,
    _ o: FindReplaceOptions = FindReplaceOptions()
) -> FindReplaceResult {
    if find.isEmpty {
        return FindReplaceResult(result: input, matches: 0, error: nil) // empty find is a no-op
    }

    var opts = NSRegularExpression.Options()
    if !o.caseSensitive { opts.insert(.caseInsensitive) }
    if o.isRegex && o.multiline { opts.insert(.anchorsMatchLines) }

    let re: NSRegularExpression
    do {
        re = try NSRegularExpression(pattern: buildPattern(find, o), options: opts)
    } catch let error as NSError {
        return FindReplaceResult(result: input, matches: 0, error: error.localizedDescription)
    }
    let numGroups = re.numberOfCaptureGroups

    let nsInput = input as NSString
    let full = NSRange(location: 0, length: nsInput.length)
    // matches(in:options:range:) guarantees forward progress on empty matches.
    let allMatches = re.matches(in: input, options: [], range: full)

    let out = NSMutableString()
    var last = 0
    var matches = 0
    for m in allMatches {
        let r = m.range // group 0
        out.append(nsInput.substring(with: NSRange(location: last, length: r.location - last)))
        out.append(expandMatch(replacement, m, nsInput))
        last = r.location + r.length
        matches += 1
        if !o.global { break }
    }
    out.append(nsInput.substring(with: NSRange(location: last, length: nsInput.length - last)))

    if matches != 0 && !o.global {
        matches = 1 + numGroups // JS String.match length quirk
    }
    return FindReplaceResult(result: out as String, matches: matches, error: nil)
}

// Top-level demo — run as main.swift (swift main.swift).
let demo = findReplace("Hello World world", "world", "Universe",
                       FindReplaceOptions(caseSensitive: false))
if let err = demo.error {
    print("error: \(err)")
} else {
    print("\(demo.result)  (\(demo.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 →