uri_to_iri.pony

primitive URIToIRI
  """
  Convert a URI to an IRI by selectively decoding percent-encoded UTF-8
  sequences that represent allowed IRI characters.

  Percent-encoded sequences that decode to valid UTF-8 `ucschar` codepoints
  (RFC 3987 section 2.2) are replaced with their literal UTF-8 bytes. In the
  query component, `iprivate` codepoints are also decoded. Bidi formatting
  characters (U+200E, U+200F, U+202A-202E) always stay percent-encoded per
  RFC 3987 section 4.1.

  Sequences that don't form valid UTF-8, or decode to codepoints outside
  the allowed ranges, remain percent-encoded. Always succeeds.
  """
  fun apply(uri: URI val): URI val =>
    """
    Decode percent-encoded IRI-legal codepoints back to literal UTF-8.
    """
    let iri_authority: (URIAuthority | None) =
      match \exhaustive\ uri.authority
      | let a: URIAuthority =>
        let iri_userinfo: (String | None) =
          match \exhaustive\ a.userinfo
          | let u: String => _decode_iri(u, false)
          | None => None
          end
        URIAuthority(iri_userinfo, _decode_iri(a.host, false), a.port)
      | None => None
      end

    let iri_query: (String | None) =
      match \exhaustive\ uri.query
      | let q: String => _decode_iri(q, true)
      | None => None
      end

    let iri_fragment: (String | None) =
      match \exhaustive\ uri.fragment
      | let f: String => _decode_iri(f, false)
      | None => None
      end

    URI(
      uri.scheme,
      iri_authority,
      _decode_iri(uri.path, false),
      iri_query,
      iri_fragment)

  fun _decode_iri(s: String val, allow_iprivate: Bool): String val =>
    if not s.contains("%") then return s end

    let out = String(s.size())
    var i: USize = 0
    while i < s.size() do
      try
        let c = s(i)?
        if (c == '%') and ((i + 2) < s.size()) then
          // Try to decode a UTF-8 sequence from percent-encoded bytes.
          // If hex decoding fails (non-hex digits), fall through to
          // pass the triplet through unchanged.
          var decoded = false
          try
            let first_byte = _decode_hex(s, i)?
            let seq_len = _utf8_sequence_length(first_byte)
            if (seq_len > 1) and ((i + (seq_len * 3)) <= s.size()) then
              let bytes = String(seq_len)
              bytes.push(first_byte)
              var j: USize = 1
              var valid = true
              while j < seq_len do
                let byte_offset = i + (j * 3)
                try
                  if s(byte_offset)? != '%' then
                    valid = false
                    break
                  end
                  bytes.push(_decode_hex(s, byte_offset)?)
                else
                  valid = false
                  break
                end
                j = j + 1
              end

              if valid and (bytes.size() == seq_len) then
                (let cp, let cp_len) = bytes.utf32(0)?
                if (cp_len.usize() == seq_len) and
                  _should_decode(cp, allow_iprivate)
                then
                  for b in bytes.values() do
                    out.push(b)
                  end
                  i = i + (seq_len * 3)
                  decoded = true
                end
              end
            end
          end
          if not decoded then
            // Keep the original %XX triplet (or malformed % sequence)
            out.push(s(i)?)
            out.push(s(i + 1)?)
            out.push(s(i + 2)?)
            i = i + 3
          end
        else
          out.push(c)
          i = i + 1
        end
      else
        _Unreachable()
      end
    end
    out.clone()

  fun _decode_hex(s: String val, offset: USize): U8 ? =>
    """
    Decode a single %HH triplet starting at offset.
    """
    let hi = PercentDecode._hex_value(s(offset + 1)?)?
    let lo = PercentDecode._hex_value(s(offset + 2)?)?
    (hi * 16) + lo

  fun _utf8_sequence_length(first_byte: U8): USize =>
    """
    Determine UTF-8 sequence length from the leading byte.
    """
    if (first_byte and 0x80) == 0 then 1
    elseif (first_byte and 0xE0) == 0xC0 then 2
    elseif (first_byte and 0xF0) == 0xE0 then 3
    elseif (first_byte and 0xF8) == 0xF0 then 4
    else 1 // Invalid leading byte — treat as single byte
    end

  fun _should_decode(cp: U32, allow_iprivate: Bool): Bool =>
    """
    Check if a codepoint should be decoded to literal UTF-8.
    """
    if _IRIChars.is_bidi_format(cp) then
      return false
    end
    if _IRIChars.is_ucschar(cp) then
      return true
    end
    if allow_iprivate and _IRIChars.is_iprivate(cp) then
      return true
    end
    false