Skip to content

Punycode Converter — Swift source

Convert internationalized domain names (IDN) between Unicode and Punycode (xn--) ACE form. RFC 3492 compliant, runs entirely in your browser, with a shareable link to your exact input.

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

// punycode - RFC 3492 Punycode encode/decode + IDNA2003 toASCII/toUnicode.
//
// Language:   Swift (5.x, no external dependencies)
// Source:     CosmoDev polyglot showcase port of the Punycode tool, ported from
//             src/lib/punycode.ts (the canonical TypeScript implementation) and
//             cli/punycode/punycode.go (the live Go CLI twin).
// License:    display source - part of CosmoDev's polyglot tool pages.
//
// Design goals:
//   - Pure + deterministic; encode never fails, decode returns nil on
//     malformed input.
//   - Functionally equivalent to the TS/Go reference: same inputs -> same
//     outputs.
//   - Self-contained: Foundation only. String.unicodeScalars yields the code
//     points, so astral characters (emoji, CJK extensions) are single
//     elements, matching Go runes and the TS code-point iteration; Int64
//     carries the RFC 3492 arithmetic with the 2^53-1
//     (Number.MAX_SAFE_INTEGER) overflow guard; code points beyond U+10FFFF
//     reject the label.

import Foundation

enum Punycode {
    private static let base: Int64 = 36
    private static let tmin: Int64 = 1
    private static let tmax: Int64 = 26
    private static let skew: Int64 = 38
    private static let damp: Int64 = 700
    private static let initialBias: Int64 = 72
    private static let initialN: Int64 = 128
    private static let acePrefix = "xn--"
    private static let maxInt: Int64 = 9_007_199_254_740_991 // 2^53-1 overflow guard

    /// Bias adaptation (RFC 3492 section 6.1).
    private static func adapt(_ delta: Int64, _ numpoints: Int64, _ firsttime: Bool) -> Int64 {
        var d = firsttime ? delta / damp : delta / 2
        d += d / numpoints
        var k: Int64 = 0
        while d > (base - tmin) * tmax / 2 {
            d /= base - tmin
            k += base
        }
        return k + (base - tmin + 1) * d / (d + skew)
    }

    /// Map a digit value (0-35) to its base-36 character (lowercase).
    private static func digitToChar(_ d: Int64) -> Character {
        d < 26
            ? Character(UnicodeScalar(UInt32(97 + d))!)
            : Character(UnicodeScalar(UInt32(48 + d - 26))!)
    }

    /// Map a character to its digit value (0-35), case-insensitive, or -1 when invalid.
    private static func charToDigit(_ c: Character) -> Int64 {
        guard let o = c.asciiValue else { return -1 }
        if o >= 97 && o <= 122 { return Int64(o - 97) }        // a-z
        if o >= 65 && o <= 90 { return Int64(o - 65) }         // A-Z
        if o >= 48 && o <= 57 { return Int64(o - 48 + 26) }    // 0-9
        return -1
    }

    /// True if the string contains any non-ASCII code point (>= 128).
    private static func hasNonAscii(_ s: String) -> Bool {
        s.unicodeScalars.contains { $0.value >= 128 }
    }

    /// Punycode-encode a single label (RFC 3492), no ACE prefix. Basic code
    /// points are emitted first, then a "-" delimiter (only if there was at
    /// least one), then the generalized-base-36 deltas.
    static func encodeLabel(_ input: String) -> String {
        let cps = input.unicodeScalars.map { Int64($0.value) } // astral chars are one element
        let length = Int64(cps.count)

        var output = ""
        var b: Int64 = 0
        for cp in cps where cp < 128 {
            output.unicodeScalars.append(UnicodeScalar(UInt32(cp))!)
            b += 1
        }
        if b > 0 { output.append("-") }

        var n = initialN, delta: Int64 = 0, bias = initialBias, h = b
        while h < length {
            var m = Int64.max // smallest code point in the input that is >= n
            for cp in cps where cp >= n && cp < m { m = cp }
            delta += (m - n) * (h + 1)
            n = m
            for cp in cps {
                if cp < n {
                    delta += 1
                } else if cp == n {
                    var q = delta
                    var k = base
                    while true {
                        let t = max(tmin, min(tmax, k - bias))
                        if q < t { break }
                        output.append(String(digitToChar(t + (q - t) % (base - t))))
                        q = (q - t) / (base - t)
                        k += base
                    }
                    output.append(String(digitToChar(q)))
                    bias = adapt(delta, h + 1, h == b)
                    delta = 0
                    h += 1
                }
            }
            delta += 1
            n += 1
        }

        return output
    }

    /// Punycode-decode a single label (RFC 3492). Returns nil when the input
    /// is malformed (invalid digit, truncated generalized number, non-ASCII in
    /// the basic portion, code point beyond U+10FFFF, or overflow).
    static func decodeLabel(_ input: String) -> String? {
        let chars = Array(input)
        let lastDash = chars.lastIndex(of: "-")
        var output: [Character] = []
        if let lastDash {
            for i in 0..<lastDash {
                if chars[i].asciiValue == nil || chars[i].unicodeScalars.count > 1 { return nil } // basic portion must be ASCII
                output.append(chars[i])
            }
        }
        let ext = lastDash != nil ? Array(chars[(lastDash! + 1)...]) : chars

        var n = initialN, i: Int64 = 0, bias = initialBias, pos = 0
        while pos < ext.count {
            let oldi = i
            var w: Int64 = 1
            var k = base
            while true {
                if pos >= ext.count { return nil } // truncated generalized number
                let digit = charToDigit(ext[pos])
                if digit < 0 { return nil } // invalid digit
                pos += 1
                if digit >= maxInt / w { return nil } // overflow guard
                i += digit * w
                let t = max(tmin, min(tmax, k - bias))
                if digit < t { break }
                w *= base - t
                k += base
            }
            bias = adapt(i - oldi, Int64(output.count) + 1, oldi == 0)
            let outLen = Int64(output.count) + 1
            n += i / outLen
            i %= outLen
            guard n <= 0x10FFFF, let scalar = UnicodeScalar(UInt32(n)) else { return nil }
            output.insert(Character(scalar), at: Int(i))
            i += 1
        }

        return String(output)
    }

    /// IDNA toASCII: lowercase the domain, ACE-encode ("xn--" + Punycode) any
    /// label containing a non-ASCII code point, leave ASCII-only labels
    /// untouched. Empty input returns empty.
    static func encode(_ domain: String) -> String {
        if domain.isEmpty { return "" }
        return domain.lowercased()
            .split(separator: ".", omittingEmptySubsequences: false) // keeps empty labels, like the TS split(".")
            .map { label in hasNonAscii(String(label)) ? acePrefix + encodeLabel(String(label)) : String(label) }
            .joined(separator: ".")
    }

    /// IDNA toUnicode: decode any "xn--" label (case-insensitive, prefix
    /// detected on the lowercased label), leave every other label untouched.
    /// Returns nil when any "xn--" label is invalid - the whole domain is
    /// rejected, matching IDNA semantics. Empty input returns empty.
    static func decode(_ domain: String) -> String? {
        if domain.isEmpty { return "" }
        var out: [String] = []
        for label in domain.split(separator: ".", omittingEmptySubsequences: false) {
            let label = String(label)
            if label.lowercased().hasPrefix(acePrefix) && label.count > acePrefix.count {
                guard let decoded = decodeLabel(String(label.dropFirst(acePrefix.count))) else { return nil }
                out.append(decoded)
            } else {
                out.append(label)
            }
        }
        return out.joined(separator: ".")
    }
}

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 →