URL Inspector — Rust source
Break any URL into its components - protocol, host, port, path, query params, hash, and credentials. Detects default ports and security at a glance, with a decode toggle for query values. Runs entirely in your browser.
This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.
// url-inspector — Rust polyglot showcase port.
//
// Parses a URL into a flat, serialisable report of every component — including
// the signals a parser usually hides (credentials, default-vs-explicit ports,
// root-only/fragment-only URLs).
//
// Display source — part of CosmoDev's polyglot tool pages.
// Ported from src/lib/url-inspector.ts (the canonical TypeScript lib).
//
// Unlike the JS/Go/Python/PHP ports, Rust's standard library has no URL
// parser, so this file implements a focused RFC 3986 parser sufficient for the
// inspector (scheme, userinfo, host, port, path, query, fragment). It is
// deliberately NOT a full WHATWG URL state machine: it matches the
// canonical lib on every input the live tool handles, and returns `None` for
// anything it cannot confidently split — which the inspector surfaces as the
// "Invalid URL" warning, exactly like a `new URL(...)` that throws.
/// Well-known default ports per scheme. Rust has no `url` crate here, so the
/// table is keyed with the trailing colon to match the WHATWG `protocol` form.
fn default_port_for(proto: &str) -> Option<&'static str> {
match proto {
"http:" => Some("80"),
"https:" => Some("443"),
"ftp:" => Some("21"),
"ws:" => Some("80"),
"wss:" => Some("443"),
_ => None,
}
}
/// Whether the scheme is a WHATWG "special" scheme. Special schemes get host
/// normalisation and an empty path serialised as "/".
fn is_special(scheme: &str) -> bool {
matches!(scheme, "http" | "https" | "ws" | "wss" | "ftp" | "file")
}
/// Whether the scheme yields a non-opaque origin. WHATWG returns the literal
/// "null" for everything else (file:, data:, mailto:…).
fn has_origin(scheme: &str) -> bool {
matches!(scheme, "http" | "https" | "ws" | "wss" | "ftp")
}
/// A single decoded query parameter (key/value pair).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UrlParam {
pub key: String,
pub value: String,
}
/// Structured inspection result. `Option` fields mirror the TypeScript
/// optionals (absent on the invalid early-return path).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UrlReport {
pub valid: bool,
pub protocol: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub host: Option<String>,
pub hostname: Option<String>,
pub port: Option<String>,
pub pathname: Option<String>,
pub search: Option<String>,
pub hash: Option<String>,
pub search_params: Vec<UrlParam>,
pub origin: Option<String>,
pub is_secure: Option<bool>,
pub default_port: Option<bool>,
pub warnings: Vec<String>,
}
/// Internal parse result. The parser keeps IPv6 brackets on `host` (matching
/// WHATWG `hostname`) and stores query/fragment without their leading sigils.
struct ParsedUrl {
scheme: String, // lowercased, no colon
username: String,
password: Option<String>,
host: String, // brackets retained for IPv6, no port
port: Option<String>, // parser-extracted port
path: String,
query: String,
fragment: String,
}
/// Parse an absolute URL into components. Returns `None` for anything that is
/// not `scheme:[//authority]path[?query][#fragment]`. Scheme must start with a
/// letter; for special schemes a host is mandatory (WHATWG invariant).
fn parse_url(input: &str) -> Option<ParsedUrl> {
let bytes = input.as_bytes();
if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
return None;
}
// Scheme = letter followed by a run of alnum / '+' / '-' / '.' (RFC 3986).
let mut i = 1;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_alphanumeric() || c == b'+' || c == b'-' || c == b'.' {
i += 1;
} else {
break;
}
}
if i >= bytes.len() || bytes[i] != b':' {
return None;
}
// Scheme chars are all ASCII, so slicing at `i` is safe on char boundaries.
let scheme = input[..i].to_ascii_lowercase();
let rest = &input[i + 1..]; // after the ':'
let mut parsed = ParsedUrl {
scheme: scheme.clone(),
username: String::new(),
password: None,
host: String::new(),
port: None,
path: String::new(),
query: String::new(),
fragment: String::new(),
};
let special = is_special(&scheme);
let mut tail = rest; // remainder after the authority
if let Some(after) = rest.strip_prefix("//") {
// Authority runs until the first path/query/fragment delimiter.
let end = after.find(|c| c == '/' || c == '?' || c == '#').unwrap_or(after.len());
let authority = &after[..end];
tail = &after[end..];
// Split off userinfo: everything up to the LAST '@' is credentials.
let hostport = match authority.rfind('@') {
Some(at) => {
let userinfo = &authority[..at];
let hostpart = &authority[at + 1..];
// userinfo = user[:pass]
match userinfo.find(':') {
Some(colon) => {
parsed.username = userinfo[..colon].to_string();
parsed.password = Some(userinfo[colon + 1..].to_string());
}
None => parsed.username = userinfo.to_string(),
}
hostpart
}
None => authority,
};
// Split host:port, IPv6-aware. For IPv6 the host keeps its brackets.
if hostport.starts_with('[') {
let close = hostport.find(']')?; // unterminated bracket -> None
parsed.host = hostport[..=close].to_string();
let after_bracket = &hostport[close + 1..];
if let Some(p) = after_bracket.strip_prefix(':') {
parsed.port = Some(p.to_string());
}
} else if let Some(colon) = hostport.find(':') {
parsed.host = hostport[..colon].to_string();
parsed.port = Some(hostport[colon + 1..].to_string());
} else {
parsed.host = hostport.to_string();
}
if parsed.host.is_empty() && special {
return None; // special schemes require a host
}
// WHATWG lowercases the ASCII host; do the same for the common case.
parsed.host.make_ascii_lowercase();
} else if special {
// Special schemes are always rewritten to an authority form by WHATWG;
// we decline the few malformed inputs that lack the "//" instead of
// silently mis-parsing them.
return None;
}
// `tail` is now path[?query][#fragment]. Peel fragment, then query, then path.
let (main, fragment) = match tail.find('#') {
Some(idx) => (&tail[..idx], Some(&tail[idx + 1..])),
None => (tail, None),
};
if let Some(f) = fragment {
parsed.fragment = f.to_string();
}
let (path, query) = match main.find('?') {
Some(idx) => (&main[..idx], Some(&main[idx + 1..])),
None => (main, None),
};
parsed.path = path.to_string();
if let Some(q) = query {
parsed.query = q.to_string();
}
Some(parsed)
}
/// Decode one hex nibble to its numeric value, or `None` if not hex.
fn hex_val(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
/// Percent-decode a byte slice. Returns `None` when the decoded bytes are not
/// valid UTF-8 (mirrors decodeURIComponent throwing on bad sequences — the
/// caller then falls back to the original string).
fn percent_decode(input: &[u8]) -> Option<String> {
let mut out = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
if input[i] == b'%' && i + 2 < input.len() {
if let (Some(hi), Some(lo)) = (hex_val(input[i + 1]), hex_val(input[i + 2])) {
out.push(hi * 16 + lo);
i += 3;
continue;
}
}
out.push(input[i]);
i += 1;
}
String::from_utf8(out).ok()
}
/// Percent-decode a query value, treating '+' as a space; never panics.
/// Falls back to the original string when the decoding is malformed.
pub fn decode_param(v: &str) -> String {
let replaced = v.replace('+', " ");
match percent_decode(replaced.as_bytes()) {
Some(decoded) => decoded,
None => v.to_string(),
}
}
/// Decode a raw query string into ordered key/value pairs, preserving
/// duplicates (a HashMap would lose both order and repeats).
fn parse_query(raw: &str) -> Vec<UrlParam> {
if raw.is_empty() {
return Vec::new();
}
raw.split('&')
.filter(|pair| !pair.is_empty())
.map(|pair| {
let (key, value) = match pair.find('=') {
Some(idx) => (&pair[..idx], &pair[idx + 1..]),
None => (pair, ""),
};
UrlParam {
key: decode_param(key),
value: decode_param(value),
}
})
.collect()
}
/// Strip `<scheme>://` and return the remainder (used to re-read the explicit
/// port). Returns `None` if the input has no valid `scheme://` prefix.
fn rest_after_scheme(s: &str) -> Option<&str> {
let bytes = s.as_bytes();
if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
return None;
}
let mut i = 1;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_alphanumeric() || c == b'+' || c == b'-' || c == b'.' {
i += 1;
} else {
break;
}
}
s.get(i..)?.strip_prefix("://")
}
/// Read an explicitly-written port straight from the raw input. The parser
/// above normalises default ports away, so we re-parse the authority to
/// recover them. Handles userinfo (`user:pass@`) and IPv6 literals
/// (`[::1]:8080`). Returns `None` when no numeric port is present.
fn raw_port(trimmed: &str) -> Option<String> {
let rest = rest_after_scheme(trimmed)?;
// The authority runs until the first path/query/fragment delimiter.
let end = rest.find(|c| c == '/' || c == '?' || c == '#').unwrap_or(rest.len());
let authority = &rest[..end];
// Drop userinfo: everything up to the LAST '@' belongs to credentials.
let hostport = match authority.rfind('@') {
Some(at) => &authority[at + 1..],
None => authority,
};
let port_candidate = if hostport.starts_with('[') {
// IPv6 literal — the port (if any) lives after the closing bracket.
let close = hostport.find(']')?;
hostport[close + 1..].strip_prefix(':')
} else {
hostport.find(':').map(|c| &hostport[c + 1..])
};
let candidate = port_candidate?;
if !candidate.is_empty() && candidate.bytes().all(|b| b.is_ascii_digit()) {
Some(candidate.to_string())
} else {
None
}
}
/// Parse and decompose a URL into a structured report; never panics.
pub fn inspect_url(raw: &str) -> UrlReport {
let mut warnings = Vec::new();
let trimmed = raw.trim();
if trimmed.is_empty() {
return UrlReport {
valid: false,
warnings: vec!["URL is empty".to_string()],
..Default::default()
};
}
let parsed = match parse_url(trimmed) {
Some(p) => p,
None => {
return UrlReport {
valid: false,
warnings: vec![
"Invalid URL — could not be parsed (include the scheme, e.g. https://)"
.to_string(),
],
..Default::default()
};
}
};
let scheme = parsed.scheme.clone();
let proto = format!("{}:", scheme);
let special = is_special(&scheme);
// Query parameters — decode in insertion order, duplicates preserved.
let search_params = parse_query(&parsed.query);
// Credentials.
if !parsed.username.is_empty() {
warnings.push("URL contains a username credential".to_string());
}
if parsed.password.as_deref().map_or(false, |p| !p.is_empty()) {
warnings.push("URL contains a password credential".to_string());
}
// Host (hostname:port) — WHATWG drops a port that equals the scheme default.
let mut host = parsed.host.clone();
if let Some(p) = &parsed.port {
let is_default = default_port_for(&proto).map_or(false, |d| d == p.as_str());
if !is_default {
host = format!("{}:{}", parsed.host, p);
}
}
// Pathname — special schemes serialise an empty path as "/".
let pathname = if parsed.path.is_empty() && special {
"/".to_string()
} else {
parsed.path.clone()
};
// Recover the explicit port and flag it if it's the scheme default.
let explicit_port = raw_port(trimmed);
let mut default_port_flag = None;
if let Some(ref ep) = explicit_port {
let is_default = default_port_for(&proto).map_or(false, |d| d == ep.as_str());
default_port_flag = Some(is_default);
if is_default {
warnings.push(format!("Port {} is the default for {}", ep, proto));
}
}
if pathname == "/" && parsed.query.is_empty() && search_params.is_empty() {
warnings.push("URL points to the site root (no path or query)".to_string());
}
let is_secure = scheme == "https" || scheme == "wss";
// Origin — only special schemes with a host yield a non-opaque origin.
let origin = if has_origin(&scheme) && !parsed.host.is_empty() {
Some(format!("{}://{}", scheme, host))
} else {
None
};
UrlReport {
valid: true,
protocol: Some(proto.clone()),
username: if parsed.username.is_empty() {
None
} else {
Some(parsed.username.clone())
},
password: parsed
.password
.clone()
.filter(|p| !p.is_empty()),
host: if host.is_empty() { None } else { Some(host.clone()) },
hostname: if parsed.host.is_empty() {
None
} else {
Some(parsed.host.clone())
},
port: explicit_port,
pathname: Some(pathname),
search: if parsed.query.is_empty() {
None
} else {
Some(format!("?{}", parsed.query))
},
hash: if parsed.fragment.is_empty() {
None
} else {
Some(format!("#{}", parsed.fragment))
},
search_params,
origin,
is_secure: Some(is_secure),
default_port: default_port_flag,
warnings,
}
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →