parse_uri.pony

primitive ParseURI
  """
  Parse a URI-reference (RFC 3986 section 4.1) into structured components.

  Handles both absolute URIs (`http://host/path?query`) and relative
  references (`/path?query`). Components are stored percent-encoded.

  The parsing algorithm follows RFC 3986 section 3: scan for `:` before
  any of `/?#` to identify the scheme, then check for `//` to identify
  authority, then extract path (up to `?` or `#`), query (between `?`
  and `#`), and fragment (after `#`). Scanning operates on literal
  delimiter characters only — percent-encoded delimiters (`%2F`, `%3F`,
  `%23`) are not treated as structural boundaries.

  Scheme validation: the scheme must start with a letter and contain
  only letters, digits, `+`, `-`, and `.` (RFC 3986 section 3.1).

  The asterisk-form (`*`) used by HTTP OPTIONS is a valid relative
  reference under RFC 3986 — it parses as a URI with path `*` and no
  other components.
  """
  fun apply(raw: String val): (URI val | URIParseError val) =>
    """
    Parse `raw` into a `URI`, or return a `URIParseError` on failure.
    """
    var pos: USize = 0
    let len = raw.size()

    // Step 1: Detect scheme by scanning for ':' before any of '/?#'
    var scheme: (String | None) = None
    try
      var i: USize = 0
      while i < len do
        let c = raw(i)?
        if c == ':' then
          // Everything before ':' is potential scheme
          let candidate: String val = raw.substring(0, i.isize())
          if _valid_scheme(candidate) then
            scheme = candidate
            pos = i + 1
          end
          break
        elseif (c == '/') or (c == '?') or (c == '#') then
          break
        end
        i = i + 1
      end
    else
      _Unreachable()
    end

    // Step 2: Check for authority (starts with "//")
    var authority: (URIAuthority | None) = None
    try
      if ((pos + 1) < len) and (raw(pos)? == '/') and (raw(pos + 1)? == '/')
      then
        pos = pos + 2
        // Find end of authority: next '/', '?', or '#'
        var auth_end = pos
        while auth_end < len do
          let c = raw(auth_end)?
          if (c == '/') or (c == '?') or (c == '#') then
            break
          end
          auth_end = auth_end + 1
        end
        match \exhaustive\ ParseURIAuthority._parse(raw, pos, auth_end)
        | let a: URIAuthority val =>
          authority = a
        | let e: URIParseError val =>
          return e
        end
        pos = auth_end
      end
    else
      _Unreachable()
    end

    // Step 3: Extract path (up to '?' or '#')
    var path_end = pos
    try
      while path_end < len do
        let c = raw(path_end)?
        if (c == '?') or (c == '#') then
          break
        end
        path_end = path_end + 1
      end
    else
      _Unreachable()
    end
    let path: String val = raw.substring(pos.isize(), path_end.isize())
    pos = path_end

    // Step 4: Extract query (between '?' and '#')
    var query: (String | None) = None
    try
      if (pos < len) and (raw(pos)? == '?') then
        pos = pos + 1
        var query_end = pos
        while query_end < len do
          if raw(query_end)? == '#' then
            break
          end
          query_end = query_end + 1
        end
        let q: String val = raw.substring(pos.isize(), query_end.isize())
        query = q
        pos = query_end
      end
    else
      _Unreachable()
    end

    // Step 5: Extract fragment (after '#')
    var fragment: (String | None) = None
    try
      if (pos < len) and (raw(pos)? == '#') then
        pos = pos + 1
        let f: String val = raw.substring(pos.isize(), len.isize())
        fragment = f
      end
    else
      _Unreachable()
    end

    URI(scheme, authority, path, query, fragment)

  fun _valid_scheme(candidate: String val): Bool =>
    """
    RFC 3986 section 3.1: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
    """
    if candidate.size() == 0 then
      return false
    end
    try
      let first = candidate(0)?
      if not (((first >= 'A') and (first <= 'Z')) or
        ((first >= 'a') and (first <= 'z')))
      then
        return false
      end
      var i: USize = 1
      while i < candidate.size() do
        let c = candidate(i)?
        if not (
          ((c >= 'A') and (c <= 'Z')) or
            ((c >= 'a') and (c <= 'z')) or
            ((c >= '0') and (c <= '9')) or
            (c == '+') or (c == '-') or (c == '.'))
        then
          return false
        end
        i = i + 1
      end
    else
      _Unreachable()
    end
    true