Skip to content

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 →