percent_encoding.pony

primitive InvalidPercentEncoding is Stringable
  """
  Percent-encoded sequence is truncated or contains non-hex digits.
  """
  fun string(): String iso^ => "InvalidPercentEncoding".clone()

primitive URIPartUserinfo
  """
  Encoding rules for the userinfo component.
  """

primitive URIPartHost
  """
  Encoding rules for the host component (reg-name only).
  """

primitive URIPartPath
  """
  Encoding rules for the path component.
  """

primitive URIPartQuery
  """
  Encoding rules for the query component.
  """

primitive URIPartFragment
  """
  Encoding rules for the fragment component.
  """

type URIPart is
  ( URIPartUserinfo | URIPartHost | URIPartPath
  | URIPartQuery | URIPartFragment )

primitive PercentDecode
  """
  Decode all percent-encoded sequences in the input.

  Does NOT decode `+` as space — that convention belongs to
  `application/x-www-form-urlencoded` (handled by `ParseFormURLEncoded`),
  not RFC 3986 percent-encoding.
  """
  fun apply(input: String val): (String val | InvalidPercentEncoding val) =>
    """
    Decode percent-encoded sequences in `input`.
    """
    // Fast path: no percent signs means nothing to decode
    if not input.contains("%") then
      return input
    end

    let out = String(input.size())
    var i: USize = 0
    while i < input.size() do
      try
        let c = input(i)?
        if c == '%' then
          if (i + 2) >= input.size() then
            return InvalidPercentEncoding
          end
          let hi = _hex_value(input(i + 1)?)?
          let lo = _hex_value(input(i + 2)?)?
          out.push((hi * 16) + lo)
          i = i + 3
        else
          out.push(c)
          i = i + 1
        end
      else
        return InvalidPercentEncoding
      end
    end
    out.clone()

  fun _hex_value(c: U8): U8 ? =>
    if (c >= '0') and (c <= '9') then
      c - '0'
    elseif (c >= 'A') and (c <= 'F') then
      (c - 'A') + 10
    elseif (c >= 'a') and (c <= 'f') then
      (c - 'a') + 10
    else
      error
    end

primitive PercentEncode
  """
  Percent-encode characters that are not allowed unencoded in the
  specified URI component per RFC 3986.

  For `URIPartHost`, this applies to reg-name hosts only. IP-literals
  (IPv6 addresses and IPvFuture in brackets) have their own syntax and
  should not be percent-encoded — pass them through unchanged.
  """
  fun apply(input: String val, part: URIPart): String val =>
    let out = String(input.size())
    for c in input.values() do
      if _allowed(c, part) then
        out.push(c)
      else
        out.append(_encode_byte(c))
      end
    end
    out.clone()

  fun _encode_byte(c: U8): String val =>
    let hex = "0123456789ABCDEF"
    recover val
      let out = String(3)
      out.push('%')
      try
        out.push(hex(c.usize() >> 4)?)
        out.push(hex(c.usize() and 0x0F)?)
      else
        _Unreachable()
      end
      out
    end

  fun _allowed(c: U8, part: URIPart): Bool =>
    // unreserved characters are always allowed (RFC 3986 section 2.3)
    if _is_unreserved(c) then
      return true
    end

    // sub-delims are allowed in all components
    if _is_sub_delim(c) then
      return true
    end

    // component-specific allowed characters
    match \exhaustive\ part
    | URIPartUserinfo => c == ':'
    | URIPartHost => false
    | URIPartPath => (c == ':') or (c == '@') or (c == '/')
    | URIPartQuery =>
      (c == ':') or (c == '@') or (c == '/') or (c == '?')
    | URIPartFragment =>
      (c == ':') or (c == '@') or (c == '/') or (c == '?')
    end

  fun _is_unreserved(c: U8): Bool =>
    // ALPHA / DIGIT / "-" / "." / "_" / "~"
    ((c >= 'A') and (c <= 'Z')) or
      ((c >= 'a') and (c <= 'z')) or
      ((c >= '0') and (c <= '9')) or
      (c == '-') or (c == '.') or (c == '_') or (c == '~')

  fun _is_sub_delim(c: U8): Bool =>
    // "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
    (c == '!') or (c == '$') or (c == '&') or (c == '\'') or
      (c == '(') or (c == ')') or (c == '*') or (c == '+') or
      (c == ',') or (c == ';') or (c == '=')