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 →