Skip to content

URL Inspector — Swift 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 Swift 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. Language: Swift (5.9, Foundation only). Port of src/lib/url-inspector.ts — same logic as this dir's javascript.js; URLComponents never injects default ports, so an explicit :443 stays visible — exactly the signal the report needs.

import Foundation

/// A single decoded query parameter, in insertion order (duplicates preserved).
struct UrlParam: CustomStringConvertible {
    let key: String, value: String
    var description: String { "\(key)=\(value)" }
}

/// Flat decomposition; nil mirrors the TS undefined-when-absent fields.
struct UrlReport {
    var valid = false
    var warnings: [String] = []
    var `protocol`: String?
    var username: String?
    var password: String?
    var host: String?
    var hostname: String?
    var port: String?
    var pathname: String?
    var search: String?
    var hash: String?
    var searchParams: [UrlParam] = []
    var origin: String?
    var isSecure = false
    var defaultPort: Bool?

    static func invalid(_ warning: String) -> UrlReport {
        var r = UrlReport()
        r.warnings = [warning]
        return r
    }
}

/// Well-known default ports per scheme, keyed WHATWG-style with the ':'.
let defaultPorts = ["http:": "80", "https:": "443", "ftp:": "21", "ws:": "80", "wss:": "443"]

/// Schemes that yield a non-opaque origin (others serialise origin as "null").
let originSchemes: Set<String> = ["http", "https", "ws", "wss", "ftp"]

let invalidMsg = "Invalid URL - could not be parsed (include the scheme, e.g. https://)"

/// Percent-decode a query value, treating '+' as a space. removingPercentEncoding
/// returns nil on a malformed escape — fall back to the original, like the TS.
func decodeParam(_ v: String) -> String {
    v.replacingOccurrences(of: "+", with: " ").removingPercentEncoding ?? v
}

/// Decode a raw query string into ordered pairs, preserving duplicates.
func parseQuery(_ raw: String?) -> [UrlParam] {
    guard let raw, !raw.isEmpty else { return [] }
    return raw.split(separator: "&", omittingEmptySubsequences: true).map { pair in
        if let eq = pair.firstIndex(of: "=") {
            return UrlParam(key: decodeParam(String(pair[..<eq])),
                            value: decodeParam(String(pair[pair.index(after: eq)...])))
        }
        return UrlParam(key: decodeParam(String(pair)), value: "")
    }
}

/// Parse and decompose a URL into a structured report; never throws.
func inspectUrl(_ raw: String?) -> UrlReport {
    let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmed.isEmpty else { return .invalid("URL is empty") }

    // WHATWG validity: an absolute scheme AND a host are both required
    // (URLComponents accepts scheme-less input with a nil scheme).
    guard let parts = URLComponents(string: trimmed),
          let rawScheme = parts.scheme?.lowercased(),
          let rawHost = parts.host, !rawHost.isEmpty else {
        return .invalid(invalidMsg)
    }

    var r = UrlReport()
    r.valid = true
    r.protocol = rawScheme + ":"

    // Credentials — the percentEncoded* variants keep the input verbatim.
    if let user = parts.percentEncodedUser, !user.isEmpty {
        r.username = user
        r.warnings.append("URL contains a username credential")
    }
    if let pass = parts.percentEncodedPassword, !pass.isEmpty {
        r.password = pass
        r.warnings.append("URL contains a password credential")
    }

    // Foundation strips IPv6 brackets — WHATWG hostname keeps them.
    let hostname = rawHost.contains(":") ? "[\(rawHost)]" : rawHost
    r.hostname = hostname

    // Explicit port (nil when unwritten — defaults are NOT injected); flag it
    // when it equals the scheme default.
    let port = parts.port.map(String.init)
    let expected = defaultPorts[r.protocol!]
    let defaultPort = port != nil ? port == expected : nil
    r.port = port
    r.defaultPort = defaultPort
    if defaultPort == true {
        r.warnings.append("Port \(port!) is the default for \(r.protocol!)")
    }

    // host drops a scheme-default port (WHATWG serialisation).
    r.host = port != nil && port != expected ? "\(hostname):\(port!)" : hostname

    // percentEncodedPath keeps escapes like WHATWG url.pathname; an empty
    // hierarchical path normalises to "/".
    let pathname = parts.percentEncodedPath.isEmpty ? "/" : parts.percentEncodedPath
    r.pathname = pathname
    r.search = (parts.percentEncodedQuery?.isEmpty ?? true) ? nil : "?" + parts.percentEncodedQuery!
    r.hash = (parts.percentEncodedFragment?.isEmpty ?? true) ? nil : "#" + parts.percentEncodedFragment!

    // Query params, decoded in insertion order with duplicates preserved.
    r.searchParams = parseQuery(parts.percentEncodedQuery)

    if pathname == "/" && (r.search == nil || r.search == "?") && r.searchParams.isEmpty {
        r.warnings.append("URL points to the site root (no path or query)")
    }

    r.origin = originSchemes.contains(rawScheme) ? "\(rawScheme)://\(r.host!)" : nil
    r.isSecure = rawScheme == "https" || rawScheme == "wss"
    return r
}

let r = inspectUrl("https://user:pass@example.com:8443/docs/api?q=hello+world&tags=a&tags=b&path=%2Fhome#section")
print("protocol  \(r.protocol!)  secure=\(r.isSecure)")
print("creds     \(r.username ?? "-"):\(r.password ?? "-")")
print("host      \(r.host!)  (port \(r.port ?? "-"), default=\(r.defaultPort.map(String.init) ?? "nil"))")
print("path      \(r.pathname!)  search \(r.search ?? "-")  hash \(r.hash ?? "-")")
print("params    \(r.searchParams.map(\.description).joined(separator: ", "))")
print("origin    \(r.origin ?? "nil")")
print("warnings  \(r.warnings.isEmpty ? "(none)" : r.warnings.joined(separator: " | "))")

let d = inspectUrl("http://example.com:80/")
print("\nhttp://example.com:80/ -> \(d.warnings.joined(separator: " | "))")
let e = inspectUrl("not a url")
print("'not a url' -> valid=\(e.valid) (\(e.warnings.first ?? ""))")

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 →