IPv4 ↔ IPv6 Converter — Ruby 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 Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# ip-converter — IPv4 ↔ IPv6 conversion (polyglot showcase: Ruby)
#
# Language: Ruby (3.2, 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 nil (or '' for the string renderers) on invalid input
# rather than raising, 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.
#
# Mirrors the Python port closely (regex guards, integer groups). Ruby notes:
# \A/\z anchor the whole token (Ruby's ^/$ are line anchors), and split needs
# limit -1 to keep trailing empty tokens — both mirror the Python/Rust split
# semantics this parser relies on.
module IpConverter
module_function
# One IPv6 group: 1-4 hex digits. (Equivalent to the TS
# /^[0-9a-fA-F]{1,4}$/ regex.)
HEX_GROUP = /\A[0-9a-fA-F]{1,4}\z/
# One IPv4 octet token: 1-3 decimal digits (range checked separately).
DEC3 = /\A\d{1,3}\z/
# Count non-overlapping occurrences of "::" — used to enforce the
# at-most-one-compression rule.
def count_double_colon(s)
s.scan('::').size
end
# Parse a dotted-decimal IPv4 string into four octets, validating each is
# 0-255. Returns nil for anything that is not exactly four numeric octets
# in range.
def parse_ipv4(s)
parts = s.strip.split('.', -1)
return nil unless parts.length == 4
octets = []
parts.each do |p|
return nil unless DEC3.match?(p)
# DEC3 guarantees digits only, so to_i cannot surprise us.
n = p.to_i
return nil if n > 255
octets << n
end
octets
end
# Render four octets as "a.b.c.d", or '' if they are out of range.
def ipv4_to_string(octets)
return '' unless octets.is_a?(Array) && octets.length == 4 &&
octets.all? { |o| o.is_a?(Integer) && o.between?(0, 255) }
octets.map(&:to_s).join('.')
end
# 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 nil on any malformed input — never raises.
def parse_ipv6(s)
inp = s.strip
return nil if inp.empty?
# At most one "::" run is legal; reject ambiguous double-compression.
return nil if count_double_colon(inp) > 1
dc = inp.index('::')
if dc
before = inp[0...dc]
after = inp[(dc + 2)..] || ''
head_tokens = before.empty? ? [] : before.split(':', -1)
tail_tokens = after.empty? ? [] : after.split(':', -1)
head = []
head_tokens.each do |g|
return nil unless HEX_GROUP.match?(g)
head << g.to_i(16)
end
tail = []
tail_tokens.each_with_index do |g, 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 == tail_tokens.length - 1 && g.include?('.')
oct = parse_ipv4(g)
return nil if oct.nil?
tail << ((oct[0] << 8) | oct[1])
tail << ((oct[2] << 8) | oct[3])
else
return nil unless HEX_GROUP.match?(g)
tail << g.to_i(16)
end
end
total = head.length + tail.length
# "::" must elide at least one group.
return nil if total >= 8
head + [0] * (8 - total) + tail
else
# No compression: split on ':' and parse, allowing a dotted-quad only
# in the last slot. The result must be exactly eight groups.
tokens = inp.split(':', -1)
groups = []
tokens.each_with_index do |g, i|
if i == tokens.length - 1 && g.include?('.')
oct = parse_ipv4(g)
return nil if oct.nil?
groups << ((oct[0] << 8) | oct[1])
groups << ((oct[2] << 8) | oct[3])
else
return nil unless HEX_GROUP.match?(g)
groups << g.to_i(16)
end
end
groups.length == 8 ? groups : nil
end
end
# True when the eight groups form an IPv4-mapped ("::ffff:") address.
def _is_mapped(g)
g[0, 5].all?(&:zero?) && g[5] == 0xffff
end
# True when the eight groups form an IPv4-compatible ("::") address.
def _is_compatible(g)
g[0, 6].all?(&:zero?)
end
# Lowercase hex for one 16-bit group, with no leading zeros.
def _hex_group(v)
v.to_s(16)
end
# A slice of groups as colon-separated lowercase hex.
def _render_slice(slice)
slice.map { |v| _hex_group(v) }.join(':')
end
# 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 array (8 for a whole address, 6 for
# the high part of an embedded-IPv4 render). Does not emit dotted-decimal;
# call _render_canonical for that.
def _compress_groups(groups)
best_start = -1
best_len = 0
cur_start = -1
cur_len = 0
# Track the longest run of consecutive zero groups. best_start records
# the first run of the longest length (strict > keeps earliest).
groups.each_with_index do |v, i|
if v.zero?
cur_start = i if cur_start.negative?
cur_len += 1
if cur_len > best_len
best_len = cur_len
best_start = cur_start
end
else
cur_start = -1
cur_len = 0
end
end
return _render_slice(groups) if best_len < 2
"#{_render_slice(groups[0...best_start])}::#{_render_slice(groups[(best_start + best_len)..])}"
end
# 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".
def _render_with_embedded_tail(high, octets)
high_str = _compress_groups(high)
ipv4 = octets.map(&:to_s).join('.')
if high_str.end_with?('::')
"#{high_str}#{ipv4}"
else
"#{high_str}:#{ipv4}"
end
end
# 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 ipv6_to_ipv4; on-demand compatible generation via
# ipv4_to_ipv6 is untouched.
def _render_canonical(groups)
return _compress_groups(groups) unless _is_mapped(groups)
octets = [(groups[6] >> 8) & 0xff, groups[6] & 0xff,
(groups[7] >> 8) & 0xff, groups[7] & 0xff]
_render_with_embedded_tail(groups[0, 6], octets)
end
# Render eight groups as canonical compressed IPv6, or '' if invalid.
def ipv6_to_string(groups)
return '' unless groups.is_a?(Array) && groups.length == 8 &&
groups.all? { |v| v.is_a?(Integer) && v.between?(0, 0xffff) }
_render_canonical(groups)
end
# Expand an IPv6 string to its full eight-group, four-hex-digit form;
# '' if invalid.
def expand_ipv6(s)
g = parse_ipv6(s)
return '' if g.nil?
# '%04x' left-pads each group to a fixed 4-digit width: 0000..ffff.
g.map { |v| format('%04x', v) }.join(':')
end
# Compress an IPv6 string to its RFC 5952 canonical form; '' if invalid.
def compress_ipv6(s)
g = parse_ipv6(s)
return '' if g.nil?
_render_canonical(g)
end
# Embed an IPv4 octet quad into an IPv6 address. By default produces the
# IPv4-mapped form "::ffff:a.b.c.d"; mode: :compatible yields "::a.b.c.d";
# a set prefix: overrides both and places the IPv4 after any custom /96
# prefix (e.g. "64:ff9b::a.b.c.d"). Returns '' for invalid octets or
# prefix.
# (The Python/Rust ports carry the mode/prefix pair in an options object;
# this port uses keyword arguments for the same contract.)
def ipv4_to_ipv6(octets, mode: :mapped, prefix: nil)
unless octets.is_a?(Array) && octets.length == 4 &&
octets.all? { |o| o.is_a?(Integer) && o.between?(0, 255) }
return ''
end
if prefix
pfx = parse_ipv6(prefix)
return '' if pfx.nil?
return _render_with_embedded_tail(pfx[0, 6], octets)
end
if mode == :compatible
_render_with_embedded_tail([0, 0, 0, 0, 0, 0], octets)
else
_render_with_embedded_tail([0, 0, 0, 0, 0, 0xffff], octets)
end
end
# 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 nil
# when the address carries no embedded IPv4 (or is unparseable).
def ipv6_to_ipv4(s)
g = parse_ipv6(s)
return nil if g.nil? || !(_is_mapped(g) || _is_compatible(g))
octets = [(g[6] >> 8) & 0xff, g[6] & 0xff,
(g[7] >> 8) & 0xff, g[7] & 0xff]
octets.map(&:to_s).join('.')
end
end
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 →