Skip to content

URL Inspector — C# 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 C# implementation — the same logic the interactive tool runs, in a shareable, citable form.

// url-inspector — break any URL into components (protocol, credentials, host, port, path, query params, fragment), detecting default ports and security at a glance. C# (.NET 7+) port of src/lib/url-inspector.ts — same logic as this dir's javascript.js; System.Uri fills Port with the scheme default even when nothing was written, so the explicitly-written port is recovered by re-parsing the authority (userinfo + IPv6 aware).

using System;
using System.Collections.Generic;

namespace UrlInspector;

/// A single decoded query parameter, in insertion order (duplicates preserved).
public record UrlParam(string Key, string Value);

/// Flat decomposition; null mirrors the TS undefined-when-absent fields.
public sealed class UrlReport {
    public bool Valid { get; init; }
    public List<string> Warnings { get; init; } = new();
    public string? Protocol { get; init; }
    public string? Username { get; init; }
    public string? Password { get; init; }
    public string? Host { get; init; }
    public string? Hostname { get; init; }
    public string? Port { get; init; }
    public string? Pathname { get; init; }
    public string? Search { get; init; }
    public string? Hash { get; init; }
    public List<UrlParam> SearchParams { get; init; } = new();
    public string? Origin { get; init; }
    public bool IsSecure { get; init; }
    public bool? DefaultPort { get; init; }

    public static UrlReport Invalid(string warning) =>
        new() { Valid = false, Warnings = { warning } };
}

public static class UrlInspector {
    private const string InvalidMsg =
        "Invalid URL - could not be parsed (include the scheme, e.g. https://)";

    /// Well-known default ports per scheme, keyed WHATWG-style with the ':'.
    private static readonly Dictionary<string, string> DefaultPorts = new() {
        ["http:"] = "80", ["https:"] = "443", ["ftp:"] = "21", ["ws:"] = "80", ["wss:"] = "443",
    };

    /// Schemes that yield a non-opaque origin (others serialise origin as "null").
    private static readonly HashSet<string> OriginSchemes = new() { "http", "https", "ws", "wss", "ftp" };

    /// Percent-decode a query value, '+' as a space. UnescapeDataString never
    /// throws and passes malformed escapes through — same outcome as the TS
    /// try/catch fallback.
    public static string DecodeParam(string v) => Uri.UnescapeDataString(v.Replace('+', ' '));

    /// Digits-only check for the raw port candidate.
    private static bool DigitsOnly(string s) {
        foreach (var c in s) if (!char.IsAsciiDigit(c)) return false;
        return s.Length > 0;
    }

    /// Read an explicitly-written port straight from the raw input: System.Uri
    /// normalises it away, so the authority segment is re-parsed by hand.
    /// Handles userinfo ("user:pass@") and IPv6 literals ("[::1]:8080").
    private static string? RawPort(string trimmed) {
        int sep = trimmed.IndexOf("://", StringComparison.Ordinal);
        if (sep < 1) return null;
        for (int i = 0; i < sep; i++) {          // scheme: letter, then alnum / '+' / '-' / '.'
            char c = trimmed[i];
            bool ok = i == 0 ? char.IsLetter(c)
                             : char.IsLetterOrDigit(c) || c == '+' || c == '-' || c == '.';
            if (!ok) return null;
        }
        string rest = trimmed[(sep + 3)..];
        int end = rest.AsSpan().IndexOfAny('/', '?', '#');
        string authority = end < 0 ? rest : rest[..end];

        // Drop userinfo: everything up to the LAST '@' belongs to credentials.
        int at = authority.LastIndexOf('@');
        string hostPort = at < 0 ? authority : authority[(at + 1)..];

        string? candidate = null;
        if (hostPort.StartsWith('[')) {          // IPv6 literal — port lives after ']'
            int close = hostPort.IndexOf(']');
            if (close < 0) return null;         // unterminated bracket
            string tail = hostPort[(close + 1)..];
            if (tail.StartsWith(':')) candidate = tail[1..];
        } else if (hostPort.IndexOf(':') is int colon && colon >= 0) {
            candidate = hostPort[(colon + 1)..];
        }
        return candidate != null && DigitsOnly(candidate) ? candidate : null;
    }

    /// Decode a raw query string into ordered pairs, preserving duplicates.
    private static List<UrlParam> ParseQuery(string? raw) {
        var list = new List<UrlParam>();
        if (string.IsNullOrEmpty(raw)) return list;
        foreach (var pair in raw.Split('&')) {
            if (pair.Length == 0) continue;      // "a=1&&b=2" yields an empty pair
            int eq = pair.IndexOf('=');
            list.Add(eq >= 0
                ? new UrlParam(DecodeParam(pair[..eq]), DecodeParam(pair[(eq + 1)..]))
                : new UrlParam(DecodeParam(pair), ""));
        }
        return list;
    }

    /// Parse and decompose a URL into a structured report; never throws.
    public static UrlReport InspectUrl(string? raw) {
        var trimmed = (raw ?? "").Trim();
        if (trimmed.Length == 0) return UrlReport.Invalid("URL is empty");

        // Absolute + host present mirrors WHATWG validity (relative input fails).
        if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) || uri.Host.Length == 0)
            return UrlReport.Invalid(InvalidMsg);

        var warnings = new List<string>();
        string scheme = uri.Scheme.ToLowerInvariant();
        string proto = scheme + ":";

        // Credentials — UserInfo is the raw "user[:pass]" segment.
        string? username = null, password = null;
        if (uri.UserInfo.Length > 0) {
            int colon = uri.UserInfo.IndexOf(':');
            username = colon >= 0 ? uri.UserInfo[..colon] : uri.UserInfo;
            password = colon >= 0 ? uri.UserInfo[(colon + 1)..] : null;
        }
        if (!string.IsNullOrEmpty(username)) warnings.Add("URL contains a username credential");
        if (!string.IsNullOrEmpty(password)) warnings.Add("URL contains a password credential");

        // uri.Host keeps '[' ']' around IPv6 literals, like WHATWG url.hostname.
        string hostname = uri.Host;
        string? port = RawPort(trimmed);
        string? expected = DefaultPorts.GetValueOrDefault(proto);
        bool? defaultPort = port != null ? port == expected : null;
        if (defaultPort == true) warnings.Add($"Port {port} is the default for {proto}");

        // host drops a scheme-default port (WHATWG serialisation).
        string host = port != null && port != expected ? $"{hostname}:{port}" : hostname;

        // Query / Fragment carry their leading '?' / '#' already.
        string pathname = uri.AbsolutePath.Length > 0 ? uri.AbsolutePath : "/";
        var parameters = ParseQuery(uri.Query.Length > 1 ? uri.Query[1..] : null);
        if (pathname == "/" && uri.Query.Length < 2 && parameters.Count == 0)
            warnings.Add("URL points to the site root (no path or query)");

        return new UrlReport {
            Valid = true, Protocol = proto,
            Username = username, Password = password,
            Host = host, Hostname = hostname, Port = port,
            Pathname = pathname,
            Search = uri.Query.Length > 1 ? uri.Query : null,
            Hash = uri.Fragment.Length > 0 ? uri.Fragment : null,
            SearchParams = parameters,
            Origin = OriginSchemes.Contains(scheme) ? $"{scheme}://{host}" : null,
            IsSecure = scheme is "https" or "wss",
            DefaultPort = defaultPort, Warnings = warnings,
        };
    }

    public static void Main() {
        var r = InspectUrl(
            "https://user:pass@example.com:8443/docs/api?q=hello+world&tags=a&tags=b&path=%2Fhome#section");
        Console.WriteLine($"protocol  {r.Protocol}  secure={r.IsSecure}");
        Console.WriteLine($"creds     {r.Username}:{r.Password}");
        Console.WriteLine($"host      {r.Host}  (port {r.Port}, default={r.DefaultPort})");
        Console.WriteLine($"path      {r.Pathname}  search {r.Search}  hash {r.Hash}");
        Console.WriteLine("params    " + string.Join(", ", r.SearchParams.Select(p => $"{p.Key}={p.Value}")));
        Console.WriteLine($"origin    {r.Origin}");
        Console.WriteLine("warnings  " + (r.Warnings.Count == 0 ? "(none)" : string.Join(" | ", r.Warnings)));

        var d = InspectUrl("http://example.com:80/");
        Console.WriteLine($"\nhttp://example.com:80/ -> {string.Join(" | ", d.Warnings)}");
        var e = InspectUrl("not a url");
        Console.WriteLine($"'not a url' -> valid={e.Valid} ({e.Warnings[0]})");
    }
}

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 →