IPv4 ↔ IPv6 Converter — Kotlin source
Convert between IPv4 and IPv6 addresses both ways. Parse and validate addresses, expand and compress IPv6 to its canonical RFC 5952 form, map an IPv4 into IPv4-mapped and IPv4-compatible IPv6 (or any custom /96 prefix), and extract an embedded IPv4 back out.
This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.
// ip-converter — IPv4 ↔ IPv6 conversion (polyglot showcase: Kotlin)
//
// Language: Kotlin (1.9, standard library only)
// Source: CosmoDev polyglot showcase port of the ip-converter tool,
// ported from src/lib/ip-converter.ts (the canonical TypeScript
// implementation).
// License: display source — part of CosmoDev's polyglot tool pages.
//
// Pure, deterministic IPv4/IPv6 address conversion logic. Every parse
// function returns null (or null for the string renderers) on invalid input
// rather than throwing, so the UI can show a graceful error. IPv6 text
// follows RFC 5952: lowercase hex, no leading zeros, the single longest run
// of zero groups collapsed to "::", and a dotted-decimal tail only for
// IPv4-mapped ("::ffff:") addresses.
//
// Octets and 16-bit groups are plain Ints confined to 0..255 / 0..0xFFFF by
// the parsers (mirroring the Python port). Kotlin's String.split keeps
// trailing empty tokens by default, which this parser relies on.
/** Embedding family for placing an IPv4 quad inside an IPv6 address. */
enum class EmbedMode {
/** `::ffff:a.b.c.d` — the modern, non-deprecated IPv4-mapped form (default). */
MAPPED,
/** `::a.b.c.d` — the deprecated IPv4-compatible form. */
COMPATIBLE,
}
/** Options for embedding an IPv4 octet quad into an IPv6 address. */
data class Ipv4ToIpv6Options(
/** Embedding family. Ignored when [prefix] is non-null. */
val mode: EmbedMode = EmbedMode.MAPPED,
/**
* Optional custom high-96-bit prefix (a valid IPv6 string; its first 6
* groups are used and its low 32 bits are overwritten by the IPv4). e.g.
* `"64:ff9b::"` yields a NAT64-style `64:ff9b::a.b.c.d`. Overrides [mode].
*/
val prefix: String? = null,
)
/** Pure IPv4/IPv6 address conversion logic, ported from src/lib/ip-converter.ts. */
object IpConverter {
/** One IPv6 group: 1-4 hex digits (the TS /^[0-9a-fA-F]{1,4}$/ guard;
* matches() anchors the whole token). */
private val HEX = Regex("[0-9a-fA-F]{1,4}")
/** One IPv4 octet token: 1-3 decimal digits (range checked separately;
* the TS /^\d{1,3}$/ guard). */
private val DEC3 = Regex("\\d{1,3}")
/** Count non-overlapping occurrences of "::" — used to enforce the
* at-most-one-compression rule. */
private fun countDoubleColon(s: String): Int {
var count = 0
var i = s.indexOf("::")
while (i >= 0) {
count++
i = s.indexOf("::", i + 2)
}
return count
}
/**
* Parse a dotted-decimal IPv4 string into four octets, validating each is
* 0-255. Returns null for anything that is not exactly four numeric octets
* in range.
*/
fun parseIpv4(s: String): IntArray? {
val parts = s.trim().split(".")
if (parts.size != 4) return null
val octets = IntArray(4)
for (i in parts.indices) {
if (!DEC3.matches(parts[i])) return null
// DEC3 guarantees digits only, so toInt() cannot fail.
val n = parts[i].toInt()
if (n !in 0..255) return null
octets[i] = n
}
return octets
}
/** Render four octets as `a.b.c.d`. (The parser already confines each
* octet to 0..255, but the signature keeps the symmetry with the other
* ports.) */
fun ipv4ToString(octets: IntArray): String = octets.joinToString(".")
/**
* Parse an IPv6 string (with "::" compression, hex groups, and an optional
* dotted-decimal IPv4 tail for mapped/compatible forms) into eight 16-bit
* groups. Returns null on any malformed input — never throws.
*/
fun parseIpv6(s: String): IntArray? {
val input = s.trim()
if (input.isEmpty()) return null
// At most one "::" run is legal; reject ambiguous double-compression.
if (countDoubleColon(input) > 1) return null
// Branch on the position of "::" (if any). The two arms mirror each
// other: split into tokens, validate each, allow a dotted-quad only in
// the final slot, then assemble exactly eight groups.
val dc = input.indexOf("::")
if (dc >= 0) {
val before = input.substring(0, dc)
val after = input.substring(dc + 2)
val headTokens = if (before.isEmpty()) emptyList() else before.split(":")
val tailTokens = if (after.isEmpty()) emptyList() else after.split(":")
val head = mutableListOf<Int>()
for (g in headTokens) {
if (!HEX.matches(g)) return null
head.add(g.toInt(16))
}
val tail = mutableListOf<Int>()
for (i in tailTokens.indices) {
val g = tailTokens[i]
// A dotted-quad IPv4 tail is permitted only in the final slot,
// where it contributes two groups (high octet pair, low octet pair).
if (i == tailTokens.lastIndex && '.' in g) {
val oct = parseIpv4(g) ?: return null
tail.add((oct[0] shl 8) or oct[1])
tail.add((oct[2] shl 8) or oct[3])
} else {
if (!HEX.matches(g)) return null
tail.add(g.toInt(16))
}
}
val total = head.size + tail.size
// "::" must elide at least one group.
if (total >= 8) return null
return IntArray(8).also { groups ->
for (k in head.indices) groups[k] = head[k]
// The middle [head.size .. 8 - tail.size] stays zero — that is
// the elided run "::" stands in for.
for (k in tail.indices) groups[8 - tail.size + k] = tail[k]
}
}
// No compression: split on ':' and parse, allowing a dotted-quad only
// in the last slot. The result must be exactly eight groups.
val tokens = input.split(":")
val groups = IntArray(8)
var n = 0
for (i in tokens.indices) {
val g = tokens[i]
if (i == tokens.lastIndex && '.' in g) {
val oct = parseIpv4(g) ?: return null
if (n + 2 > 8) return null
groups[n] = (oct[0] shl 8) or oct[1]
groups[n + 1] = (oct[2] shl 8) or oct[3]
n += 2
} else {
if (!HEX.matches(g)) return null
if (n + 1 > 8) return null
groups[n] = g.toInt(16)
n += 1
}
}
return if (n == 8) groups else null
}
/** True when the eight groups form an IPv4-mapped ("::ffff:") address. */
private fun isMapped(g: IntArray): Boolean =
g[0] == 0 && g[1] == 0 && g[2] == 0 && g[3] == 0 && g[4] == 0 && g[5] == 0xFFFF
/** True when the eight groups form an IPv4-compatible ("::") address. */
private fun isCompatible(g: IntArray): Boolean =
g[0] == 0 && g[1] == 0 && g[2] == 0 && g[3] == 0 && g[4] == 0 && g[5] == 0
/** Lowercase hex for one 16-bit group, with no leading zeros. */
private fun hexGroup(v: Int): String = v.toString(16)
/** The slice [from, to) as colon-separated lowercase hex. */
private fun renderRange(groups: IntArray, from: Int, to: Int): String =
(from until to).joinToString(":") { hexGroup(groups[it]) }
/**
* Collapse the longest run (length >= 2) of zero groups into "::" (first
* run wins on ties) and strip leading zeros — RFC 5952 canonical text for
* pure-hex IPv6. Works over any group span (8 for a whole address, 6 for
* the high part of an embedded-IPv4 render). Does not emit dotted-decimal;
* call [renderCanonical] for that.
*/
private fun compressGroups(groups: IntArray, from: Int = 0, to: Int = groups.size): String {
var bestStart = -1
var bestLen = 0
var curStart = -1
var curLen = 0
// Track the longest run of consecutive zero groups. bestStart records
// the first run of the longest length (strict > keeps earliest).
for (i in from until to) {
if (groups[i] == 0) {
if (curStart < 0) curStart = i
curLen++
if (curLen > bestLen) {
bestLen = curLen
bestStart = curStart
}
} else {
curStart = -1
curLen = 0
}
}
if (bestLen < 2) return renderRange(groups, from, to)
return renderRange(groups, from, bestStart) + "::" +
renderRange(groups, bestStart + bestLen, to)
}
/**
* Render a compressed high part followed by a dotted-decimal IPv4 tail.
* When the high part already ends in "::" (its zero run reaches the
* boundary) the IPv4 attaches directly; otherwise a single ":" separates
* them — so "::ffff:" → "::ffff:a.b.c.d" and "::" → "::a.b.c.d".
*/
private fun renderWithEmbeddedTail(high: IntArray, octets: IntArray): String {
val highStr = compressGroups(high)
val ipv4 = ipv4ToString(octets)
return if (highStr.endsWith("::")) highStr + ipv4 else "$highStr:$ipv4"
}
/**
* Canonical RFC 5952 text for eight groups: a dotted-decimal tail for
* IPv4-mapped ("::ffff:") addresses, otherwise pure compressed hex. The
* deprecated IPv4-compatible range ("::/96") is NOT rendered dotted here —
* that would mis-render the unspecified ("::") and loopback ("::1")
* addresses as "::0.0.0.0" / "::0.0.0.1". Compatible extraction is still
* available via [ipv6ToIpv4]; on-demand compatible generation via
* [ipv4ToIpv6] is untouched.
*/
private fun renderCanonical(groups: IntArray): String {
if (isMapped(groups)) {
val octets = intArrayOf(
(groups[6] shr 8) and 0xFF, groups[6] and 0xFF,
(groups[7] shr 8) and 0xFF, groups[7] and 0xFF,
)
return renderWithEmbeddedTail(groups.copyOfRange(0, 6), octets)
}
return compressGroups(groups)
}
/** Render eight groups as canonical compressed IPv6. */
fun ipv6ToString(groups: IntArray): String = renderCanonical(groups)
/**
* Expand an IPv6 string to its full eight-group, four-hex-digit form;
* null if invalid.
*/
fun expandIpv6(s: String): String? {
val g = parseIpv6(s) ?: return null
// padStart left-pads each group to a fixed 4-digit width: 0000..ffff.
return g.joinToString(":") { hexGroup(it).padStart(4, '0') }
}
/** Compress an IPv6 string to its RFC 5952 canonical form; null if invalid. */
fun compressIpv6(s: String): String? {
val g = parseIpv6(s) ?: return null
return renderCanonical(g)
}
/**
* Embed an IPv4 octet quad into an IPv6 address. By default produces the
* IPv4-mapped form "::ffff:a.b.c.d"; [EmbedMode.COMPATIBLE] yields
* "::a.b.c.d"; a set [Ipv4ToIpv6Options.prefix] overrides both and places
* the IPv4 after any custom /96 prefix (e.g. "64:ff9b::a.b.c.d"). Returns
* null for an invalid octet count or prefix.
*/
fun ipv4ToIpv6(
octets: IntArray,
options: Ipv4ToIpv6Options = Ipv4ToIpv6Options(),
): String? {
if (octets.size != 4 || octets.any { it !in 0..255 }) return null
options.prefix?.let { prefix ->
val p = parseIpv6(prefix) ?: return null
return renderWithEmbeddedTail(p.copyOfRange(0, 6), octets)
}
if (options.mode == EmbedMode.COMPATIBLE) {
return renderWithEmbeddedTail(intArrayOf(0, 0, 0, 0, 0, 0), octets)
}
return renderWithEmbeddedTail(intArrayOf(0, 0, 0, 0, 0, 0xFFFF), octets)
}
/**
* Extract the embedded IPv4 from an IPv4-mapped ("::ffff:a.b.c.d") or
* IPv4-compatible ("::a.b.c.d") address, returning dotted-decimal or null
* when the address carries no embedded IPv4 (or is unparseable).
*/
fun ipv6ToIpv4(s: String): String? {
val g = parseIpv6(s) ?: return null
if (!(isMapped(g) || isCompatible(g))) return null
val octets = intArrayOf(
(g[6] shr 8) and 0xFF, g[6] and 0xFF,
(g[7] shr 8) and 0xFF, g[7] and 0xFF,
)
return ipv4ToString(octets)
}
}
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 →