Skip to content

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 →