A URI parser and formatter for Zig, modelled on Python's hyperlink package.
  • Zig 98.9%
  • Python 1%
  • Nix 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 9882c56a36
Add a docs server, copied from z46
`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
2026-08-27 23:04:44 -05:00
.forgejo/workflows Publish the API docs to Codeberg Pages, and allow manual runs 2026-08-27 20:39:08 -05:00
LICENSES Add optional IDNA and IRI support 2026-08-27 17:07:35 -05:00
src Link the repository from the module documentation 2026-08-27 22:30:14 -05:00
tools Add a docs server, copied from z46 2026-08-27 23:04:44 -05:00
.gitignore Add optional IDNA and IRI support 2026-08-27 17:07:35 -05:00
build.zig Add a docs server, copied from z46 2026-08-27 23:04:44 -05:00
build.zig.zon Validate address literals with z46 2026-08-27 22:25:59 -05:00
flake.lock Implement URI library with a hyperlink-style API 2026-08-27 15:24:50 -05:00
flake.nix Implement URI library with a hyperlink-style API 2026-08-27 15:24:50 -05:00
README.md Add a docs server, copied from z46 2026-08-27 23:04:44 -05:00
REUSE.toml Record the package download location for REUSE 2026-08-27 15:42:24 -05:00

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.

  • port is what was written. hyperlink's .port falls back to the scheme's default; here port stays null unless a port was present, so http://x and http://x:80 round-trip distinctly. Use effectivePort() for the fallback behaviour.
  • rooted and path instead of a leading empty segment. /a/b is rooted = true with path = .{ "a", "b" }.
  • An empty fragment is preserved. hyperlink cannot tell http://x/# from http://x/; here fragment is "" versus null.
  • click follows RFC 3986 §5.2.2 strictly. hyperlink raises NotImplementedError for a reference with a scheme and a rootless path; click("g:h") here returns g: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. hyperlink emits it for any scheme that could have one, turning http:/foo into http:///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. nfs is 2049 rather than the portmapper's 111, and rtspu is 554 rather than 5005. Both are wrong in hyperlink; see Default ports below for the citations.
  • set appends when the key is absent, rather than inserting before the last parameter.
  • Address literals are validated. hyperlink accepts 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. hyperlink always has them; here they need -Dunicode=true, which keeps the default build dependency-free. toUri and toIri correspond to to_uri() and to_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/wsshttp 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.