- Zig 98.9%
- Python 1%
- Nix 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
`zig build docs-serve` builds the documentation and serves it on http://127.0.0.1:8000/, with -Ddocs-port=N for another port. A server is needed rather than just opening index.html: the generated viewer fetches sources.tar and main.wasm at runtime, and a browser refuses those from a file:// page. This is the same reason `zig std` runs one. tools/docs_server.zig is copied verbatim from z46; only the build wiring is adapted to this project's module names. Its mimeType test is attached to the test step, so `zig build test` covers it too. Exercised rather than only compiled: index.html, main.wasm and sources.tar all come back with the right content types, a missing file gives 404, and traversal attempts sent with curl --path-as-is give 400. Without --path-as-is curl normalises the path away and the guard is never reached, which makes that an easy thing to think you have tested when you have not. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015jUN4Y7pS8z55xj1jArsUa |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
zig-uri
A URI parser and formatter for Zig, modelled on Python's
hyperlink package.
Requires Zig 0.16. Address literals are validated with z46; IDNA and IRI support are optional and behind a build flag.
API documentation — generated from the source and published on
every green build of main.
const uri = @import("uri");
const u = try uri.Uri.parse(alloc, "https://example.com/docs/intro.html?lang=en#top");
defer u.deinit();
u.scheme.? // "https"
u.host.?.hostname // "example.com"
u.rooted // true
u.path // .{ "docs", "intro.html" }
u.first("lang").? // .{ .key = "lang", .value = "en" }
u.effectivePort() // 443
const link = try u.click(alloc, "../api/v1/");
defer link.deinit();
// https://example.com/api/v1/
Components are stored decoded
Every string on a Uri is already percent-decoded, so you can use it directly:
const u = try uri.Uri.parse(alloc, "http://x/a%2Fb?q=1%262");
u.path[0] // "a/b" — one segment, not two
u.first("q").?.value.? // "1&2"
Writing re-applies the encoding, escaping each component with the largest
character set that cannot change how the result re-parses. Output is therefore
minimally and canonically escaped rather than byte-identical to the input:
%2f comes back as %2F, and %41 comes back as A. Parsing the output
always yields exactly the same components, and writing it again is a fixed
point. The test suite enforces both on every table case and on 20,000
randomized delimiter-heavy inputs.
Decoded values are byte strings, not validated UTF-8 — %FF is legal in a URI
and carries no encoding guarantee.
By default a % that is not followed by two hex digits is kept as a literal
%, which is what browsers do with the malformed URLs that occur in practice.
Uri.parseOpts(alloc, text, .{ .strict_percent = true }) rejects it instead.
Ownership
A Uri owns an arena holding all of its strings, so every accessor is
allocation-free and no field needs freeing individually. A Uri is immutable:
replace, child, sibling, click, normalize, add, set and remove
each return a new Uri with its own arena, independent of the receiver.
const a = try uri.Uri.parse(alloc, "http://example.com/a");
const b = try a.replace(alloc, .{ .scheme = "https" });
a.deinit(); // b is still valid
defer b.deinit();
Replace fields are doubly optional: omitting one keeps the current value,
.port = 8080 sets it, and .port = @as(?u16, null) clears it.
API
Parsing and building
Uri.parse(alloc, text) |
parse a URI reference |
Uri.parseOpts(alloc, text, opts) |
as above, with .strict_percent |
Uri.init(alloc, components) |
build from loose Components |
Uri.clone(alloc) / deinit() |
copy / free |
parts() |
the components as borrowed plain data |
Inspection
scheme, username, password, host, port, rooted, path, query,
fragment are plain fields. Plus isAbsolute(), usesNetloc(),
defaultPort(), effectivePort(), eql(), hash().
Text
write(w, opts), toText(alloc, opts), authority(alloc, opts), and {f}
formatting. The password is withheld unless .with_password = true, so a stray
log line cannot leak a credential.
Derivation
replace, child, sibling, click, clickUri, normalize.
Query
get, first, last, has, add, set, remove, removeWhere.
Unicode (with -Dunicode=true)
Uri.toUri, Uri.toIri, and TextOptions.form. The idna, normalization
and punycode submodules. unicode_enabled reports whether the flag was set.
Free functions
defaultPort, usesNetloc, percentEncode, percentDecode, plus the
percent, parse, format, normalize, query, schemes, components and
punycode submodules.
Differences from hyperlink
portis what was written.hyperlink's.portfalls back to the scheme's default; hereportstaysnullunless a port was present, sohttp://xandhttp://x:80round-trip distinctly. UseeffectivePort()for the fallback behaviour.rootedandpathinstead of a leading empty segment./a/bisrooted = truewithpath = .{ "a", "b" }.- An empty fragment is preserved.
hyperlinkcannot tellhttp://x/#fromhttp://x/; herefragmentis""versusnull. clickfollows RFC 3986 §5.2.2 strictly.hyperlinkraisesNotImplementedErrorfor a reference with a scheme and a rootless path;click("g:h")here returnsg:h, as the RFC specifies. All 41 examples in RFC 3986 §5.4 are in the test suite.//is written only for a real authority.hyperlinkemits it for any scheme that could have one, turninghttp:/foointohttp:///foo— which re-parses with an empty host rather than no host. Ask for an empty authority explicitly with.host = .{ .hostname = "" }.- Two default ports are corrected.
nfsis 2049 rather than the portmapper's 111, andrtspuis 554 rather than 5005. Both are wrong inhyperlink; see Default ports below for the citations. setappends when the key is absent, rather than inserting before the last parameter.- Address literals are validated.
hyperlinkaccepts whatever sits between the brackets; here an IPv6 literal has to parse, and an IPv4 one has to be four dec-octets. See Address literals below. - IDNA and IRI are opt-in.
hyperlinkalways has them; here they need-Dunicode=true, which keeps the default build dependency-free.toUriandtoIricorrespond toto_uri()andto_iri(). See IDNA and IRI below.
Address literals
Hosts that look like IP addresses are checked with z46, which is a required dependency — unlike the Unicode support, address literals are core RFC 3986 §3.2.2 syntax rather than an add-on.
The literal is validated, never rewritten. [1080:0:0:0:8:800:200C:417A]
round-trips exactly as written rather than being canonicalised to
[1080::8:800:200c:417a], which keeps the guarantee in Components are stored
decoded intact. The consequence is that two spellings of one address are
still different Uri values: [::1] does not equal [0:0:0:0:0:0:0:1].
What changes is that malformed literals are now rejected rather than carried through:
try uri.Uri.parse(alloc, "http://[::gg]/"); // error.InvalidIPv6Literal
try uri.Uri.parse(alloc, "http://[1:2:3]/"); // error.InvalidIPv6Literal
An RFC 6874 zone identifier is accepted in its percent-encoded form,
[fe80::1%25eth0], and rejected when the % is left bare. RFC 3986's
IPvFuture form ([v7.something]) is accepted on shape alone, since it has no
defined interpretation to check against.
For IPv4, RFC 3986 tries IPv4address first and falls back to reg-name, so
anything that is not exactly four dec-octets is simply a host name:
u.host.?.ipv4 // "192.0.2.16"
u.host.?.hostname // "192.168.01.1" -- a leading zero is not a dec-octet
That last case is the reason to be strict. A parser reading 192.168.01.1 as
octal and one reading it as decimal disagree about which host it names.
IDNA and IRI
Building with -Dunicode=true adds internationalized domain names and IRIs.
The core parser is unchanged and dependency-free without it; the flag pulls in
uucode for Unicode character data.
zig build test -Dunicode=true
const u = try uri.Uri.parse(alloc, "https://Bücher.example/straße?q=café#ü");
const as_uri = try u.toUri(alloc); // host to its xn-- form
try as_uri.toText(alloc, .{});
// https://xn--bcher-kva.example/stra%C3%9Fe?q=caf%C3%A9#%C3%BC
const as_iri = try as_uri.toIri(alloc); // host back to Unicode
try as_iri.toText(alloc, .{ .form = .iri });
// https://bücher.example/straße?q=café#ü
toUri and toIri convert only the host, because that is the only component
whose two forms differ by more than escaping. Everything else is stored decoded,
so TextOptions.form decides it: .uri percent-encodes non-ASCII (RFC 3986),
.iri writes the characters RFC 3987 permits literally.
idna.toAscii and idna.toUnicode are also available directly, along with
punycode (RFC 3492) and normalization.nfc.
What it implements
UTS #46 — the profile browsers implement — with non-transitional
processing by default, so faß.de encodes to xn--fa-hia.de rather than
folding to fass.de. Options exposes transitional, use_std3_ascii_rules,
check_hyphens, check_bidi, check_joiners and verify_dns_length.
Only the UTS #46 mapping table is generated into this repository
(src/idna_table.zig, from IdnaMappingTable.txt); it is derived data of the
UTS with no equivalent in the UCD. Every other Unicode property — general
category, script, joining type, bidi class, combining class, canonical
decompositions and composition exclusions — comes from uucode.
NFC is implemented here rather than taken from a table, on uucode's data. Its inverse composition map is derived on first use by inverting uucode's decompositions, so it can never disagree with the Unicode version uucode was built against.
punycode needs no Unicode data at all and is compiled in either way.
Conformance
Verified against Unicode's own suites:
| Suite | Result |
|---|---|
IdnaTestV2.txt (UTS #46) |
12,774 checks, 0 failures |
NormalizationTest.txt (NFC) |
100,170 checks, 0 failures |
| RFC 3492 §7.1 Punycode vectors | all pass |
Those suites total about 3.5 MB, too much to ship to every consumer, so a
deterministic sample is committed as src/unicode_vectors.zig and runs on
every build: every 13th case that expects an error, plus a spaced sample of
those expected to succeed — 455 IDNA and 364 NFC cases. To re-run the full
suites, download them into testdata/ and regenerate with the scripts in
tools/, which name the exact source URLs.
Two version notes. uucode is built against Unicode 17.0.0, while the newest
published IdnaMappingTable.txt is 16.0.0 — Unicode has not released IDNA data
for 17 yet, so the mapping table trails the character properties by one
version. And lone surrogates cannot be carried through a Zig []const u8, so
those rows of IdnaTestV2.txt are skipped rather than mis-tested; such input
is rejected as invalid UTF-8 regardless.
Specifications
The library implements RFC 3986, Uniform Resource Identifier (URI): Generic Syntax. Where the code cites a section, this is what it means:
| Section | Where | |
|---|---|---|
| §2.1 | Percent-Encoding | percent.encode, percent.decode |
| §2.2 | Reserved Characters | the sub_delims set in percent |
| §2.3 | Unreserved Characters | percent.unreserved_punct |
| §3.1 | Scheme | scheme parsing; case-insensitive lookup in schemes |
| §3.2.1 | User Information | username / password |
| §3.2.2 | Host | Host.ipv4, .ipv6, .hostname |
| §3.2.3 | Port | a bare : is an absent port, not a zero one |
| §3.3 | Path | rooted + path; segment-nz-nc; the no-authority // rule |
| §4.2 | Relative Reference | isAbsolute |
| §5.2.2 | Transform References | click, strictly |
| §5.2.3 | Merge Paths | merging a relative path against the base |
| §5.2.4 | Remove Dot Segments | normalize.resolveDotSegments |
| §5.4 | Reference Resolution Examples | all 41 are in the test suite |
| §6.2.2 | Syntax-Based Normalization | Uri.normalize |
| §6.2.2.1 | Case Normalization | lowercased scheme/host, uppercase %XX |
| §6.2.3 | Scheme-Based Normalization | .drop_default_port |
RFC 3986 obsoletes RFC 2396 and RFC 1738; neither is implemented here, and the older RFC 1738 percent-encoding rules in particular should not be assumed.
Related, and deliberately not implemented — see Differences from
hyperlink above:
| RFC 3987 | Internationalized Resource Identifiers (IRIs). There is no to_uri/to_iri pair. |
| RFC 5890, RFC 5891 | IDNA2008. A non-ASCII host is percent-encoded as UTF-8, not Punycode. |
| RFC 9844 | IPv6 zone identifiers ([fe80::1%25eth0]), obsoleting RFC 6874. The literal is passed through verbatim. |
| RFC 5952 | IPv6 text representation. Address literals are never rewritten, so case and :: placement survive. |
The scheme tables in schemes.zig follow RFC 7595 (BCP 35) and the
IANA registries. Schemes appearing in the tests and in the rootless-scheme
list are defined by RFC 6068 (mailto), RFC 8089 (file) and
RFC 8141 (urn).
Default ports
All 40 entries in defaultPort were checked against IANA's Service Name and
Transport Protocol Port Number Registry and, for schemes whose
defining document states a default, against that RFC. Where the two disagree
the RFC wins: a default port belongs to the URI scheme, and a scheme need not
share a name — or a protocol — with the IANA service sitting on its port.
Twenty-three entries match an IANA service of the same name outright. The rest are worth recording:
| Scheme | Port | |
|---|---|---|
nfs |
2049 | Corrected. Was 111, which is sunrpc, the portmapper rather than NFS. RFC 2224 §3 specifies 2049, matching IANA. |
rtspu |
554 | Corrected. Was 5005, which IANA assigns to avt-profile-2. RFC 2326 §3.2 gives rtsp and rtspu one default of 554; they differ in transport, TCP versus UDP, not in port. |
sftp |
22 | Correct. SSH File Transfer runs over SSH. IANA's sftp is 115, an unrelated and long-obsolete Simple File Transfer Protocol. |
prospero |
1525 | Correct. RFC 4157 says "the port defaults to 1525". IANA's prospero is 191; 1525 is its prospero-np. |
ventrilo |
3784 | Convention, not an assignment — IANA gives 3784 to bfd-control (RFC 5881). Kept because it is what clients use. |
afp dns ircs mms pop smb vnc wais ws wss |
— | Correct, but filed by IANA under another service name: afpovertcp, domain, ircs-u, ms-streaming, pop3, microsoft-ds, rfb, z39.50, and — for ws/wss — http and https (RFC 6455 §3). |
The two corrections are also wrong in Python's hyperlink and in
boltons.urlutils, which is where this table was originally adapted from.
defaultPort reports what a scheme means by an absent port. It is not a claim
that a host is listening there, and Uri.port still holds only what was
actually written — see effectivePort().
Repository
The repository is published on Radicle and mirrored to Codeberg. Either will do; the Radicle copy needs no account and no forge.
# Radicle
rad clone rad:zrcnvxgQxKm2DSmX3Q9VSF2dHJ8m
# Codeberg
git clone https://codeberg.org/jcollie/zig-uri.git
rad clone finds seeds through your local node's routing table, so the node
has to be running first:
rad node start
If you already have the repository and only want to follow it, seeding it makes your node help host it for others:
rad seed rad:zrcnvxgQxKm2DSmX3Q9VSF2dHJ8m
The Radicle repository is public, its default branch is main, and it is
named zig-uri — the same name and default branch as the Codeberg mirror, so
either remote gives the same history.
Building
zig build test # run the test suite
zig build test -Dunicode=true # ...including IDNA and IRI
zig build docs # generate API docs into zig-out/docs
zig build docs-serve # ...and read them at http://127.0.0.1:8000/
docs-serve exists because the generated viewer fetches sources.tar and
main.wasm at runtime, which a browser refuses to do from a file:// page —
the same reason zig std runs a server. Use -Ddocs-port=N for a different
port.
The published copy at jcollie.codeberg.page/zig-uri is the same output,
built with -Dunicode=true so it covers idna, normalization and
punycode as well as the core parser. CI republishes it whenever main goes
green.
Using it as a dependency
The repository lives at
codeberg.org/jcollie/zig-uri, and on
Radicle as rad:zrcnvxgQxKm2DSmX3Q9VSF2dHJ8m (see Repository above).
zig fetch needs an HTTP or git URL, so the Codeberg mirror is what the
commands below use.
Pin a specific commit — zig fetch wants a full 40-character SHA or a ref
name, and rejects an abbreviated hash:
zig fetch --save git+https://codeberg.org/jcollie/zig-uri.git#<full-commit-sha>
Or name a branch, or take a tarball:
zig fetch --save git+https://codeberg.org/jcollie/zig-uri.git#main
zig fetch --save https://codeberg.org/jcollie/zig-uri/archive/main.tar.gz
Naming a branch does not leave the dependency floating: zig fetch resolves
the ref at fetch time and records it as ?ref=main#<sha>, so the pin is still
to one commit.
Either way --save writes the entry and its integrity hash into your
build.zig.zon, under the name uri:
.dependencies = .{
.uri = .{
.url = "git+https://codeberg.org/jcollie/zig-uri.git#04ded71f8d88fded46b3eaf1e8e02f44c8c47b7e",
.hash = "uri-0.1.0-yCrwNFS4AQCXVzzGneu7zkEf9kopQC3n8KbzJpyDnFnP",
},
},
Then wire the module into whatever needs it, in build.zig:
const uri_dep = b.dependency("uri", .{});
exe_module.addImport("uri", uri_dep.module("uri"));
To get IDNA and IRI support, ask for it through the dependency — that is what pulls in uucode:
const uri_dep = b.dependency("uri", .{ .unicode = true });
const uri = @import("uri");
Licence
MIT. See LICENSES/MIT.txt.