Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

std.url

import std.url parses and formats absolute URLs, percent-encodes data for a named URL component, and splits and builds query strings. It has one error type, UrlError.

The module is checked Plenty code over std.bytes, with no native primitives of its own. It is available on the supported x86-64 Linux GNU target. A URL is not a filesystem path: std.path is a separate type and nothing converts between them.

import std.url
from std.url import Component, Pair, UrlError

def main() -> Result[(), AllocError | IoError | UrlError]:
    url = std.url.parse("https://example.com:8443/search?q=caf%C3%A9&page=2#top", 2048)?
    print(url.scheme, url.host, url.port, url.path)?
    match &url.query:
        case Some(query):
            for pair in std.url.query_pairs(query)?:
                print(pair.key, "=", pair.value)?
        case Nothing:
            print("no query")?
    print(std.url.encode("a b/c", Component.Segment)?)?
    print(std.url.query_text(&[Pair("q", "fish & chips"), Pair("n", "1")]?)?)?
    Ok(())
https Option[str].Some("example.com") Option[i64].Some(8443) /search
q = café
page = 2
a%20b%2Fc
q=fish%20%26%20chips&n=1

The accepted syntax

std.url implements the generic syntax of RFC 3986, with these stated differences. It is not the WHATWG URL Standard and does not imitate browsers.

RuleBehavior
SchemeRequired. Relative references such as /a/b, //host/a, and ?q are MissingScheme
CharactersASCII only, exactly the characters RFC 3986 allows in each component. Spaces, controls, "<>\^{} and the backtick, [/] outside an address literal, and all non-ASCII text are InvalidByte
Escapes% must be followed by two hex digits of either case, or the URL is InvalidEscape
UserinfoEverything before the first @ of the authority. A second @ is an error, never a choice of host
HostA registered name, or [IPv6 address], or [v…] (IPvFuture). Empty is allowed. A dotted IPv4 address is an ordinary registered name
IPv6The RFC 3986 IPv6address grammar: one optional ::, an optional trailing dotted IPv4 address whose octets have no leading zeros. Zone identifiers (%25eth0) are rejected
PortNarrower than the RFC: one to five digits, no leading zero, at most 65535. An empty port (http://h:/) is InvalidPort
Internationalized namesNo IDNA processing. A Unicode host must already be Punycode (xn--...) or percent-encoded UTF-8
NormalizationNone. Case, . and .. segments, escapes, default ports, and empty components are kept exactly as written

Because nothing is normalized and the port has one spelling, every accepted URL formats back to the identical text.

Url

pub class Url:
    pub scheme: str
    pub userinfo: Option[str]
    pub host: Option[str]
    pub port: Option[i64]
    pub path: str
    pub query: Option[str]
    pub fragment: Option[str]

Components hold the text between the delimiters, still percent-encoded and without the delimiters themselves. Nothing means the delimiter was absent; Some("") means it was present with nothing after it, so s:, s:?, and s:// are three different values. host is Nothing exactly when the URL has no // authority. An address literal keeps its brackets: [::1].

FunctionResultContract
std.url.parse(text: &str, max_bytes: NonNegative[i64])Result[Url, UrlError]Split and validate text, whose UTF-8 length must not exceed max_bytes
url.to_string()Result[str, UrlError]Validate every component and join them

parse checks the length first: LimitExceeded for a longer text. It then reports the first violation from left to right.

to_string exists so that a Url built or edited by a program cannot produce text that parses differently. It applies the rules of parse to each field, and also returns:

  • MissingHost when userinfo or port is present without host;
  • InvalidPort when port is outside 0 to 65535;
  • InvalidByte when host is present and a nonempty path does not start with /, or host is absent and path starts with //.

A successful to_string result always parses back to equal components.

Compare URLs only after deciding what equivalence means for your scheme. The module does not lowercase schemes or hosts and does not decode escapes, so HTTP://A/%7e and http://a/~ are different values.

Percent encoding

FunctionResultContract
encode(text: &str, component)Result[str, UrlError]Escape the UTF-8 bytes of text for component
encode_bytes(source: &[u8], component)Result[Bytes, UrlError]The same for a byte view
decode(text: &str)Result[str, UrlError]Replace each %hh; the result must be UTF-8
decode_bytes(source: &[u8])Result[Bytes, UrlError]Replace each %hh in a byte view; an InvalidEscape index is relative to the view

Ranges follow the std.bytes range rule.

Letters, digits, and -._~ are never escaped. % is always escaped, so encoding text that already contains escapes encodes it again. Escapes are written with uppercase hex digits. Each Component additionally keeps:

ComponentAlso keptUse for
Userinfo!$&'()*+,;=A user name or a password; : is escaped, so join them yourself
Segment!$&'()*+,;=:@One path segment; / is escaped
Path!$&'()*+,;=:@/A whole path whose / are separators
Query!$&'()*+,;=:@/?A whole query string
QueryPart!$'()*,;:@/?One key or one value; &, =, and + are escaped
Fragment!$&'()*+,;=:@/?A fragment

Userinfo output is also valid in a registered host name.

Decoding is the same for every component: %hh becomes one byte and every other byte is copied. + stays +; the application/x-www-form-urlencoded reading of + as a space is not implemented. decode returns InvalidUtf8 when the bytes are not UTF-8; use decode_bytes for binary data.

Decoded text can contain any character, including /, ?, NUL, and line breaks. Split a URL into components before decoding, and never parse decoded text as a URL or a path.

Query strings

pub class Pair:
    pub key: str
    pub value: str
FunctionResultContract
query_pairs(query: &str)Result[list[Pair], UrlError]Split at &, split each piece at its first =, decode both sides
query_text(pairs: &list[Pair])Result[str, UrlError]Join key=value with &, each side encoded as QueryPart

Every &-separated piece is one pair, in order, including empty pieces and repeated keys. A piece without = has an empty value, so a and a= read the same; query_text always writes the =. The empty query has no pairs. query_pairs accepts any byte that is not part of a malformed escape; pass it a Url.query to get the stricter component check.

Errors

pub enum UrlError:
    LimitExceeded
    MissingScheme
    MissingHost
    InvalidByte(i64)
    InvalidEscape(i64)
    InvalidHost(i64)
    InvalidPort(i64)
    InvalidUtf8
    Allocation(AllocError)
VariantMeaning
LimitExceededThe text is longer than max_bytes
MissingSchemeNo scheme and : before the first /, ?, or #
MissingHostto_string was given userinfo or a port without a host
InvalidByte(index)The byte at index is not allowed where it appears
InvalidEscape(index)The % at index is not followed by two hex digits
InvalidHost(index)The address literal starting at index is malformed, or the byte at index follows its ] and is not :
InvalidPort(index)The port starting at index is not an accepted number
InvalidUtf8Decoded bytes are not UTF-8
Allocation(cause)Storage could not be allocated

An index is a UTF-8 byte offset into the input. For to_string it is an offset into the text that would have been produced; for the byte-range functions it is a position in the source buffer, not relative to start. A failed operation releases anything it allocated. Wrap a result with std.error.context to name the operation.

Ownership and threads

Url and Pair own their strings, so they are resources: assignment moves them, and the ordinary loan rules apply to their fields. All inputs are shared loans. The module holds no global state; its functions pass the ordinary worker-effect checks and may run in any thread.

Costs

Every operation is linear in its input and allocates in proportion to it.

OperationAllocates
parseOne copy of the text, then one string per present component. A text of more than max_bytes UTF-8 bytes is rejected before the copy, so the copy never exceeds max_bytes
to_stringOne growing buffer, a temporary per component, and the result
encode_bytes, decode_bytesOne buffer of exactly the output size, after validation
encode, decodeA Bytes copy of the text, the coded buffer, then the str
query_pairsA copy of the query, the list, and two strings per pair
query_textOne growing buffer and two temporaries per pair

Encoded output is at most three times its input. The encoder counts literal bytes, then checks 3 * escaped + literals before allocating its result. Unrepresentable output sizes are Allocation(AllocError.CapacityOverflow). Parser, decoder and query cursors use documented bounds-proven arithmetic; terminal literal runs and final query pairs stop at the input endpoint instead of advancing past it. A serializer error offset that cannot fit i64 also returns allocation-capacity overflow. Caller arithmetic modes do not change these contracts.

Every byte goes through checked single-element Bytes calls, several times per byte. An earlier release-compiler measurement, before checked-default adoption, on x86-64 Linux pinned to one core found that parsing one 91-byte URL with all seven components took about 49 µs: roughly half a microsecond per input byte.

Limits

  • No relative references and no reference resolution (RFC 3986 section 5).
  • No normalization or equivalence: no case folding, dot-segment removal, escape normalization, or default-port knowledge.
  • No IDNA or Punycode conversion, and no scheme-specific rules such as http requiring a host.
  • No IPv6 zone identifiers. IPv4 addresses are not distinguished from registered names.
  • No form encoding (+ as a space) and no ; pair separator.
  • Query pairs are a list. There is no keyed lookup helper; scan the list.