Skip to content

Text Extractor — Swift source

Pull URLs, emails, IPv4/IPv6 addresses, hashes (MD5/SHA-1/SHA-256/SHA-512), and domains out of logs, headers, or any pasted text.

This is the Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.

// extract — pull URLs, emails, IPv4/IPv6 addresses, hashes, and domains
// out of arbitrary text (logs, headers, config).
//
// Language: Swift 5.9+ (Foundation)
// Source:   CosmoDev polyglot showcase port of the Extract tool, ported from
//           src/lib/extract.ts (the canonical TypeScript implementation) and
//           held in lock-step with its Go twin cli/extract/extract.go.
// License:  display source — part of CosmoDev's polyglot tool pages.
//
// Design goals:
//   - Pure + deterministic; never traps.
//   - Functionally equivalent to the TS/Go reference: same inputs -> same outputs.
//   - Foundation's NSRegularExpression (ICU) is the engine; every construct
//     used (`\b`, `(?:...)`, counted repetition) behaves exactly as it does
//     in the TS lib, so the pattern strings are copied verbatim — modulo
//     Swift string escaping, where a backslash is doubled ("\\b" is `\b`).
//
// Behavior: pull URLs, emails, IPv4/IPv6 addresses, hashes (md5/sha1/sha256/
// sha512 by length), and domains out of arbitrary text. Matches are deduped
// per type preserving first-occurrence order; an email also contributes its
// domain to the domain list when both email and domain are selected. An empty
// types array defaults to all six kinds.

import Foundation

/// One of the six canonical extraction kinds. Mirrors the TS `ExtractType`
/// union ('url' | 'email' | 'ipv4' | 'ipv6' | 'hash' | 'domain') and Go's
/// `Type`. Declaration order == the `EXTRACT_TYPES` display order, so
/// `allCases` doubles as the default selection.
enum ExtractKind: String, CaseIterable {
    case url, email, ipv4, ipv6, hash, domain
}

/// One field per kind — always all six, populated only for the selected
/// kinds (unselected kinds stay empty). Mirrors the TS
/// `Record<ExtractType, string[]>` and the Go twin's `Result`.
struct ExtractResult: Equatable {
    var url: [String] = []
    var email: [String] = []
    var ipv4: [String] = []
    var ipv6: [String] = []
    var hash: [String] = []
    var domain: [String] = []
}

enum Extractor {
    /// The per-kind patterns, compiled once and reused (static lets are
    /// lazily initialized and thread-safe — the LazyLock of this port).
    /// `try!` is sound: these are static literals known valid at compile
    /// time, the same justification as the Rust port's `.unwrap()`.
    /// In Swift string literals a backslash is escaped: `\b` is "\\b".
    private static let reURL = try! NSRegularExpression(pattern: "https?://[^\\s]+")
    private static let reEmail =
        try! NSRegularExpression(pattern: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")
    private static let reIPv4 =
        try! NSRegularExpression(pattern: "\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b")
    /// Intentionally permissive — a hex/colon run — post-filtered by `isIpv6`.
    private static let reIPv6 = try! NSRegularExpression(pattern: "[0-9a-fA-F:]+")
    /// md5 (32) / sha1 (40) / sha256 (64) / sha512 (128). `\b` keeps each
    /// length honest, so a 64-char run does not also match as a leading
    /// 32-char hash.
    private static let reHash = try! NSRegularExpression(
        pattern: "\\b[a-fA-F0-9]{32}\\b|\\b[a-fA-F0-9]{40}\\b|"
            + "\\b[a-fA-F0-9]{64}\\b|\\b[a-fA-F0-9]{128}\\b")
    private static let reDomain = try! NSRegularExpression(
        pattern: "\\b[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z]{2,})+\\b")

    /// ASCII hex digits only — the exact class behind `[0-9a-fA-F]`.
    private static let hexSet: Set<Character> = Set("0123456789abcdefABCDEF")

    /// Deduplicate, preserving first-occurrence order. The Go twin of the
    /// `uniq()` helper in src/lib/extract.ts.
    private static func uniq(_ values: [String]) -> [String] {
        var seen = Set<String>()
        var out: [String] = []
        for v in values where !seen.contains(v) {
            seen.insert(v)
            out.append(v)
        }
        return out
    }

    /// Every non-overlapping match of `re` in `text`, left to right — the
    /// ICU equivalent of JS `String.match` with the global flag and Go's
    /// `FindAllString`. NSRange counts UTF-16 units, so the string is
    /// bridged through NSString for slicing.
    private static func allMatches(_ re: NSRegularExpression, in text: String) -> [String] {
        let ns = text as NSString
        return re.matches(in: text, range: NSRange(location: 0, length: ns.length))
            .map { ns.substring(with: $0.range) }
    }

    /// A hex/colon run is a plausible IPv6: it has a colon AND either
    /// contains `::` (a compressed zero-run) or is exactly eight groups of
    /// 1-4 hex digits. Mirrors `isIpv6()` in src/lib/extract.ts.
    static func isIpv6(_ run: some StringProtocol) -> Bool {
        guard run.contains(":") else { return false }
        if run.contains("::") { return true }
        let groups = run.split(separator: ":", omittingEmptySubsequences: false)
        return groups.count == 8 && groups.allSatisfy { g in
            (1...4).contains(g.count) && g.allSatisfy(hexSet.contains)
        }
    }

    /// Domain part (after the last `@`) of a matched email. Mirrors
    /// `domainOf()` in src/lib/extract.ts (lastIndexOf('@') + slice).
    private static func domainOf(_ email: String) -> String {
        guard let at = email.lastIndex(of: "@") else { return email }
        return String(email[email.index(after: at)...])
    }

    /// Extract every occurrence of the given `types` (default: all six) from
    /// `input`. Returns an `ExtractResult` with one field per kind — always
    /// all six, populated only for the selected types (unselected kinds stay
    /// empty). Matches are deduped per kind, preserving first-occurrence
    /// order. An email also contributes its domain to the `domain` list when
    /// both `.email` and `.domain` are selected. Never traps; nil input is
    /// treated as empty text.
    static func extract(_ input: String?, types: [ExtractKind] = []) -> ExtractResult {
        let text = input ?? ""
        let selected = types.isEmpty ? ExtractKind.allCases : types
        func want(_ k: ExtractKind) -> Bool { selected.contains(k) }

        var out = ExtractResult()
        if want(.url) { out.url = uniq(allMatches(reURL, in: text)) }
        if want(.email) { out.email = uniq(allMatches(reEmail, in: text)) }
        if want(.ipv4) { out.ipv4 = uniq(allMatches(reIPv4, in: text)) }
        if want(.ipv6) {
            out.ipv6 = uniq(allMatches(reIPv6, in: text).filter(isIpv6))
        }
        if want(.hash) { out.hash = uniq(allMatches(reHash, in: text)) }
        if want(.domain) {
            var combined = allMatches(reDomain, in: text)
            // Cross-rule: an email also yields its domain in the domain list.
            if want(.email) {
                combined += allMatches(reEmail, in: text).map(domainOf)
            }
            out.domain = uniq(combined)
        }
        return out
    }
}

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 →