normalize_uri.pony

primitive NormalizeURI
  """
  Normalize a URI per RFC 3986 sections 6.2.2 (syntax-based) and 6.2.3
  (scheme-based).

  Syntax-based normalization (always safe, uses only generic URI syntax):
  - **Case normalization**: scheme and host are lowercased.
  - **Percent-encoding normalization**: hex digits in `%XX` are uppercased;
    percent-encoded unreserved characters are decoded to their literal form.
  - **Dot-segment removal**: `.` and `..` segments are resolved in the path.

  Scheme-based normalization (requires scheme-specific knowledge):
  - **Default port removal**: ports matching the scheme's well-known default
    (http→80, https→443, ftp→21) are removed.
  - **Empty path normalization**: for `http` and `https`, an empty path with
    an authority is set to `"/"` (per RFC 7230).

  Returns `InvalidPercentEncoding` if any component contains a malformed
  percent-encoded sequence, since `ParseURI` does not validate
  percent-encoding within components.
  """
  fun apply(uri: URI val): (URI val | InvalidPercentEncoding val) =>
    """
    Normalize `uri` and return the normalized form, or an error if
    percent-encoding is malformed.
    """
    // -- 6.2.2: Syntax-based normalization --

    // Lowercase scheme
    let norm_scheme: (String | None) =
      match \exhaustive\ uri.scheme
      | let s: String => s.lower()
      | None => None
      end

    // Normalize authority
    let norm_authority: (URIAuthority | None) =
      match \exhaustive\ uri.authority
      | let a: URIAuthority =>
        // Normalize userinfo percent-encoding
        let norm_userinfo: (String | None) =
          match \exhaustive\ a.userinfo
          | let u: String =>
            match \exhaustive\ _NormalizePercentEncoding(u)
            | let s: String => s
            | let e: InvalidPercentEncoding val => return e
            end
          | None => None
          end

        // Lowercasing must happen after decoding so that a decoded letter
        // (e.g. %41 → 'A') is lowercased along with the rest of the host.
        // Uses _LowerOutsidePercent rather than .lower() to preserve the
        // uppercase hex digits that _NormalizePercentEncoding produces.
        let norm_host: String val =
          match \exhaustive\ _NormalizePercentEncoding(a.host)
          | let s: String => _LowerOutsidePercent(s)
          | let e: InvalidPercentEncoding val => return e
          end

        URIAuthority(norm_userinfo, norm_host, a.port)
      | None => None
      end

    // Normalize path: percent-encoding first, then dot-segment removal
    let pct_path =
      match \exhaustive\ _NormalizePercentEncoding(uri.path)
      | let s: String => s
      | let e: InvalidPercentEncoding val => return e
      end
    let norm_path = RemoveDotSegments(pct_path)

    // Normalize query percent-encoding
    let norm_query: (String | None) =
      match \exhaustive\ uri.query
      | let q: String =>
        match \exhaustive\ _NormalizePercentEncoding(q)
        | let s: String => s
        | let e: InvalidPercentEncoding val => return e
        end
      | None => None
      end

    // Normalize fragment percent-encoding
    let norm_fragment: (String | None) =
      match \exhaustive\ uri.fragment
      | let f: String =>
        match \exhaustive\ _NormalizePercentEncoding(f)
        | let s: String => s
        | let e: InvalidPercentEncoding val => return e
        end
      | None => None
      end

    // -- 6.2.3: Scheme-based normalization --
    // Only applies when a scheme is present (not for relative references)
    (let final_authority, let final_path) =
      match \exhaustive\ norm_scheme
      | let scheme: String =>
        // Default port removal
        let scheme_authority =
          match \exhaustive\ norm_authority
          | let a: URIAuthority =>
            match \exhaustive\ (a.port, _SchemeDefaultPort(scheme))
            | (let port: U16, let default_port: U16)
              if port == default_port
            =>
              URIAuthority(a.userinfo, a.host, None)
            else
              a
            end
          | None => None
          end

        // Empty path normalization (http and https only)
        let scheme_path =
          if (norm_path == "") and
            (scheme_authority isnt None) and
            ((scheme == "http") or (scheme == "https"))
          then
            "/"
          else
            norm_path
          end

        (scheme_authority, scheme_path)
      else
        (norm_authority, norm_path)
      end

    URI(
      norm_scheme,
      final_authority,
      final_path,
      norm_query,
      norm_fragment)

primitive _LowerOutsidePercent
  """
  Lowercase ASCII letters in a string, skipping characters inside `%XX`
  percent-encoded triplets. This preserves the uppercase hex digits that
  `_NormalizePercentEncoding` produces while lowercasing literal host
  characters per RFC 3986 section 6.2.2.1.
  """
  fun apply(input: String val): String val =>
    var has_upper = false
    var i: USize = 0
    while i < input.size() do
      try
        let c = input(i)?
        if c == '%' then
          i = i + 3
        elseif (c >= 'A') and (c <= 'Z') then
          has_upper = true
          break
        else
          i = i + 1
        end
      else
        break
      end
    end

    if not has_upper then
      return input
    end

    let out = String(input.size())
    i = 0
    while i < input.size() do
      try
        let c = input(i)?
        if c == '%' then
          out.push(c)
          if (i + 1) < input.size() then
            out.push(input(i + 1)?)
          end
          if (i + 2) < input.size() then
            out.push(input(i + 2)?)
          end
          i = i + 3
        elseif (c >= 'A') and (c <= 'Z') then
          out.push(c + 32)
          i = i + 1
        else
          out.push(c)
          i = i + 1
        end
      else
        break
      end
    end
    out.clone()

primitive _SchemeDefaultPort
  """
  Return the well-known default port for a URI scheme, or `None` for
  unknown schemes.

  The input scheme must already be lowercased (6.2.2 case normalization
  runs before 6.2.3).
  """
  fun apply(scheme: String): (U16 | None) =>
    if scheme == "http" then
      80
    elseif scheme == "https" then
      443
    elseif scheme == "ftp" then
      21
    else
      None
    end