Module url/index
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
Parsed URL with scheme, host, port, path, query, and fragment components.
Fields
| Name | Type | Description |
|---|---|---|
_scheme | String | |
_host | Option(String) | |
_port | Option(u16) | |
_path | String | |
_query | Option(String) | |
_fragment | Option(String) | |
_userinfo | Option(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) -> StringRender self under spec. An unrecognised spec degrades to the plain
to_string() rendering rather than failing.
Parameters
| Name | Type | Notes |
|---|---|---|
self | Self | |
spec | str |
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
| Name | Type | Notes |
|---|---|---|
s | String |
parse_exn : (Url) fn(s : String, exn : Exception) -> Urlscheme : (Url) fn(self : Url) -> Stringhost : (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
| Name | Type | Notes |
|---|---|---|
self | Url |
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
| Name | Type | Notes |
|---|---|---|
self | Url |
Returns: Option(u16)
path : (Url) fn(self : Url) -> Stringquery : (Url) fn(self : Url) -> Option(String)fragment : (Url) fn(self : Url) -> 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
| Name | Type | Notes |
|---|---|---|
self | Url |
set_scheme : (Url) fn(self : Url, scheme : String) -> 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
| Name | Type | Notes | Description |
|---|---|---|---|
self | Url | ||
host | Option(String) | The host, still percent-encoded, with an IPv6 literal keeping its
brackets (
|
set_port : (Url) fn(self : Url, port : Option(u16)) -> unitReplace the port, or remove it with .None. Infallible: every u16 is a
legal port, including 0.
Parameters
| Name | Type | Notes | Description |
|---|---|---|---|
self | Url | ||
port | Option(u16) | The port as written, or
|
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
| Name | Type | Notes | Description |
|---|---|---|---|
self | Url | ||
path | 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. |
set_query : (Url) fn(self : Url, query : Option(String)) -> 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
| Name | Type | Notes | Description |
|---|---|---|---|
self | Url | ||
fragment | Option(String) |
|
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
| Name | Type | Notes | Description |
|---|---|---|---|
self | Url | ||
userinfo | Option(String) | The userinfo WITHOUT the trailing
|
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
| Name | Type | Notes |
|---|---|---|
self | Url |
origin : (Url) fn(self : Url) -> Stringscheme://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
| Name | Type | Notes |
|---|---|---|
self | Url |
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
| Name | Type | Notes |
|---|---|---|
self | Url | |
reference | String |
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
| Name | Type | Notes |
|---|---|---|
self | Url |
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
| Name | Type | Notes |
|---|---|---|
self | Url |
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
Errors that can occur during URL parsing.
Variants
| Variant | Fields | Description |
|---|---|---|
EmptyInput | The input string was empty. A URL needs at least a scheme. | |
MissingScheme | No | |
InvalidPort | The | |
InvalidCharacter | pos: usize | A byte outside RFC 3986 §2 was found at BYTE offset |
Other | msg: String | A structural rejection with no dedicated variant, carrying its own
message: an authority-bearing URL whose path does not start with |
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) -> StringRender self under spec. An unrecognised spec degrades to the plain
to_string() rendering rather than failing.
Parameters
| Name | Type | Notes |
|---|---|---|
self | Self | |
spec | str |
Returns: String
Methods
to_string : (UrlError) fn(self : UrlError) -> Stringsource : (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
| Name | Type | Notes |
|---|---|---|
self | UrlError |