Skip to content

URL Encode / Decode — Kotlin source

Percent-encode or decode URLs and query parameters. Choose component (encodeURIComponent) or full-URI (encodeURI) mode. 100% client-side.

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

// url-encode — percent encode/decode, component (encodeURIComponent) and full-URI (encodeURI) scope.
// Language: Kotlin (2.0, JVM, standard library only). Port of src/tools/UrlEncodeTool.tsx — same
// contract as this dir's go.go: each byte outside the safe set becomes %XX (uppercase hex);
// component keeps A-Za-z0-9-_.!~*'(), full URI also keeps ;,/?:@&=+$#; decode is strict (bad %
// pair, invalid UTF-8 -> error) and full-URI scope leaves encoded reserved bytes intact.
import java.nio.ByteBuffer
import java.nio.charset.CharacterCodingException
import java.nio.charset.CodingErrorAction
import java.nio.charset.StandardCharsets

object UrlEncode {
    data class Result(val output: String, val error: String?) // error == null on success

    private const val HEX = "0123456789ABCDEF"
    private const val BAD = "malformed URI sequence"

    private fun compSafe(c: Int) =
        c in 'A'.code..'Z'.code || c in 'a'.code..'z'.code || c in '0'.code..'9'.code ||
            "-_.!~*'()".indexOf(c.toChar()) >= 0

    private fun uriExtra(c: Int) = ";,/?:@&=+$#".indexOf(c.toChar()) >= 0

    fun encode(s: String, fullUri: Boolean = false): String = buildString {
        for (cb in s.toByteArray(StandardCharsets.UTF_8)) {
            val c = cb.toInt() and 0xff
            if (compSafe(c) || (fullUri && uriExtra(c))) append(c.toChar())
            else append('%').append(HEX[c shr 4]).append(HEX[c and 0xf])
        }
    }

    private fun unhex(c: Char): Int = when (c) {
        in '0'..'9' -> c - '0'
        in 'a'..'f' -> c - 'a' + 10
        in 'A'..'F' -> c - 'A' + 10
        else -> -1
    }

    fun decode(s: String, fullUri: Boolean = false): Result {
        val bytes = ByteArray(s.length) // worst case: one byte per char
        var n = 0
        var i = 0
        while (i < s.length) {
            if (s[i] != '%') { bytes[n++] = s[i].code.toByte(); i++; continue }
            if (i + 2 >= s.length) return Result("", BAD) // truncated tail
            val hi = unhex(s[i + 1])
            val lo = unhex(s[i + 2])
            if (hi < 0 || lo < 0) return Result("", BAD) // bad hex digit
            val c = hi shl 4 or lo
            if (fullUri && c < 0x80 && uriExtra(c)) { // decodeURI keeps encoded reserved
                bytes[n++] = '%'.code.toByte()
                bytes[n++] = s[i + 1].code.toByte()
                bytes[n++] = s[i + 2].code.toByte()
            } else {
                bytes[n++] = c.toByte()
            }
            i += 3
        }
        return try {
            // REPORT -> strict decoder: overlong/surrogate/truncated UTF-8 throws
            val out = StandardCharsets.UTF_8.newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT)
                .onUnmappableCharacter(CodingErrorAction.REPORT)
                .decode(ByteBuffer.wrap(bytes, 0, n)).toString()
            Result(out, null)
        } catch (e: CharacterCodingException) {
            Result("", BAD)
        }
    }
}

fun main() {
    fun show(label: String, r: UrlEncode.Result) = println(label + (r.error ?: r.output))
    println("enc:  " + UrlEncode.encode("hello world & café"))
    println("uri:  " + UrlEncode.encode("hello world & café", fullUri = true))
    show("dec:  ", UrlEncode.decode("hello%20world%20%26%20caf%C3%A9"))
    show("duri: ", UrlEncode.decode("a%2Fb%3Fc%2Cd", fullUri = true)) // reserved stay escaped in decodeURI
    show("dcmp: ", UrlEncode.decode("a%2Fb%3Fc%2Cd")) // ...but decode in component scope
    show("bad:  ", UrlEncode.decode("100%"))
}

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 →