urlparse-rs 0.1.0

A Rust port of Python's urllib.parse: URL splitting/joining and percent-encoding (quote/unquote).
Documentation
# urlparse-rs

A Rust port of Python's [`urllib.parse`](https://docs.python.org/3/library/urllib.parse.html) —
URL component splitting/joining, query-string handling, and percent-encoding. Behaviour is
verified against the reference `urllib.parse` module itself across thousands of inputs.

Covers `urlsplit`/`urlunsplit`, `urlparse`/`urlunparse`, `urljoin`, `urldefrag`,
`parse_qs`/`parse_qsl`, `urlencode`, and the `quote`/`unquote` family, plus the
`SplitResult`/`ParseResult` netloc accessors (`username`/`password`/`hostname`/`port`) and
`urlsplit`'s authority validation — bracketed IPv6/IPvFuture hosts and the NFKC homograph check on
non-ASCII authorities. The deprecated `split*`/`unwrap`/bytes-result helpers are intentionally
omitted.

## Usage

```rust
use urlparse_rs::{quote, unquote, urljoin, urlsplit};

// Percent-encode / decode (Python's quote defaults safe to "/").
assert_eq!(quote("/El Niño/", "/"), "/El%20Ni%C3%B1o/");
assert_eq!(unquote("/El%20Ni%C3%B1o/"), "/El Niño/");

// Split a URL and resolve a relative reference.
let s = urlsplit("http://host:8080/p?q#f", "", true).unwrap();
assert_eq!(s.hostname().as_deref(), Some("host"));
assert_eq!(s.port(), Ok(Some(8080)));
assert_eq!(urljoin("http://a/b/c/d", "../g", true).unwrap(), "http://a/b/g");
```

## Installation

```sh
cargo add urlparse-rs
```

Requires a Rust toolchain with 2024-edition support (Rust 1.85 or newer).

## How it works

Percent-encoding operates on bytes, exactly like `urllib.parse`. A `str` is encoded as UTF-8;
each byte that is neither "always safe" (`A-Z a-z 0-9 _ . - ~`, the RFC 3986 unreserved set) nor
in the caller's `safe` set becomes `%XX` with uppercase hex. Decoding reverses this: only maximal
runs of ASCII characters are `%`-decoded and UTF-8 decoded as a unit, while non-ASCII characters
pass through unchanged — matching the reference `unquote`.

URL parsing mirrors `urlsplit`'s structure, including its authority checks: a bracketed host must
be a valid IPv6 or IPvFuture literal, and a non-ASCII authority is rejected when NFKC normalization
would introduce a delimiter (the homograph guard `urllib.parse` performs via `unicodedata`). That
NFKC step is the crate's one dependency, [`unicode-normalization`](https://crates.io/crates/unicode-normalization),
because the standard library has no Unicode normalization.

## Performance

This is a compiled port, so for **cold parsing** — each URL seen for the first time — it runs
several times faster than CPython's `urllib.parse`. But `urllib.parse` wraps `urlsplit` in an
`lru_cache`, so for **repeated** identical URLs CPython returns a cached result faster than this
crate re-parses from scratch (the crate keeps no cache of its own). Both effects are real; which
one dominates depends entirely on your access pattern.

Indicative figures on one machine, over a corpus of real-world URLs, in nanoseconds per operation
(lower is better):

| Operation   | urlparse-rs | `urllib.parse` (cold) | `urllib.parse` (cached) |
|-------------|------------:|----------------------:|------------------------:|
| `quote`     |        ~180 |                ~2,140 |          — (not cached) |
| `unquote`   |        ~820 |                ~4,890 |                       — |
| `urlsplit`  |        ~620 |                ~3,740 |                    ~230 |
| `urlencode` |      ~2,570 |               ~14,800 |                       — |
| `parse_qsl` |      ~1,190 |                ~4,230 |                       — |
| `urljoin`   |      ~2,200 |                ~7,400 |                       — |

So roughly 3–12× faster on cold parses, but on a hot loop re-parsing the same URL CPython's cache
wins (~230 ns vs ~620 ns for `urlsplit`); a caller with that pattern can wrap this crate in its own
memoization. These are release-build microbenchmarks on a single machine with ~10–20% run-to-run
variance — treat them as ballpark, not a guarantee.

## License

Licensed under the [MIT License](LICENSE-MIT).