Skip to content

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 →