Punycode Converter — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.
// punycode - RFC 3492 Punycode encode/decode + IDNA2003 toASCII/toUnicode.
//
// Language: Kotlin (1.5+, JVM, standard library only)
// 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 null on
// malformed input.
// - Functionally equivalent to the TS/Go reference: same inputs -> same
// outputs.
// - Self-contained: stdlib only. String.codePoints() yields the code
// points, so astral characters (emoji, CJK extensions) are single
// elements, matching Go runes and the TS code-point iteration; Long
// carries the RFC 3492 arithmetic with the 2^53-1
// (Number.MAX_SAFE_INTEGER) overflow guard; code points beyond U+10FFFF
// reject the label.
private const val BASE = 36L
private const val TMIN = 1L
private const val TMAX = 26L
private const val SKEW = 38L
private const val DAMP = 700L
private const val INITIAL_BIAS = 72L
private const val INITIAL_N = 128L
private const val ACE_PREFIX = "xn--"
private const val MAX_INT = 9007199254740991L // 2^53-1 overflow guard
object Punycode {
/** Bias adaptation (RFC 3492 section 6.1). */
private fun adapt(delta: Long, numpoints: Long, firsttime: Boolean): Long {
var d = if (firsttime) delta / DAMP else delta / 2
d += d / numpoints
var k = 0L
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 fun digitToChar(d: Long): Char =
if (d < 26) ('a' + d.toInt()) else ('0' + (d - 26).toInt())
/** Map a character to its digit value (0-35), case-insensitive, or -1 when invalid. */
private fun charToDigit(c: Char): Long = when (c) {
in 'a'..'z' -> (c - 'a').toLong()
in 'A'..'Z' -> (c - 'A').toLong()
in '0'..'9' -> (c - '0' + 26).toLong()
else -> -1L
}
/** True if the string contains any non-ASCII code point (>= 128). */
private fun hasNonAscii(s: String): Boolean = s.any { it.code >= 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.
*/
fun encodeLabel(input: String): String {
val cps = input.codePoints().toArray() // code points: astral chars are one element
val length = cps.size.toLong()
val output = StringBuilder()
var b = 0L
for (cp in cps) {
if (cp < 128) {
output.append(cp.toChar())
b++
}
}
if (b > 0) output.append('-')
var n = INITIAL_N
var delta = 0L
var bias = INITIAL_BIAS
var h = b
while (h < length) {
var m = Long.MAX_VALUE // smallest code point in the input that is >= n
for (cp in cps) if (cp >= n && cp < m) m = cp.toLong()
delta += (m - n) * (h + 1)
n = m
for (cp in cps) {
if (cp < n) {
delta += 1
} else if (cp.toLong() == n) {
var q = delta
var k = BASE
while (true) {
val t = maxOf(TMIN, minOf(TMAX, k - bias))
if (q < t) break
output.append(digitToChar(t + (q - t) % (BASE - t)))
q = (q - t) / (BASE - t)
k += BASE
}
output.append(digitToChar(q))
bias = adapt(delta, h + 1, h == b)
delta = 0
h += 1
}
}
delta += 1
n += 1
}
return output.toString()
}
/**
* Punycode-decode a single label (RFC 3492). Returns null when the input
* is malformed (invalid digit, truncated generalized number, non-ASCII in
* the basic portion, code point beyond U+10FFFF, or overflow).
*/
fun decodeLabel(input: String): String? {
val lastDash = input.lastIndexOf('-')
val output = ArrayList<Int>()
if (lastDash >= 0) {
for (i in 0 until lastDash) {
if (input[i].code >= 128) return null // basic portion must be ASCII
output.add(input[i].code)
}
}
val ext = if (lastDash >= 0) input.substring(lastDash + 1) else input
var n = INITIAL_N
var i = 0L
var bias = INITIAL_BIAS
var pos = 0
while (pos < ext.length) {
val oldi = i
var w = 1L
var k = BASE
while (true) {
if (pos >= ext.length) return null // truncated generalized number
val digit = charToDigit(ext[pos])
if (digit < 0) return null // invalid digit
pos++
if (digit >= MAX_INT / w) return null // overflow guard
i += digit * w
val t = maxOf(TMIN, minOf(TMAX, k - bias))
if (digit < t) break
w *= BASE - t
k += BASE
}
bias = adapt(i - oldi, output.size + 1L, oldi == 0L)
val outLen = output.size + 1L
n += i / outLen
i %= outLen
if (n > 0x10FFFF) return null
output.add(i.toInt(), n.toInt())
i += 1
}
val sb = StringBuilder()
for (cp in output) sb.appendCodePoint(cp)
return sb.toString()
}
/**
* 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.
*/
fun encode(domain: String): String {
if (domain.isEmpty()) return ""
return domain.lowercase()
.split('.') // keeps empty labels, like the TS split('.')
.joinToString(".") { label -> if (hasNonAscii(label)) ACE_PREFIX + encodeLabel(label) else label }
}
/**
* IDNA toUnicode: decode any "xn--" label (case-insensitive, prefix
* detected on the lowercased label), leave every other label untouched.
* Returns null when any "xn--" label is invalid - the whole domain is
* rejected, matching IDNA semantics. Empty input returns empty.
*/
fun decode(domain: String): String? {
if (domain.isEmpty()) return ""
val out = StringBuilder()
domain.split('.').forEachIndexed { idx, label ->
if (idx > 0) out.append('.')
if (label.lowercase().startsWith(ACE_PREFIX) && label.length > ACE_PREFIX.length) {
val decoded = decodeLabel(label.substring(ACE_PREFIX.length))
?: return null // reject the whole domain on any invalid label
out.append(decoded)
} else {
out.append(label)
}
}
return out.toString()
}
}
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 →