Module url/index

url/index
Stability: unstable — the surface moved twice in September 2026 and one shape question is still open in the code. `Url.parse` became `Result(Url, UrlError)` with `parse_exn` as the wrapper (D13), and the seven fallible `set_*` setters plus `join`/`path_segments`/`query_pairs` landed on 2026-09-09; none of that has been through a release yet. The open question is the EMPTY host. `Url.parse` folds "no `//` authority" and "an authority whose host is empty" into the same `host() == .None` (`mailto:a@b` and `file:///a/b` are indistinguishable), which makes `path_segments()` answer `.None` for `file:///a/b` — Rust answers `Some(["a", "b"])`, because a `file:` URL can be a base. The same fold makes the empty host asymmetric: `http://:80/x` keeps a `.Some("")` host while `http:///x` loses it. Fixing it means either an `Option(String)`-inside-authority representation or a separate "has authority" bit, and both change what `host()` returns. Freezing follows that decision, plus a decision on the authority split: this parser takes the FIRST `@` where the WHATWG URL Standard (and so Rust's `url`) takes the LAST, and whether `userinfo()` should split into `username()`/`password()` the way Rust does. — stable modules only change additively; this one may still change.

URL parsing and formatting per RFC 3986 (simplified).

Url.parse is STRICT about the byte set: RFC 3986 §2 admits only unreserved, gen-delims, sub-delims and %, and every other byte — a raw space, a control byte, a CR or LF, a raw UTF-8 byte — is rejected with UrlError.InvalidCharacter(pos). Callers with non-ASCII or space-bearing components percent-encode first, via std/encoding/percent (percent.encode).

This is a security boundary, not a tidiness rule: Url.path() flows into HttpRequest's request line, so a URL carrying a raw CRLF would otherwise split one HTTP request into two.

Escaping

Parsing NEVER decodes. scheme() is lowercased and everything else — host(), path(), query(), fragment(), userinfo() — comes back byte-for-byte as written, percent escapes intact. That is Rust's url shape and it is the only safe one for a component that goes back on a wire: decoding %2F in a path would produce a string whose / count no longer matches the URL's own structure.

Decoding is therefore an explicit step, and there are two spellings because there are two grammars: path_segments() and query_pairs() decode for you (the latter also reading + as a space, per application/x-www-form-urlencoded), while std/encoding/percent's percent_decode handles one value at a time and REPORTS a malformed escape. The accessors are deliberately lossy where percent_decode is strict — one bad escape in one query value must not fail the whole accessor.

Absent versus empty

The Option-returning accessors distinguish "not written" from "written empty", and callers get this wrong: http://h/ has NO query (.None), http://h/? has an EMPTY one (.Some("")), and the two are different URLs that stay different through to_string. Same for fragment() (a trailing #) and userinfo() (a bare @). path() is the exception — it is a plain String and "" covers both, because RFC 3986 §3.3 makes the two the same thing.

Stability

unstable — the surface moved twice in September 2026 and one shape question is still open in the code. Url.parse became Result(Url, UrlError) with parse_exn as the wrapper (D13), and the seven fallible set_* setters plus join/path_segments/query_pairs landed on 2026-09-09; none of that has been through a release yet.

The open question is the EMPTY host. Url.parse folds "no // authority" and "an authority whose host is empty" into the same host() == .None (mailto:a@b and file:///a/b are indistinguishable), which makes path_segments() answer .None for file:///a/b — Rust answers Some(["a", "b"]), because a file: URL can be a base. The same fold makes the empty host asymmetric: http://:80/x keeps a .Some("") host while http:///x loses it. Fixing it means either an Option(String)-inside-authority representation or a separate "has authority" bit, and both change what host() returns. Freezing follows that decision, plus a decision on the authority split: this parser takes the FIRST @ where the WHATWG URL Standard (and so Rust's url) takes the LAST, and whether userinfo() should split into username()/password() the way Rust does.

Types

Url object
Url

Parsed URL with scheme, host, port, path, query, and fragment components.

Fields

NameTypeDescription
_schemeString
_hostOption(String)
_portOption(u16)
_pathString
_queryOption(String)
_fragmentOption(String)
_userinfoOption(String)

Trait Implementations

impl(generic(T : Type), where(T <: ToString), T : (ToString))
impl(generic(T : Type), where(T <: ToString), T : (ToString), Format)
format : fn(self : Self, spec : str) -> String

Render self under spec. An unrecognised spec degrades to the plain to_string() rendering rather than failing.

Parameters

NameTypeNotes
selfSelf
specstr

Returns: String

impl(Url, ...)
parse : (Url) fn(s : String) -> Result(Url, UrlError)

Parse a URL string into a Url (D13 — a pure transform returns a Result; parse_exn is the effect-carrying wrapper).

Supports scheme://[userinfo@]host[:port]/path[?query][#fragment] and opaque scheme:path URIs like mailto:user@example.com.

Parameters

NameTypeNotes
sString

Returns: Result(Url, UrlError)

parse_exn : (Url) fn(s : String, exn : Exception) -> Url

parse as an effect: throws the UrlError through exn instead of returning it. Kept for callers already inside an effect scope.

Parameters

NameTypeNotes
sString
exnException

Returns: Url

scheme : (Url) fn(self : Url) -> String

.None for a relative reference — which is what makes §5.3's first branch ("the reference is already absolute") testable.

Parameters

NameTypeNotes
selfUrl

Returns: String

host : (Url) fn(self : Url) -> Option(String)

The host, still percent-encoded, with an IPv6 literal keeping its brackets ([::1]) — Rust's Url::host_str.

.None means "no host to speak of", which covers TWO different URLs: an opaque URI with no // authority at all (mailto:a@b), and an authority whose host is empty (file:///a/b). This library does not distinguish them, which is a divergence from Rust — see the module doc's ## Stability note.

Parameters

NameTypeNotes
selfUrl

Returns: Option(String)

port : (Url) fn(self : Url) -> Option(u16)

The port as written, or .None when the URL carried no :port.

.None is NOT "the scheme's default port": nothing here knows that http means 80, so a caller that needs a port supplies its own default. A : with nothing after it is InvalidPort at parse time rather than .None, so .None only ever means the colon was absent.

Parameters

NameTypeNotes
selfUrl

Returns: Option(u16)

path : (Url) fn(self : Url) -> String

Never optional in §5.3 — a missing path IS the empty path, and the empty-path case is what selects the base's path in the merge.

Parameters

NameTypeNotes
selfUrl

Returns: String

query : (Url) fn(self : Url) -> Option(String)

.None for "no ? was written"; .Some("") for a bare ?. §5.3 carries the base's query only in the first of the two cases.

Parameters

NameTypeNotes
selfUrl

Returns: Option(String)

fragment : (Url) fn(self : Url) -> Option(String)

.None for "no # was written". A fragment is never inherited from the base, so this one is copied straight through.

Parameters

NameTypeNotes
selfUrl

Returns: Option(String)

userinfo : (Url) fn(self : Url) -> Option(String)

The userinfo WITHOUT the trailing @, still percent-encoded and NOT split at the :user:pw@h answers .Some("user:pw"), where Rust splits username() from password().

.None means the authority had no @; .Some("") means it had one with nothing before it (http://@h/). Note this splits at the FIRST @ in the authority, where the WHATWG URL Standard — and so Rust's url — splits at the LAST one: http://a@b@h/ is userinfo a, host b@h here and userinfo a@b, host h there. RFC 3986 §3.2.1 does not admit a raw @ inside userinfo at all, which is why set_userinfo rejects one outright rather than picking a side.

Parameters

NameTypeNotes
selfUrl

Returns: Option(String)

set_scheme : (Url) fn(self : Url, scheme : String) -> Result(unit, UrlError)

Replace the scheme. Must be ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ).

Parameters

NameTypeNotesDescription
selfUrl
schemeString

.None for a relative reference — which is what makes §5.3's first branch ("the reference is already absolute") testable.

Returns: Result(unit, UrlError)

set_host : (Url) fn(self : Url, host : Option(String)) -> Result(unit, UrlError)

Replace the host, or remove it with .None.

An IPv6 literal must be bracketed ([::1]) — that is how RFC 3986 §3.2.2 keeps the host's colons apart from the port's. /, ?, # and @ are rejected because each of them ENDS the host in the grammar, so accepting one would mean the next parse read a different URL than the one built.

The .None case removes the whole authority, so a following to_string renders the opaque scheme:path form.

Parameters

NameTypeNotesDescription
selfUrl
hostOption(String)

The host, still percent-encoded, with an IPv6 literal keeping its brackets ([::1]) — Rust's Url::host_str.

.None means "no host to speak of", which covers TWO different URLs: an opaque URI with no // authority at all (mailto:a@b), and an authority whose host is empty (file:///a/b). This library does not distinguish them, which is a divergence from Rust — see the module doc's ## Stability note.

Returns: Result(unit, UrlError)

set_port : (Url) fn(self : Url, port : Option(u16)) -> unit

Replace the port, or remove it with .None. Infallible: every u16 is a legal port, including 0.

Parameters

NameTypeNotesDescription
selfUrl
portOption(u16)

The port as written, or .None when the URL carried no :port.

.None is NOT "the scheme's default port": nothing here knows that http means 80, so a caller that needs a port supplies its own default. A : with nothing after it is InvalidPort at parse time rather than .None, so .None only ever means the colon was absent.

Returns: unit

set_path : (Url) fn(self : Url, path : String) -> Result(unit, UrlError)

Replace the path.

With a host present the path must be empty or start with /, because to_string writes scheme://host immediately followed by the path — a path of x would render http://hostx. With NO host the path must not start with //, which would render scheme://... and re-parse as an authority: a different URL than the one built.

Parameters

NameTypeNotesDescription
selfUrl
pathString

Never optional in §5.3 — a missing path IS the empty path, and the empty-path case is what selects the base's path in the merge.

Returns: Result(unit, UrlError)

set_query : (Url) fn(self : Url, query : Option(String)) -> Result(unit, UrlError)

Replace the query (WITHOUT the leading ?), or remove it with .None.

Parameters

NameTypeNotesDescription
selfUrl
queryOption(String)

.None for "no ? was written"; .Some("") for a bare ?. §5.3 carries the base's query only in the first of the two cases.

Returns: Result(unit, UrlError)

set_fragment : (Url) fn(self : Url, fragment : Option(String)) -> Result(unit, UrlError)

Replace the fragment (WITHOUT the leading #), or remove it with .None. The fragment is LAST in the grammar, so nothing can follow it and there is no delimiter to guard — every URI byte is legal here, including /, ? and #.

Parameters

NameTypeNotesDescription
selfUrl
fragmentOption(String)

.None for "no # was written". A fragment is never inherited from the base, so this one is copied straight through.

Returns: Result(unit, UrlError)

set_userinfo : (Url) fn(self : Url, userinfo : Option(String)) -> Result(unit, UrlError)

Replace the userinfo (WITHOUT the trailing @), or remove it with .None.

A @ inside userinfo is rejected: the authority splits at its FIRST @, so set_userinfo("a@b") on //host would re-parse with userinfo a and host b@host — the classic credential-confusion shape, where the host a reader sees is not the host the URL resolves to.

Parameters

NameTypeNotesDescription
selfUrl
userinfoOption(String)

The userinfo WITHOUT the trailing @, still percent-encoded and NOT split at the :user:pw@h answers .Some("user:pw"), where Rust splits username() from password().

.None means the authority had no @; .Some("") means it had one with nothing before it (http://@h/). Note this splits at the FIRST @ in the authority, where the WHATWG URL Standard — and so Rust's url — splits at the LAST one: http://a@b@h/ is userinfo a, host b@h here and userinfo a@b, host h there. RFC 3986 §3.2.1 does not admit a raw @ inside userinfo at all, which is why set_userinfo rejects one outright rather than picking a side.

Returns: Result(unit, UrlError)

host_port : (Url) fn(self : Url) -> Option(String)

host:port, or the bare host when no port was written — the string a Host: header and a connect() both want, assembled once instead of at every call site.

.None exactly when host() is .None. An IPv6 host keeps its brackets, so the result stays unambiguous ([::1]:8080).

Parameters

NameTypeNotes
selfUrl

Returns: Option(String)

origin : (Url) fn(self : Url) -> String

scheme://host[:port] — the same-origin key, and the prefix to hang a path off when building a sibling URL.

Two divergences from the web platform's notion of an origin, both because nothing here knows a scheme's default port: the port is included only when it was WRITTEN, so http://h and http://h:80 give different strings for the same origin; and a URL with no host answers the bare scheme rather than the opaque "null" that HTML specifies.

Parameters

NameTypeNotes
selfUrl

Returns: String

impl(Url, ...)
join : (Url) fn(self : Url, reference : String) -> Result(Url, UrlError)

Resolve reference against this URL — RFC 3986 §5.3 reference resolution, and url::Url::join in Rust.

An absolute reference replaces everything. A network-path reference (//host/p) keeps only the scheme. An absolute-path reference (/p) keeps the authority. A relative reference (p, ../p) resolves against the base's DIRECTORY — https://h/a/b joined with c is https://h/a/c, because b is a document, not a directory. An empty reference keeps the base's path and query but drops its fragment; a bare #f changes only the fragment.

base := Url.parse("https://example.com/a/b").unwrap();
base.join(`c`).unwrap().to_string();       // https://example.com/a/c
base.join(`/c`).unwrap().to_string();      // https://example.com/c
base.join(`../c`).unwrap().to_string();    // https://example.com/c
base.join(`https://x/y`).unwrap().to_string(); // https://x/y

Parameters

NameTypeNotes
selfUrl
referenceString

Returns: Result(Url, UrlError)

path_segments : (Url) fn(self : Url) -> Option(ArrayList(String))

The path split on /, percent-DECODED, or .None for a URL that cannot be a base — Rust's Url::path_segments.

"Cannot be a base" means no authority: mailto:a@b has an opaque path that is not a /-separated hierarchy, and splitting it would invent structure that is not there.

A trailing / yields a final EMPTY segment, as Rust's does — that is how /a/ is distinguishable from /a.

Parameters

NameTypeNotes
selfUrl

Returns: Option(ArrayList(String))

query_pairs : (Url) fn(self : Url) -> ArrayList(Tuple(0 : String, 1 : String))

The query string parsed as key=value pairs, percent-decoded, with + read as a space — Rust's Url::query_pairs, and the application/x-www-form-urlencoded rules a ?a=b&c=d query actually follows.

A key with no = yields an empty value (?flag is ("flag", "")), an empty pair between two &s is skipped, and a value containing = keeps everything after the FIRST one (?a=b=c is ("a", "b=c")). Order and duplicates are preserved: ?a=1&a=2 is two pairs.

Parameters

NameTypeNotes
selfUrl

Returns: ArrayList(Tuple(0 : String, 1 : String))

impl(Url, ToString(...))
to_string : ( self -> { result := `${self._scheme}:`; match( self._host, .Some(h) => { result = result.concat(`//`); match( self._userinfo, .Some(ui) => { result = result.concat(ui).concat(`@`); }, .None => () ); result = result.concat(h); match( self._port, .Some(p) => { result = result.concat(`:${p}`); }, .None => () ); }, .None => () ); result = result.concat(self._path); match( self._query, .Some(q) => { result = result.concat(`?`).concat(q); }, .None => () ); match( self._fragment, .Some(f) => { result = result.concat(`#`).concat(f); }, .None => () ); result } )
Methods
to_string : (Url) fn(self : Url) -> String

Render self as the text a USER should read — Rust's Display::fmt, not its Debug. Hand-written (or generated by derive(Error) from a per-variant format string) whenever the structural form would be wrong.

Parameters

NameTypeNotes
selfUrl

Returns: String

UrlError enum
UrlError

Errors that can occur during URL parsing.

Variants

VariantFieldsDescription
EmptyInput

The input string was empty. A URL needs at least a scheme.

MissingScheme

No scheme: prefix, or the scheme is not ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ). A bare example.com/x and a 1http://x both land here — Yo has no "relative URL" mode, so a reference is resolved through join against a base instead.

InvalidPort

The :port was empty, held a non-digit, or exceeded 65535. There is no separate "port too large" variant; a value out of range is as malformed as a letter.

InvalidCharacterpos: usize

A byte outside RFC 3986 §2 was found at BYTE offset pos in the input — a raw space, a control byte, a CR/LF, or a raw UTF-8 byte. pos is what lets a caller point at the offender in a 4 KiB URL instead of only knowing that one exists. Percent-encode the component first.

Othermsg: String

A structural rejection with no dedicated variant, carrying its own message: an authority-bearing URL whose path does not start with /, an invalid scheme handed to set_scheme, or one of the three to_string round-trip guards on the setters (bare IPv6 host, relative path beside a host, //-leading path with no host).

Trait Implementations

impl(generic(T : Type), where(T <: ToString), T : (ToString))
impl(generic(T : Type), where(T <: ToString), T : (ToString), Format)
format : fn(self : Self, spec : str) -> String

Render self under spec. An unrecognised spec degrades to the plain to_string() rendering rather than failing.

Parameters

NameTypeNotes
selfSelf
specstr

Returns: String

Methods
to_string : (UrlError) fn(self : UrlError) -> String

Parameters

NameTypeNotes
selfUrlError

Returns: String

source : (UrlError) fn(self : UrlError) -> Option(dyn( + ToString))

The error that caused this one, or .None at the root of the chain.

Rust's Error::source. Defaulted to .None, so an error with nothing underneath it implements the trait by saying only what it is; a wrapper overrides it to hand back what it wrapped. Walking the chain to the root cause is not expressible yet — the returned Dyn loses the Error trait on an erased receiver, so a caller can print one link but cannot follow it (#521, issues/self-trait-in-a-return-type-loses-the-trait-on-an-erased-receiver.md).

Parameters

NameTypeNotes
selfUrlError

Returns: Option(dyn( + ToString))