A URI parser and formatter for Zig, modelled on Python's hyperlink package.
  • Zig 93.7%
  • JavaScript 3.4%
  • HTML 1.2%
  • Python 0.9%
  • Nix 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 086cdf369d
Some checks failed
test / reuse (push) Successful in 1m31s
test / test-unicode (push) Successful in 2m1s
test / web (push) Failing after 3m56s
test / test (push) Successful in 3m12s
test / docs (push) Has been skipped
Add a WebAssembly build and a JavaScript binding
The library now compiles to a freestanding wasm32 module with a flat
C-style ABI (src/wasm.zig) and ships an ES module (web/uri.js) that
wraps it in an immutable `Uri` class mirroring the Zig API.  Strings
cross the boundary as (ptr, len) pairs; components cross as JSON.

`zig build wasm` builds and installs the module and the web files,
`zig build web-serve` serves them, and `zig build test-web` runs the
shared suite in web/tests.js under node.  The same suite runs in a real
browser inside a NixOS test VM, exposed as the flake check `browser`,
which serves the built artifacts from nginx and drives headless
Chromium against them; the workflow runs both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaYEZhvA4unCg9SufZ8VAE
2026-09-07 01:11:20 -05:00
.forgejo/workflows Add a WebAssembly build and a JavaScript binding 2026-09-07 01:11:20 -05:00
LICENSES Add optional IDNA and IRI support 2026-08-27 17:07:35 -05:00
src Add a WebAssembly build and a JavaScript binding 2026-09-07 01:11:20 -05:00
tools Add a docs server, copied from z46 2026-08-27 23:04:44 -05:00
web Add a WebAssembly build and a JavaScript binding 2026-09-07 01:11:20 -05:00
.gitignore Add optional IDNA and IRI support 2026-08-27 17:07:35 -05:00
build.zig Add a WebAssembly build and a JavaScript binding 2026-09-07 01:11:20 -05:00
build.zig.zon Validate address literals with z46 2026-08-27 22:25:59 -05:00
flake.lock Add a WebAssembly build and a JavaScript binding 2026-09-07 01:11:20 -05:00
flake.nix Add a WebAssembly build and a JavaScript binding 2026-09-07 01:11:20 -05:00
README.md Add a WebAssembly build and a JavaScript binding 2026-09-07 01:11:20 -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.
  • Scheme-based normalization is opt-in. hyperlink's normalize() always gives an empty path a /; here that is .root_empty_path = true, alongside .drop_default_port, so the syntax-based rules RFC 3986 §6.2.2 gives stay separable from the scheme-based ones in §6.2.3.
  • A few inputs are accepted rather than refused. A control character in a component is escaped on the way out (#\n\n writes as #%0A%0A) instead of being a parse error, and an authority ending in a bare : has no port rather than being rejected, which is what RFC 3986 §3.2.3 allows and what browsers do.
  • 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.

hyperlink's own round-trip corpus and the inputs its tests use to pin down corner cases are in src/tests_hyperlink.zig, along with every place the two libraries write the same URI differently — so a deliberate divergence cannot quietly become an accidental one.

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, .root_empty_path

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 test --fuzz=1M          # ...and search for new failures
zig build docs                    # generate API docs into zig-out/docs
zig build docs-serve              # ...and read them at http://127.0.0.1:8000/
zig build wasm                    # build the browser bundle into zig-out/web
zig build web-serve               # ...and try it at http://127.0.0.1:8001/
zig build test-web                # test the JavaScript binding under node
nix build .#checks.x86_64-linux.browser   # ...and in a real browser

Fuzzing

src/fuzz.zig and src/fuzz_unicode.zig state the properties that have to hold for any input — parsing and writing are inverses, writing is a fixed point, normalization and reference resolution leave a reference that still round-trips, percent-encoding and Punycode round-trip exactly, and both directions of IDNA agree — and let std.testing.Smith generate the inputs. Smith is structure-aware: it is asked for a length, an index or a byte from a restricted alphabet rather than for a flat blob, so the search moves through the shape of a URI reference instead of hammering a string that stops parsing after its first byte. One of the targets builds Components by hand, which is how the writer gets fed the values parsing can never produce — a / inside a path segment, an @ inside a host.

zig build test compiles the fuzz tests and runs each against one smoke input, so they cost an ordinary test run nothing. zig build test --fuzz=N is what searches; a failure prints the input it was reached with and saves it to .zig-cache/f/crash.

Zig 0.16.0 needs two workarounds for this, both in flake.nix: its test runner does not compile in fuzz mode (it hands an error return trace to a function that wants a different kind of stack trace), so the devshell points ZIG_LIB_DIR at a copy of the library with that one line fixed. Coverage is separately broken — the table of program counters comes out empty — so --fuzz=N prints its findings and then fails on the coverage report, and plain --fuzz panics in the build runner rather than serving the web UI. The search itself works, and Smith's structure-aware generation is what makes it useful without coverage feedback to steer it.

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.

In a browser

The library also compiles to WebAssembly, with a JavaScript binding on top of it, so a page can parse and resolve URIs with the same code the Zig tests exercise:

zig build wasm -Dunicode=true     # -> zig-out/web/{uri.wasm,uri.js,index.html}
zig build web-serve               # a demo page at http://127.0.0.1:8001/

zig-out/web is the whole of what has to be served. uri.wasm is 84 KB without Unicode support and 462 KB with it, or 34 KB and 125 KB over the wire once a server has gzipped it — the difference is almost entirely the UTS #46 and normalization tables.

import { init, Uri } from "./uri.js";

await init();

const u = Uri.parse("https://user@example.com/a/b/?q=1&flag#top");
u.host;         // "example.com"
u.path;         // ["a", "b", ""] -- the empty segment is the trailing slash
u.first("q");   // { key: "q", value: "1" }
u.first("flag") // { key: "flag", value: null } -- a bare key, not an empty one

u.resolve("../c/").toString();  // "https://user@example.com/a/c/"
u.set("q", "2").toString();     // "https://user@example.com/a/b/?q=2&flag#top"
u.child("d").toString();        // "https://user@example.com/a/b/d?q=1&flag#top"

Uri.parse("HTTPS://Example.COM:443/a/./b/..")
  .normalize({ dropDefaultPort: true })
  .toString();                  // "https://example.com/a/"

// These two need a module built with -Dunicode=true.
Uri.parse("http://münchen.de/straße").toUri().toString();
  // "http://xn--mnchen-3ya.de/stra%C3%9Fe"
Uri.parse("http://xn--mnchen-3ya.de/stra%C3%9Fe").toIri().toString({ form: "iri" });
  // "http://münchen.de/straße"

init() fetches uri.wasm from beside uri.js; pass it a URL, a Response, or the bytes if they are already in hand. initSync(bytes) is the same thing without the await, which is how the node tests load it.

As in Zig, a Uri is immutable: with, child, sibling, resolve, normalize, add, set and remove each return a new one, and instances are frozen. Alongside the class there are text-in/text-out functions — resolve, normalize, toUri, toIri, idnaToAscii, idnaToUnicode, percentEncode, percentDecode — which never build a JavaScript string from a component and so are exactly as faithful as the Zig library.

That distinction matters in one place. A decoded component is a byte string and a JavaScript string is not, so a percent escape that is not valid UTF-8 — http://x/%FF, a stray Latin-1 query from a legacy site — arrives as U+FFFD in a Uri field and re-encodes as %EF%BF%BD. Reading components apart and putting them back together is lossy for such input; the text functions above carry it through unharmed.

The ABI itself is documented in src/wasm.zig: (ptr, len) pairs of UTF-8 in and a static { ok, ptr, len } record out, with option structs flattened to bitfields. It has no imports at all, so WebAssembly.instantiate(bytes, {}) is the whole of what instantiating it takes.

Testing it

web/tests.js is one suite run by two runners. zig build test-web runs it under node, which is fast enough to keep in the edit loop. nix flake check runs the same suite in a real browser: it boots a NixOS virtual machine, serves the bundle from nginx inside it, and drives headless Chromium against browser-test.html, then checks the demo page renders what the library computed. Only the second one exercises what a browser actually does — module resolution, WebAssembly.instantiateStreaming, and whether the server sends application/wasm — so both run in CI.

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.