///|
/// # MoonBit URI Library
///
/// A comprehensive RFC3986-compliant URI parsing and manipulation library for MoonBit.
/// This library provides robust URI parsing, validation, and manipulation capabilities
/// with full support for all URI components and operations.
///
/// ## Features
///
/// - **RFC3986 Compliant**: Full compliance with URI specification
/// - **Immutable Operations**: All URI modifications return new instances
/// - **Comprehensive Parsing**: Support for all URI components
/// - **Percent Encoding**: Built-in encoding/decoding utilities
/// - **Query Parameter Helpers**: Easy query string manipulation
/// - **Path Segment Operations**: Convenient path manipulation
/// - **URI Resolution**: Resolve relative URIs against base URIs
/// - **Validation**: Robust error handling and validation
///
/// ## Quick Start
///
/// ```moonbit nocheck
/// // Parse a URI
/// let uri = @uri.parse("https://user:pass@example.com:8080/path?query=value#fragment")
///
/// // Access components
/// inspect(uri.scheme(), content="Some(\"https\")")
/// inspect(uri.host(), content="Some(\"example.com\")")
/// inspect(uri.port(), content="Some(8080)")
/// inspect(uri.path(), content="/path")
///
/// // Modify URI (immutable)
/// let new_uri = uri.with_host(Some("newhost.com")).with_port(Some(9000))
/// inspect(new_uri.to_string(), content="https://user:pass@newhost.com:9000/path?query=value#fragment")
///
/// // Query parameter helpers
/// let search_uri = @uri.parse("https://example.com/search")
/// let with_params = search_uri.with_query_param("q", "moonbit").with_query_param("lang", "en")
/// inspect(with_params.to_string(), content="https://example.com/search?q=moonbit&lang=en")
/// ```
///
/// ## URI Structure
///
/// URI data structure representing a parsed URI according to RFC3986
///
/// A URI has the general form:
/// scheme://authority/path?query#fragment
///
/// Where:
/// - scheme: identifies the protocol (e.g., "http", "https", "ftp")
/// - authority: contains user info, host, and port
/// - path: hierarchical path to resource
/// - query: additional parameters
/// - fragment: reference to a secondary resource
pub struct Uri {
scheme : String?
authority : Authority?
path : String
query : String?
fragment : String?
} derive(ToJson)
///|
/// Keep `uri.to_json()` available in dot form for existing users of this
/// published package (the implicit promotion of `impl ToJson` is deprecated).
pub extend Uri with ToJson::{to_json}
///|
/// Authority component of a URI
/// Contains optional user info, required host, and optional port
pub struct Authority {
userinfo : String?
host : String
port : Int?
} derive(ToJson)
///|
/// Keep `authority.to_json()` available in dot form for existing users.
pub extend Authority with ToJson::{to_json}
///|
/// Result type for URI parsing operations
pub suberror UriError {
InvalidScheme(String)
InvalidAuthority(String)
InvalidPath(String)
InvalidQuery(String)
InvalidFragment(String)
InvalidPort(String)
EmptyUri
} derive(ToJson)
///|
/// Keep `err.to_json()` available in dot form for existing users.
pub extend UriError with ToJson::{to_json}
///|
/// Create an empty URI with default values.
///
/// # Example
///
/// ```moonbit nocheck
/// let uri = @uri.empty()
/// inspect(uri.path(), content="")
/// inspect(uri.scheme(), content="None")
/// ```
///
/// # Returns
///
/// A new `Uri` with all components set to their default values.
pub fn empty() -> Uri {
{ scheme: None, authority: None, path: "", query: None, fragment: None, }
}
///|
/// Parse a URI string into a Uri structure according to RFC3986.
///
/// # Example
///
/// ```moonbit nocheck
/// let uri = @uri.parse("https://user:pass@example.com:8080/path?query=value#fragment")
/// inspect(uri.scheme(), content="Some(\"https\")")
/// inspect(uri.host(), content="Some(\"example.com\")")
/// inspect(uri.port(), content="Some(8080)")
/// ```
///
/// # Parameters
///
/// - `uri_str`: The URI string to parse
///
/// # Returns
///
/// A parsed `Uri` structure
///
/// # Raises
///
/// - `UriError::EmptyUri` if the input string is empty
/// - `UriError::InvalidScheme` if the scheme is malformed
/// - `UriError::InvalidAuthority` if the authority is malformed
/// - Other `UriError` variants for various parsing failures
pub fn parse(uri_str : String) -> Uri raise UriError {
if uri_str.length() == 0 {
raise EmptyUri
}
// Start with an empty URI and parse components
let uri = empty()
let remaining = uri_str
// Simple parsing for now - will enhance later
// For basic HTTP/HTTPS URIs: scheme://host:port/path?query#fragment
// Check for scheme
if remaining.contains("://") {
let parts = remaining.split("://").collect()
if parts.length() >= 2 {
let scheme_part = parts[0].to_owned()
if is_valid_scheme(scheme_part) {
let rest = parts[1].to_owned()
// Parse the rest
return parse_with_scheme(scheme_part, rest)
} else {
raise InvalidScheme(scheme_part)
}
}
}
// No scheme found, treat as path-only URI
{ ..uri, path: remaining, }
}
///|
/// Parse URI with known scheme
fn parse_with_scheme(scheme : String, rest : String) -> Uri raise UriError {
let mut uri = empty()
uri = { ..uri, scheme: Some(scheme), }
// Split by fragment first
let parts_fragment = rest.split("#").collect()
let main_part = parts_fragment[0].to_owned()
let fragment_part = if parts_fragment.length() > 1 {
let frag = parts_fragment[1].to_owned()
// Basic fragment validation - no spaces allowed
if frag.contains(" ") {
raise InvalidFragment(frag)
}
Some(frag)
} else {
None
}
uri = { ..uri, fragment: fragment_part, }
// Split by query
let parts_query = main_part.split("?").collect()
let path_authority_part = parts_query[0].to_owned()
let query_part = if parts_query.length() > 1 {
let q = parts_query[1].to_owned()
// Basic query validation - no unescaped spaces
if q.contains(" ") {
raise InvalidQuery(q)
}
Some(q)
} else {
None
}
uri = { ..uri, query: query_part, }
// Split authority and path
let parts_path = path_authority_part.split("/").collect()
if parts_path.length() > 0 {
let authority_part = parts_path[0].to_owned()
if authority_part.length() > 0 {
let auth = parse_authority(authority_part)
uri = { ..uri, authority: Some(auth), }
}
// Reconstruct path from remaining parts
if parts_path.length() > 1 {
let path_parts : Array[String] = []
for i = 1; i < parts_path.length(); i = i + 1 {
path_parts.push(parts_path[i].to_owned())
}
let path = "/" + path_parts.join("/")
// Basic path validation - no unescaped spaces
if path.contains(" ") {
raise InvalidPath(path)
}
uri = { ..uri, path, }
} else {
uri = { ..uri, path: "", }
}
} else {
// No authority, remaining is path
if path_authority_part.contains(" ") {
raise InvalidPath(path_authority_part)
}
uri = { ..uri, path: path_authority_part, }
}
uri
}
///|
/// Convert a Uri structure back to a string representation
pub fn Uri::to_string(self : Uri) -> String {
let mut result = ""
// Add scheme
match self.scheme {
Some(scheme) => {
result = result + scheme
match self.authority {
Some(_) => result = result + "://"
None => result = result + ":"
}
}
None => ()
}
// Add authority
match self.authority {
Some(auth) => {
match auth.userinfo {
Some(userinfo) => result = result + userinfo + "@"
None => ()
}
result = result + auth.host
match auth.port {
Some(port) => result = result + ":" + port.to_string()
None => ()
}
}
None => ()
}
// Add path
result = result + self.path
// Add query
match self.query {
Some(query) => result = result + "?" + query
None => ()
}
// Add fragment
match self.fragment {
Some(fragment) => result = result + "#" + fragment
None => ()
}
result
}
///|
/// Implement Show trait for Uri to provide standard string representation
pub impl Show for Uri with fn output(self, logger) {
// Use our existing to_string method for the Show implementation
logger.write_string(self.to_string())
}
///|
/// Keep `uri.output(logger)` available in dot form for existing users.
pub extend Uri with Show::{output}
///|
/// Get the scheme component of the URI.
///
/// The scheme identifies the protocol used to access the resource.
/// Common schemes include "http", "https", "ftp", "file", "mailto", etc.
///
/// # Examples
///
/// ```moonbit nocheck
/// // Absolute URI with scheme
/// let uri = @uri.parse("https://example.com")
/// inspect(uri.scheme(), content="Some(\"https\")")
///
/// // Different schemes
/// let ftp_uri = @uri.parse("ftp://files.example.com/file.txt")
/// inspect(ftp_uri.scheme(), content="Some(\"ftp\")")
///
/// // Relative URI without scheme
/// let relative_uri = @uri.parse("/path/to/resource")
/// inspect(relative_uri.scheme(), content="None")
/// ```
///
/// # Returns
///
/// The scheme as `Some(String)` if present, `None` for relative URIs.
pub fn Uri::scheme(self : Uri) -> String? {
self.scheme
}
///|
/// Get the host component of the URI.
///
/// The host identifies the server where the resource is located.
/// It can be a domain name, IPv4 address, or IPv6 address.
///
/// # Examples
///
/// ```moonbit nocheck
/// // Domain name
/// let uri = @uri.parse("https://example.com:8080/path")
/// inspect(uri.host(), content="Some(\"example.com\")")
///
/// // IPv4 address
/// let ipv4_uri = @uri.parse("http://192.168.1.1/api")
/// inspect(ipv4_uri.host(), content="Some(\"192.168.1.1\")")
///
/// // IPv6 address (with brackets)
/// let ipv6_uri = @uri.parse("http://[2001:db8::1]:8080/path")
/// inspect(ipv6_uri.host(), content="Some(\"[2001:db8::1]\")")
///
/// // Relative URI without host
/// let relative_uri = @uri.parse("/path/to/resource")
/// inspect(relative_uri.host(), content="None")
/// ```
///
/// # Returns
///
/// The host as `Some(String)` if present, `None` for relative URIs or URIs without authority.
pub fn Uri::host(self : Uri) -> String? {
match self.authority {
Some(auth) => Some(auth.host)
None => None
}
}
///|
/// Get the explicit port component of the URI.
///
/// This returns only explicitly specified ports in the URI string.
/// Use `effective_port()` to get the effective port including scheme defaults.
///
/// # Examples
///
/// ```moonbit nocheck
/// // Explicit port
/// let uri = @uri.parse("https://example.com:8080/path")
/// inspect(uri.port(), content="Some(8080)")
///
/// // No explicit port (uses scheme default)
/// let default_uri = @uri.parse("https://example.com/path")
/// inspect(default_uri.port(), content="None")
///
/// // Different port numbers
/// let custom_port = @uri.parse("http://localhost:3000/api")
/// inspect(custom_port.port(), content="Some(3000)")
///
/// // IPv6 with port
/// let ipv6_uri = @uri.parse("http://[::1]:8080/path")
/// inspect(ipv6_uri.port(), content="Some(8080)")
/// ```
///
/// # Returns
///
/// The port as `Some(Int)` if explicitly specified, `None` otherwise.
///
/// # See Also
///
/// - `effective_port()` - Get the effective port including scheme defaults
pub fn Uri::port(self : Uri) -> Int? {
match self.authority {
Some(auth) => auth.port
None => None
}
}
///|
/// Get the path component of the URI.
///
/// The path identifies the specific resource within the host.
/// It always starts with "/" for absolute URIs, or can be relative.
///
/// # Examples
///
/// ```moonbit nocheck
/// // Absolute path
/// let uri = @uri.parse("https://example.com/path/to/resource")
/// inspect(uri.path(), content="/path/to/resource")
///
/// // Root path
/// let root_uri = @uri.parse("https://example.com/")
/// inspect(root_uri.path(), content="/")
///
/// // Empty path (defaults to empty string)
/// let no_path_uri = @uri.parse("https://example.com")
/// inspect(no_path_uri.path(), content="")
///
/// // Relative path
/// let relative_uri = @uri.parse("path/to/resource")
/// inspect(relative_uri.path(), content="path/to/resource")
///
/// // Path with encoded characters
/// let encoded_uri = @uri.parse("https://example.com/path%20with%20spaces")
/// inspect(encoded_uri.path(), content="/path%20with%20spaces")
/// ```
///
/// # Returns
///
/// The path as a `String`. Empty string if no path is specified.
///
/// # See Also
///
/// - `path_segments()` - Get path as array of segments
/// - `with_path()` - Create new URI with different path
pub fn Uri::path(self : Uri) -> String {
self.path
}
///|
/// Get the query component of the URI.
///
/// The query string contains additional parameters for the resource,
/// typically in the form of key=value pairs separated by "&".
///
/// # Examples
///
/// ```moonbit nocheck
/// // Query with multiple parameters
/// let uri = @uri.parse("https://example.com/path?key=value&foo=bar")
/// inspect(uri.query(), content="Some(\"key=value&foo=bar\")")
///
/// // Single parameter
/// let single_param = @uri.parse("https://example.com/search?q=moonbit")
/// inspect(single_param.query(), content="Some(\"q=moonbit\")")
///
/// // Empty query
/// let empty_query = @uri.parse("https://example.com/path?")
/// inspect(empty_query.query(), content="Some(\"\")")
///
/// // No query
/// let no_query = @uri.parse("https://example.com/path")
/// inspect(no_query.query(), content="None")
///
/// // Query with encoded characters
/// let encoded_query = @uri.parse("https://example.com/search?q=hello%20world")
/// inspect(encoded_query.query(), content="Some(\"q=hello%20world\")")
/// ```
///
/// # Returns
///
/// The query string as `Some(String)` if present, `None` otherwise.
///
/// # See Also
///
/// - `get_query_param()` - Get specific query parameter value
/// - `parse_query()` - Parse query string into key-value pairs
pub fn Uri::query(self : Uri) -> String? {
self.query
}
///|
/// Get the fragment component of the URI.
///
/// The fragment identifies a secondary resource or section within the primary resource,
/// typically used for anchors in HTML documents or specific sections.
///
/// # Examples
///
/// ```moonbit nocheck
/// // Fragment in HTML document
/// let uri = @uri.parse("https://example.com/page.html#section1")
/// inspect(uri.fragment(), content="Some(\"section1\")")
///
/// // Fragment with encoded characters
/// let encoded_fragment = @uri.parse("https://example.com/doc#user%20guide")
/// inspect(encoded_fragment.fragment(), content="Some(\"user%20guide\")")
///
/// // No fragment
/// let no_fragment = @uri.parse("https://example.com/page.html")
/// inspect(no_fragment.fragment(), content="None")
///
/// // Empty fragment
/// let empty_fragment = @uri.parse("https://example.com/page.html#")
/// inspect(empty_fragment.fragment(), content="Some(\"\")")
/// ```
///
/// # Returns
///
/// The fragment as `Some(String)` if present, `None` otherwise.
///
/// # See Also
///
/// - `with_fragment()` - Create new URI with different fragment
pub fn Uri::fragment(self : Uri) -> String? {
self.fragment
}
///|
/// Check if a scheme string is valid according to RFC3986
fn is_valid_scheme(scheme : String) -> Bool {
if scheme.length() == 0 {
return false
}
// First character must be a letter
let first_char_code_cu = scheme.code_unit_at(0)
let first_char_code = first_char_code_cu.to_int()
let first_char = first_char_code.unsafe_to_char()
if !is_alpha(first_char) {
return false
}
// Remaining characters must be letters, digits, +, -, or .
for i = 1; i < scheme.length(); i = i + 1 {
let char_code_cu = scheme.code_unit_at(i)
let char_code = char_code_cu.to_int()
let c = char_code.unsafe_to_char()
if !(is_alpha(c) || is_digit(c) || c == '+' || c == '-' || c == '.') {
return false
}
}
true
}
///|
/// Parse authority component
fn parse_authority(auth_str : String) -> Authority raise UriError {
let mut userinfo : String? = None
let mut port : Int? = None
let mut remaining = auth_str
// Check for userinfo (contains @)
if remaining.contains("@") {
let parts = remaining.split("@").collect()
if parts.length() >= 2 {
userinfo = Some(parts[0].to_owned())
remaining = parts[1].to_owned()
}
}
// Now parse host and port from remaining
let host_part = if remaining.contains(":") &&
remaining.strip_prefix("[") == None {
// Regular host with port (not IPv6)
let parts = remaining.split(":").collect()
if parts.length() >= 2 {
let potential_port = parts[parts.length() - 1].to_owned() // Take the last part as port
// Validate port is numeric
if potential_port.length() > 0 {
let mut is_numeric = true
for i = 0; i < potential_port.length(); i = i + 1 {
let char_code_cu = potential_port.code_unit_at(i)
let char_code = char_code_cu.to_int()
let c = char_code.unsafe_to_char()
if !is_digit(c) {
is_numeric = false
break
}
}
if is_numeric {
// Convert to int
let mut port_value = 0
for i = 0; i < potential_port.length(); i = i + 1 {
let char_code_cu = potential_port.code_unit_at(i)
let char_code = char_code_cu.to_int()
let digit = char_code - 48 // '0' = 48
port_value = port_value * 10 + digit
}
if port_value < 0 || port_value > 65535 {
raise InvalidPort(potential_port)
}
port = Some(port_value)
// Reconstruct host without the port
let host_parts : Array[String] = []
for i = 0; i < parts.length() - 1; i = i + 1 {
host_parts.push(parts[i].to_owned())
}
host_parts.join(":")
} else {
// Invalid port format - should be an error
raise InvalidPort(potential_port)
}
} else {
remaining
}
} else {
remaining
}
} else if remaining.strip_prefix("[") != None {
// IPv6 literal - handle specially
if remaining.contains("]:") {
let bracket_parts = remaining.split("]:").collect()
if bracket_parts.length() >= 2 {
let ipv6_part = bracket_parts[0].to_owned() + "]"
let port_part = bracket_parts[1].to_owned()
// Validate and parse port
let mut is_numeric = true
for i = 0; i < port_part.length(); i = i + 1 {
let char_code_cu = port_part.code_unit_at(i)
let char_code = char_code_cu.to_int()
let c = char_code.unsafe_to_char()
if !is_digit(c) {
is_numeric = false
break
}
}
if is_numeric && port_part.length() > 0 {
let mut port_value = 0
for i = 0; i < port_part.length(); i = i + 1 {
let char_code_cu = port_part.code_unit_at(i)
let char_code = char_code_cu.to_int()
let digit = char_code - 48
port_value = port_value * 10 + digit
}
if port_value < 0 || port_value > 65535 {
raise InvalidPort(port_part)
}
port = Some(port_value)
}
ipv6_part
} else {
remaining
}
} else {
remaining
}
} else {
remaining
}
if host_part.length() == 0 {
raise InvalidAuthority(auth_str)
}
{ userinfo, host: host_part, port, }
}
///|
/// Helper function to check if character is alphabetic
fn is_alpha(c : Char) -> Bool {
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
///|
/// Helper function to check if character is numeric
fn is_digit(c : Char) -> Bool {
c >= '0' && c <= '9'
}
///|
/// Create a new URI with the specified scheme.
///
/// Returns a new URI instance with the scheme component replaced.
/// All other components remain unchanged. This is an immutable operation.
///
/// # Examples
///
/// ```moonbit nocheck
/// // Change HTTP to HTTPS
/// let http_uri = @uri.parse("http://example.com/path")
/// let https_uri = http_uri.with_scheme(Some("https"))
/// inspect(https_uri.to_string(), content="https://example.com/path")
///
/// // Remove scheme (make relative)
/// let absolute_uri = @uri.parse("https://example.com/path")
/// let relative_uri = absolute_uri.with_scheme(None)
/// inspect(relative_uri.to_string(), content="example.com/path")
///
/// // Add scheme to relative URI
/// let relative = @uri.parse("/path/to/resource")
/// let absolute = relative.with_scheme(Some("https"))
/// inspect(absolute.to_string(), content="https:/path/to/resource")
/// ```
///
/// # Parameters
///
/// - `new_scheme`: The new scheme as `Some(String)`, or `None` to remove
///
/// # Returns
///
/// A new `Uri` with the specified scheme
pub fn Uri::with_scheme(self : Uri, new_scheme : String?) -> Uri {
{ ..self, scheme: new_scheme, }
}
///|
/// Create a new URI with the specified host.
///
/// Returns a new URI instance with the host component replaced.
/// If setting a host on a URI without authority, creates a new authority.
/// If removing the host (None), removes the entire authority.
///
/// # Examples
///
/// ```moonbit nocheck
/// // Change host
/// let uri = @uri.parse("https://old-host.com/path")
/// let new_uri = uri.with_host(Some("new-host.com"))
/// inspect(new_uri.to_string(), content="https://new-host.com/path")
///
/// // Add host to relative URI
/// let relative = @uri.parse("/path/to/resource")
/// let with_host = relative.with_host(Some("example.com"))
/// inspect(with_host.to_string(), content="example.com/path/to/resource")
///
/// // Remove host (makes URI relative)
/// let absolute = @uri.parse("https://example.com/path")
/// let relative_uri = absolute.with_host(None)
/// inspect(relative_uri.to_string(), content="https:/path")
/// ```
///
/// # Parameters
///
/// - `new_host`: The new host as `Some(String)`, or `None` to remove
///
/// # Returns
///
/// A new `Uri` with the specified host
pub fn Uri::with_host(self : Uri, new_host : String?) -> Uri {
match new_host {
Some(host) => {
let new_authority = match self.authority {
Some(auth) => Some({ ..auth, host, })
None => Some({ userinfo: None, host, port: None, })
}
{ ..self, authority: new_authority, }
}
None => { ..self, authority: None, }
}
}
///|
/// Create a new URI with the specified port
pub fn Uri::with_port(self : Uri, new_port : Int?) -> Uri {
match self.authority {
Some(auth) => {
let new_authority = Some({ ..auth, port: new_port, })
{ ..self, authority: new_authority, }
}
None =>
match new_port {
Some(port) => {
let new_authority = Some({
userinfo: None,
host: "",
port: Some(port),
})
{ ..self, authority: new_authority, }
}
None => self
}
}
}
///|
/// Create a new URI with the specified path
pub fn Uri::with_path(self : Uri, new_path : String) -> Uri {
{ ..self, path: new_path, }
}
///|
/// Create a new URI with the specified query
pub fn Uri::with_query(self : Uri, new_query : String?) -> Uri {
{ ..self, query: new_query, }
}
///|
/// Create a new URI with the specified fragment
pub fn Uri::with_fragment(self : Uri, new_fragment : String?) -> Uri {
{ ..self, fragment: new_fragment, }
}
///|
/// Check if the URI is absolute (has a scheme)
pub fn Uri::is_absolute(self : Uri) -> Bool {
match self.scheme {
Some(_) => true
None => false
}
}
///|
/// Check if the URI is relative (no scheme)
pub fn Uri::is_relative(self : Uri) -> Bool {
!self.is_absolute()
}
///|
/// Get the default port for a given scheme
fn default_port(scheme : String) -> Int? {
match scheme {
"http" => Some(80)
"https" => Some(443)
"ftp" => Some(21)
"ssh" => Some(22)
"telnet" => Some(23)
"smtp" => Some(25)
"dns" => Some(53)
"pop3" => Some(110)
"imap" => Some(143)
"ldap" => Some(389)
"imaps" => Some(993)
"pop3s" => Some(995)
_ => None
}
}
///|
/// Get the effective port (explicit port or default port for scheme)
pub fn Uri::effective_port(self : Uri) -> Int? {
match self.authority {
Some(auth) =>
match auth.port {
Some(port) => Some(port)
None =>
match self.scheme {
Some(scheme) => default_port(scheme)
None => None
}
}
None => None
}
}
///|
/// Normalize a URI (remove default ports, normalize path, etc.)
pub fn Uri::normalize(self : Uri) -> Uri {
let mut normalized = self
// Remove default port if present
match self.authority {
Some(auth) =>
match (self.scheme, auth.port) {
(Some(scheme), Some(port)) =>
match default_port(scheme) {
Some(default) =>
if port == default {
let new_auth = { ..auth, port: None, }
normalized = { ..normalized, authority: Some(new_auth), }
}
None => ()
}
_ => ()
}
None => ()
}
// Normalize path (remove redundant slashes, resolve . and ..)
normalized = { ..normalized, path: normalize_path(normalized.path), }
normalized
}
///|
/// Normalize a path by removing redundant elements
fn normalize_path(path : String) -> String {
if path.length() == 0 {
return path
}
// Split path into segments
let segments = path.split("/").collect()
let normalized_segments : Array[String] = []
for segment in segments {
let seg_str = segment.to_owned()
match seg_str {
"." => () // Skip current directory references
".." =>
// Remove last segment if possible (go up one directory)
if normalized_segments.length() > 0 &&
normalized_segments[normalized_segments.length() - 1] != ".." {
let _ = normalized_segments.pop()
} else if path.strip_prefix("/") == None {
// Only add .. for relative paths
normalized_segments.push("..")
}
_ => normalized_segments.push(seg_str)
}
}
// Reconstruct path
let result = normalized_segments.join("/")
// Preserve leading slash for absolute paths
if path.strip_prefix("/") != None && result.strip_prefix("/") == None {
"/" + result
} else {
result
}
}
///|
/// Resolve a relative URI against a base URI according to RFC3986.
///
/// This function implements URI resolution as defined in RFC3986 Section 5.2.
/// It combines a base URI with a relative URI to produce an absolute URI.
/// If the relative URI is already absolute, it is returned unchanged.
///
/// # Examples
///
/// ```moonbit nocheck
/// // Resolve relative path
/// let base = @uri.parse("https://example.com/docs/guide/")
/// let relative = @uri.parse("../api/reference.html")
/// let resolved = @uri.resolve(base, relative)
/// inspect(resolved.to_string(), content="https://example.com/docs/api/reference.html")
///
/// // Resolve absolute path (replaces base path)
/// let base2 = @uri.parse("https://example.com/old/path")
/// let absolute_path = @uri.parse("/new/path")
/// let resolved2 = @uri.resolve(base2, absolute_path)
/// inspect(resolved2.to_string(), content="https://example.com/new/path")
///
/// // Absolute URI returns unchanged
/// let base3 = @uri.parse("https://example.com/")
/// let absolute_uri = @uri.parse("https://other.com/path")
/// let resolved3 = @uri.resolve(base3, absolute_uri)
/// inspect(resolved3.to_string(), content="https://other.com/path")
/// ```
///
/// # Parameters
///
/// - `base`: The base URI (must be absolute)
/// - `relative`: The relative URI to resolve
///
/// # Returns
///
/// The resolved absolute URI
///
/// # Raises
///
/// - `UriError::InvalidPath` if the base URI is not absolute
///
/// # See Also
///
/// - RFC3986 Section 5.2 - Reference Resolution
pub fn resolve(base : Uri, relative : Uri) -> Uri raise UriError {
// If relative URI is absolute, return it as-is
if relative.is_absolute() {
return relative
}
// Base URI must be absolute
if base.is_relative() {
raise InvalidPath("Base URI must be absolute")
}
let mut resolved = base
// If relative has authority, use it and its path/query/fragment
match relative.authority {
Some(_) =>
resolved = {
scheme: base.scheme,
authority: relative.authority,
path: relative.path,
query: relative.query,
fragment: relative.fragment,
}
None =>
// No authority in relative, use base authority
if relative.path == "" {
// Empty path, use base path
resolved = {
scheme: base.scheme,
authority: base.authority,
path: base.path,
query: match relative.query {
Some(_) => relative.query
None => base.query
},
fragment: relative.fragment,
}
} else {
// Non-empty path
let resolved_path = if relative.path.strip_prefix("/") != None {
// Absolute path, use as-is
relative.path
} else {
// Relative path, resolve against base
resolve_relative_path(base.path, relative.path)
}
resolved = {
scheme: base.scheme,
authority: base.authority,
path: resolved_path,
query: relative.query,
fragment: relative.fragment,
}
}
}
resolved.normalize()
}
///|
/// Resolve a relative path against a base path
fn resolve_relative_path(base_path : String, relative_path : String) -> String {
// Remove filename from base path (keep directory part)
let base_dir = if base_path.contains("/") {
let parts = base_path.split("/").collect()
if parts.length() > 1 {
let dir_parts : Array[String] = []
for i = 0; i < parts.length() - 1; i = i + 1 {
dir_parts.push(parts[i].to_owned())
}
dir_parts.join("/") + "/"
} else {
""
}
} else {
""
}
normalize_path(base_dir + relative_path)
}
///|
/// Parse query string into key-value pairs
fn parse_query(query_string : String) -> Array[(String, String)] {
if query_string.length() == 0 {
return []
}
let pairs = query_string.split("&").collect()
let result : Array[(String, String)] = []
for pair in pairs {
let pair_str = pair.to_owned()
if pair_str.contains("=") {
let parts = pair_str.split("=").collect()
if parts.length() >= 2 {
let key = parts[0].to_owned()
let value = parts[1].to_owned()
result.push((key, value))
} else {
// No equals sign, treat as key with empty value
result.push((pair_str, ""))
}
} else {
// No equals sign, treat as key with empty value
result.push((pair_str, ""))
}
}
result
}
///|
/// Build query string from key-value pairs
fn build_query(pairs : Array[(String, String)]) -> String {
let parts : Array[String] = []
for i = 0; i < pairs.length(); i = i + 1 {
let (key, value) = pairs[i]
if value.length() == 0 {
parts.push(key)
} else {
parts.push(key + "=" + value)
}
}
parts.join("&")
}
///|
/// Get a specific query parameter value
pub fn Uri::get_query_param(self : Uri, param_name : String) -> String? {
match self.query {
Some(query_str) => {
let params = parse_query(query_str)
for i = 0; i < params.length(); i = i + 1 {
let (key, value) = params[i]
if key == param_name {
return Some(value)
}
}
None
}
None => None
}
}
///|
/// Add or update a query parameter
pub fn Uri::with_query_param(
self : Uri,
param_name : String,
param_value : String,
) -> Uri {
let current_params = match self.query {
Some(query_str) => parse_query(query_str)
None => []
}
let new_params : Array[(String, String)] = []
let mut found = false
// Update existing parameter or collect others
for i = 0; i < current_params.length(); i = i + 1 {
let (key, value) = current_params[i]
if key == param_name {
new_params.push((param_name, param_value))
found = true
} else {
new_params.push((key, value))
}
}
// Add new parameter if not found
if !found {
new_params.push((param_name, param_value))
}
let new_query = if new_params.length() > 0 {
Some(build_query(new_params))
} else {
None
}
{ ..self, query: new_query, }
}
///|
/// Remove a query parameter
pub fn Uri::remove_query_param(self : Uri, param_name : String) -> Uri {
match self.query {
Some(query_str) => {
let current_params = parse_query(query_str)
let new_params : Array[(String, String)] = []
// Keep all parameters except the one to remove
for i = 0; i < current_params.length(); i = i + 1 {
let (key, value) = current_params[i]
if key != param_name {
new_params.push((key, value))
}
}
let new_query = if new_params.length() > 0 {
Some(build_query(new_params))
} else {
None
}
{ ..self, query: new_query, }
}
None => self // No query to modify
}
}
///|
/// URL encode a string using percent encoding according to RFC3986.
///
/// Encodes unsafe characters as %XX where XX is the hexadecimal representation.
/// This is essential for proper URI component handling, especially in query parameters,
/// path segments, and other URI components that may contain special characters.
///
/// # Examples
///
/// ```moonbit nocheck
/// // Basic encoding with spaces and punctuation
/// let encoded = @uri.Uri::encode("hello world!")
/// inspect(encoded, content="hello%20world%21")
///
/// // Encoding special characters
/// let special = @uri.Uri::encode("user@domain.com")
/// inspect(special, content="user%40domain.com")
///
/// // Encoding query parameter values
/// let query_value = @uri.Uri::encode("search term with spaces")
/// inspect(query_value, content="search%20term%20with%20spaces")
///
/// // Safe characters remain unchanged
/// let safe = @uri.Uri::encode("ABCabc123-_.~")
/// inspect(safe, content="ABCabc123-_.~")
/// ```
///
/// # Parameters
///
/// - `input`: The string to encode
///
/// # Returns
///
/// The percent-encoded string safe for use in URI components
///
/// # See Also
///
/// - `decode()` - Decode percent-encoded strings
pub fn Uri::encode(input : String) -> String {
let mut result = ""
for i = 0; i < input.length(); i = i + 1 {
let char_code_cu = input.code_unit_at(i)
let char_code = char_code_cu.to_int()
let c = char_code.unsafe_to_char()
if is_unreserved(c) {
result = result + c.to_string()
} else {
// Percent encode the character
let hex = int_to_hex(char_code)
result = result + "%" + (if hex.length() == 1 { "0" + hex } else { hex })
}
}
result
}
///|
/// URL decode a percent-encoded string according to RFC3986.
///
/// Decodes %XX sequences back to their original characters.
/// Invalid percent sequences are left as-is for robustness.
///
/// # Examples
///
/// ```moonbit nocheck
/// // Basic decoding
/// let decoded = @uri.Uri::decode("hello%20world%21")
/// inspect(decoded, content="hello world!")
///
/// // Decoding special characters
/// let email = @uri.Uri::decode("user%40domain.com")
/// inspect(email, content="user@domain.com")
///
/// // Mixed encoded and unencoded content
/// let mixed = @uri.Uri::decode("path%2Fto%2Fresource")
/// inspect(mixed, content="path/to/resource")
///
/// // Invalid sequences are preserved
/// let invalid = @uri.Uri::decode("hello%ZZ%20world")
/// inspect(invalid, content="hello%ZZ world")
///
/// // Already decoded strings remain unchanged
/// let plain = @uri.Uri::decode("hello world")
/// inspect(plain, content="hello world")
/// ```
///
/// # Parameters
///
/// - `input`: The percent-encoded string to decode
///
/// # Returns
///
/// The decoded string with %XX sequences converted to characters
///
/// # See Also
///
/// - `encode()` - Encode strings for safe URI usage
pub fn Uri::decode(input : String) -> String {
let mut result = ""
let mut i = 0
while i < input.length() {
let char_code_cu = input.code_unit_at(i)
let char_code = char_code_cu.to_int()
let c = char_code.unsafe_to_char()
if c == '%' {
// Check if we have at least 2 more characters
if i + 2 < input.length() {
// Get the next two characters for hex decoding
let hex_str = input[i + 1:i + 3].to_owned()
match hex_to_int(hex_str) {
Some(decoded_value) => {
result = result + decoded_value.unsafe_to_char().to_string()
i = i + 3
}
None => {
// Invalid hex sequence, treat % as literal
result = result + "%"
i = i + 1
}
}
} else {
// Not enough characters for percent encoding
result = result + "%"
i = i + 1
}
} else {
result = result + c.to_string()
i = i + 1
}
}
result
}
///|
/// Parse query string into key-value pairs
///
/// # Example
///
/// ```moonbit nocheck
/// let params = @uri.Uri::parse_query("name=John&age=30&city=New%20York")
/// inspect(params.length(), content="3")
/// ```
///
/// # Parameters
///
/// - `query_string`: The query string to parse
///
/// # Returns
///
/// Array of key-value pairs
pub fn Uri::parse_query(query_string : String) -> Array[(String, String)] {
if query_string.length() == 0 {
return []
}
let pairs = query_string.split("&").collect()
let result : Array[(String, String)] = []
for pair in pairs {
let pair_str = pair.to_owned()
if pair_str.contains("=") {
let parts = pair_str.split("=").collect()
if parts.length() >= 2 {
let key = Uri::decode(parts[0].to_owned())
let value = Uri::decode(parts[1].to_owned())
result.push((key, value))
} else {
// No equals sign, treat as key with empty value
result.push((Uri::decode(pair_str), ""))
}
} else {
// No equals sign, treat as key with empty value
result.push((Uri::decode(pair_str), ""))
}
}
result
}
///|
/// Build query string from key-value pairs
///
/// # Example
///
/// ```moonbit nocheck
/// let query = @uri.Uri::build_query([("name", "John Doe"), ("age", "30")])
/// inspect(query, content="name=John%20Doe&age=30")
/// ```
///
/// # Parameters
///
/// - `pairs`: Array of key-value pairs
///
/// # Returns
///
/// Encoded query string
pub fn Uri::build_query(pairs : Array[(String, String)]) -> String {
let parts : Array[String] = []
for i = 0; i < pairs.length(); i = i + 1 {
let (key, value) = pairs[i]
let encoded_key = Uri::encode(key)
if value.length() == 0 {
parts.push(encoded_key)
} else {
let encoded_value = Uri::encode(value)
parts.push(encoded_key + "=" + encoded_value)
}
}
parts.join("&")
}
///|
/// Get path segments as an array
///
/// # Example
///
/// ```moonbit nocheck
/// let uri = @uri.parse("/path/to/resource")
///
/// let _segments = uri.path_segments()
/// // _segments will contain ["path", "to", "resource"]
/// ```
///
/// # Returns
///
/// Array of path segments (excluding empty segments from leading/trailing slashes)
pub fn Uri::path_segments(self : Uri) -> Array[String] {
if self.path.length() == 0 {
return []
}
let segments = self.path.split("/").collect()
let result : Array[String] = []
for segment in segments {
let seg_str = segment.to_owned()
if seg_str.length() > 0 {
result.push(Uri::decode(seg_str))
}
}
result
}
///|
/// Create a new URI with the specified path segments
///
/// # Example
///
/// ```moonbit nocheck
/// let uri = @uri.empty().with_path_segments(["api", "v1", "users"])
/// inspect(uri.path(), content="/api/v1/users")
/// ```
///
/// # Parameters
///
/// - `segments`: Array of path segments
///
/// # Returns
///
/// New URI with the specified path segments
pub fn Uri::with_path_segments(self : Uri, segments : Array[String]) -> Uri {
if segments.length() == 0 {
return { ..self, path: "", }
}
let encoded_segments : Array[String] = []
for segment in segments {
encoded_segments.push(Uri::encode(segment.to_string()))
}
let path = "/" + encoded_segments.join("/")
{ ..self, path, }
}
///|
/// Parse userinfo into username and password components
///
/// # Example
///
/// ```moonbit nocheck
/// let uri = @uri.parse("https://user:pass@example.com")
/// match uri.userinfo_components() {
/// Some((user, Some(pass))) => {
/// inspect(user, content="user")
/// inspect(pass, content="pass")
/// }
/// _ => ()
/// }
/// ```
///
/// # Returns
///
/// Optional tuple of (username, optional password)
pub fn Uri::userinfo_components(self : Uri) -> (String, String?)? {
match self.authority {
Some(auth) =>
match auth.userinfo {
Some(userinfo) =>
if userinfo.contains(":") {
let parts = userinfo.split(":").collect()
if parts.length() >= 2 {
let username = Uri::decode(parts[0].to_owned())
let password = Uri::decode(parts[1].to_owned())
Some((username, Some(password)))
} else {
let username = Uri::decode(userinfo)
Some((username, None))
}
} else {
let username = Uri::decode(userinfo)
Some((username, None))
}
None => None
}
None => None
}
}
///|
/// Create a new URI with the specified username and password
///
/// # Example
///
/// ```moonbit nocheck
/// let _uri = @uri.empty()
/// .with_host(Some("example.com"))
/// .with_userinfo(Some("user"), Some("pass"))
/// // Creates URI with encoded userinfo
/// ```
///
/// # Parameters
///
/// - `username`: Optional username
/// - `password`: Optional password
///
/// # Returns
///
/// New URI with the specified userinfo
pub fn Uri::with_userinfo(
self : Uri,
username : String?,
password : String?,
) -> Uri {
match username {
Some(user) => {
let userinfo = match password {
Some(pass) => Uri::encode(user) + ":" + Uri::encode(pass)
None => Uri::encode(user)
}
let new_authority = match self.authority {
Some(auth) => Some({ ..auth, userinfo: Some(userinfo), })
None => Some({ userinfo: Some(userinfo), host: "", port: None, })
}
{ ..self, authority: new_authority, }
}
None =>
match self.authority {
Some(auth) => {
let new_authority = Some({ ..auth, userinfo: None, })
{ ..self, authority: new_authority, }
}
None => self
}
}
}
///|
/// Check if character is unreserved (doesn't need encoding)
fn is_unreserved(c : Char) -> Bool {
is_alpha(c) || is_digit(c) || c == '-' || c == '.' || c == '_' || c == '~'
}
///|
/// Convert hex string to integer
fn hex_to_int(hex_str : String) -> Int? {
if hex_str.length() != 2 {
return None
}
let mut result = 0
for i = 0; i < 2; i = i + 1 {
let char_code_cu = hex_str.code_unit_at(i)
let char_code = char_code_cu.to_int()
let c = char_code.unsafe_to_char()
let digit_value = if c >= '0' && c <= '9' {
char_code - 48 // '0' = 48
} else if c >= 'A' && c <= 'F' {
char_code - 55 // 'A' = 65, so 'A' - 55 = 10
} else if c >= 'a' && c <= 'f' {
char_code - 87 // 'a' = 97, so 'a' - 87 = 10
} else {
return None // Invalid hex character
}
result = result * 16 + digit_value
}
Some(result)
}
///|
/// Convert integer to hex string
fn int_to_hex(value : Int) -> String {
if value == 0 {
return "0"
}
let mut result = ""
let mut n = value
let hex_chars = "0123456789ABCDEF"
while n > 0 {
let digit = n % 16
if digit < hex_chars.length() {
let char_code = hex_chars.code_unit_at(digit).to_int()
result = char_code.unsafe_to_char().to_string() + result
} else {
break
}
n = n / 16
}
result
}