URL Encode / Decode — Ruby source
Percent-encode or decode URLs and query parameters. Choose component (encodeURIComponent) or full-URI (encodeURI) mode. 100% client-side.
This is the Ruby 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: Ruby (3.1+, 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.
# Decoded bytes are re-checked as strict UTF-8 by valid_encoding?, which rejects overlong and
# surrogate sequences — the same errors the JS built-ins raise.
module UrlEncode
HEX = '0123456789ABCDEF'
COMPONENT_SAFE = ((65..90).to_a + (97..122).to_a + (48..57).to_a + "-_.!~*'()".bytes).freeze
URI_EXTRA = ';,/?:@&=+$#'.bytes.freeze
MALFORMED = 'malformed URI sequence'
def self.encode(s, full_uri = false)
s.each_byte.map do |b|
if COMPONENT_SAFE.include?(b) || (full_uri && URI_EXTRA.include?(b))
b.chr
else
'%' + HEX[b >> 4] + HEX[b & 0xf]
end
end.join
end
def self.unhex(byte)
return nil if byte.nil? # getbyte past the end — truncated tail
if (48..57).cover?(byte) then byte - 48
elsif (97..102).cover?(byte) then byte - 97 + 10
elsif (65..70).cover?(byte) then byte - 65 + 10
end
end
# Returns { output:, error: } — error nil on success.
def self.decode(s, full_uri = false)
bytes = String.new(encoding: 'ASCII-8BIT') # binary buffer, UTF-8-checked at the end
i = 0
while i < s.bytesize
b = s.getbyte(i)
if b != 0x25 # '%'
bytes << b
i += 1
next
end
hi = unhex(s.getbyte(i + 1))
lo = hi && unhex(s.getbyte(i + 2))
return { output: '', error: MALFORMED } if hi.nil? || lo.nil?
c = (hi << 4) | lo
if full_uri && c < 0x80 && URI_EXTRA.include?(c) # decodeURI keeps encoded reserved
bytes << 0x25 << s.getbyte(i + 1) << s.getbyte(i + 2)
else
bytes << c
end
i += 3
end
out = bytes.force_encoding('UTF-8')
return { output: '', error: MALFORMED } unless out.valid_encoding?
{ output: out.freeze, error: nil }
end
end
puts "enc: #{UrlEncode.encode('hello world & café')}"
puts "uri: #{UrlEncode.encode('hello world & café', true)}"
r = UrlEncode.decode('hello%20world%20%26%20caf%C3%A9')
puts "dec: #{r[:error] || r[:output]}"
r = UrlEncode.decode('a%2Fb%3Fc%2Cd', true) # reserved stay escaped in decodeURI
puts "duri: #{r[:error] || r[:output]}"
r = UrlEncode.decode('a%2Fb%3Fc%2Cd') # ...but decode in component scope
puts "dcmp: #{r[:error] || r[:output]}"
r = UrlEncode.decode('100%')
puts "bad: #{r[:error] || r[:output]}"
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 →