diff --git a/build.gradle.kts b/build.gradle.kts index 0000481..db75bd6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,6 +49,16 @@ val generators: List> = "conformance", "Regenerate the IDNA conformance fixture from the WPT IdnaTestV2 and toascii corpora.", ), + Triple( + "generateSetterTestData", + "setters", + "Regenerate the WPT setters_tests conformance fixture from the vendored ada WPT corpus.", + ), + Triple( + "generatePercentEncodingTestData", + "percent-encoding", + "Regenerate the WPT percent-encoding conformance fixture from the vendored ada WPT corpus.", + ), ) val codegenTasks: List> = diff --git a/kuri/api/android/kuri.api b/kuri/api/android/kuri.api index 96cfcbe..fa8e1ff 100644 --- a/kuri/api/android/kuri.api +++ b/kuri/api/android/kuri.api @@ -160,7 +160,16 @@ public final class org/dexpace/kuri/Url { public final fun username ()Ljava/lang/String; public final fun validationErrors ()Ljava/util/List; public final fun withFragment (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withHash (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withHost (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withHostname (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withPassword (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withPathname (Ljava/lang/String;)Lorg/dexpace/kuri/Url; public final fun withPort (Ljava/lang/Integer;)Lorg/dexpace/kuri/Url; + public final fun withPort (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withProtocol (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withSearch (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withUsername (Ljava/lang/String;)Lorg/dexpace/kuri/Url; public final fun withoutFragment ()Lorg/dexpace/kuri/Url; } diff --git a/kuri/api/jvm/kuri.api b/kuri/api/jvm/kuri.api index 635b492..cfc9571 100644 --- a/kuri/api/jvm/kuri.api +++ b/kuri/api/jvm/kuri.api @@ -169,7 +169,16 @@ public final class org/dexpace/kuri/Url { public final fun username ()Ljava/lang/String; public final fun validationErrors ()Ljava/util/List; public final fun withFragment (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withHash (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withHost (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withHostname (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withPassword (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withPathname (Ljava/lang/String;)Lorg/dexpace/kuri/Url; public final fun withPort (Ljava/lang/Integer;)Lorg/dexpace/kuri/Url; + public final fun withPort (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withProtocol (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withSearch (Ljava/lang/String;)Lorg/dexpace/kuri/Url; + public final fun withUsername (Ljava/lang/String;)Lorg/dexpace/kuri/Url; public final fun withoutFragment ()Lorg/dexpace/kuri/Url; } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index f851832..b8b591b 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt @@ -11,6 +11,7 @@ import org.dexpace.kuri.error.map import org.dexpace.kuri.host.Host import org.dexpace.kuri.parser.BuilderPath import org.dexpace.kuri.parser.ParsedComponents +import org.dexpace.kuri.parser.StateOverride import org.dexpace.kuri.parser.UrlParser import org.dexpace.kuri.parser.UrlPath import org.dexpace.kuri.parser.decodedSegments @@ -219,7 +220,11 @@ public class Url internal constructor( private val decodedPathSegments: List by lazy { decodedSegments(components.path) { PercentCodec.decode(it) } } - private val encodedPathText: String by lazy { serializeUrlPath(components) } + + // guardAgainstAuthority = false: this is the standalone `pathname` getter, never concatenated + // onto a bare `scheme:` the way the full `href` is, so the NORM-18 `/.` anti-authority guard + // (needed only to keep href re-parseable) does not belong in its output (SPEC §11.2). + private val encodedPathText: String by lazy { serializeUrlPath(components, guardAgainstAuthority = false) } private val queryParameterSnapshot: QueryParameters by lazy { QueryParameters.parseOrEmpty(components.query) } /** @@ -382,6 +387,73 @@ public class Url internal constructor( return newBuilder().port(port).build() } + /** + * The WHATWG `port` setter (URL §5): returns a copy whose port is parsed from the leading + * digits of [value] (trailing non-digits are ignored, per the port state), or with the port + * removed when [value] is empty. A no-op when the URL cannot have a port (no host, empty host, + * or `file` scheme). Never throws. + * + * @param value the new port as text; `""` removes the port. + * @return the updated [Url], or `this` when the setter is a WHATWG no-op. + */ + public fun withPort(value: String): Url { + // No pre-check on value[0]: the WHATWG pre-processing strips tab/newline from `value` + // before the port state ever sees it (e.g. "\t8080" is a valid port once stripped), and an + // invalid leading character is itself a no-op the port state machine already resolves. + return when { + !canHaveCredentials() -> this + value.isEmpty() -> Url(components.copy(port = null)) + else -> applyOverride(value, StateOverride.PORT) + } + } + + /** + * The WHATWG `pathname` setter (URL §5): returns a copy whose path is parsed from [value] + * (the existing path is discarded first), or `this` when the URL has an opaque path. Never + * throws. + * + * @param value the new path text. + * @return the updated [Url], or `this` when the setter is a WHATWG no-op. + */ + public fun withPathname(value: String): Url { + if (components.path is UrlPath.Opaque) return this + return applyOverride(value, StateOverride.PATHNAME) + } + + /** + * The WHATWG `search` setter (URL §5): returns a copy whose query is [value] with a single + * leading `?` stripped and percent-encoded with the (special-)query set, or with the query + * removed when [value] is empty. Never throws. + * + * @param value the new query text, with or without a leading `?`; `""` removes the query. + * @return the updated [Url]. + */ + public fun withSearch(value: String): Url { + if (value.isEmpty()) return Url(components.copy(query = null)) + val stripped = if (value.startsWith('?')) value.substring(1) else value + return applyOverride(stripped, StateOverride.QUERY) + } + + /** + * The WHATWG `hash` setter (URL §5): returns a copy whose fragment is [value] with a single + * leading `#` stripped and percent-encoded with the fragment set, or with the fragment + * removed when [value] is empty. Never throws. + * + * @param value the new fragment text, with or without a leading `#`; `""` removes the fragment. + * @return the updated [Url]. + */ + public fun withHash(value: String): Url { + if (value.isEmpty()) return Url(components.copy(fragment = null)) + val withoutHash = if (value.startsWith('#')) value.substring(1) else value + // WHATWG's hash setter basic-URL-parses this input with fragment state as the override, + // which unconditionally strips every ASCII tab/LF/CR first (SPEC §8.1); withHash bypasses + // the shared engine (there is no FRAGMENT StateOverride), so it must replicate that step + // itself rather than percent-encode a literal tab/newline into the fragment. + val stripped = withoutHash.filterNot { it == '\t' || it == '\n' || it == '\r' } + val encoded = PercentCodec.encode(stripped, PercentEncodeSets.FRAGMENT) + return Url(components.copy(fragment = encoded)) + } + /** * Returns a copy of this URL with its [fragment] set (or removed when `null`). * @@ -403,6 +475,90 @@ public class Url internal constructor( */ public fun withoutFragment(): Url = withFragment(null) + /** + * The WHATWG `protocol` setter (URL §5): returns a copy with [value]'s scheme, or this URL + * unchanged when the change is not permitted (special↔non-special, or an invalid `file` + * transition). Never throws — an invalid scheme is a no-op. + * + * @param value the new scheme, with or without a trailing `:`. + * @return the updated [Url], or `this` when the setter is a WHATWG no-op. + */ + public fun withProtocol(value: String): Url { + // No pre-validation here: WHATWG appends `:` to the raw value and basic-URL-parses it with + // scheme-start state as the override, so the state machine itself (which strips tab/newline + // first) decides validity and reports an invalid scheme as a no-op (SCHEME_START/SCHEME + // failing under an override; see UrlParserStates). Pre-checking the unstripped value here + // previously rejected e.g. "h\r\ntt\tps" even though it strips to the valid scheme "https". + return applyOverride("$value:", StateOverride.PROTOCOL) + } + + /** + * The WHATWG `username` setter (URL §5): returns a copy whose userinfo user is [value] + * percent-encoded with the userinfo set, or `this` when the URL cannot have credentials + * (no host, empty host, or a `file` scheme). Never throws. + * + * @param value the decoded username to set. + * @return the updated [Url], or `this` when the setter is a WHATWG no-op. + */ + public fun withUsername(value: String): Url { + if (!canHaveCredentials()) return this + val encoded = PercentCodec.encode(value, PercentEncodeSets.USERINFO) + return Url(components.copy(username = encoded)) + } + + /** + * The WHATWG `password` setter (URL §5): as [withUsername], for the password half. + * + * @param value the decoded password to set. + * @return the updated [Url], or `this` when the setter is a WHATWG no-op. + */ + public fun withPassword(value: String): Url { + if (!canHaveCredentials()) return this + val encoded = PercentCodec.encode(value, PercentEncodeSets.USERINFO) + return Url(components.copy(password = encoded)) + } + + /** + * The WHATWG `host` setter (URL §5): returns a copy with host and (optional) port parsed + * from [value], or `this` when the URL has an opaque path or [value] is not a valid host. + * Never throws. + * + * @param value the new host text, optionally followed by `:` and a port. + * @return the updated [Url], or `this` when the setter is a WHATWG no-op. + */ + public fun withHost(value: String): Url { + if (components.path is UrlPath.Opaque) return this + return applyOverride(value, StateOverride.HOST) + } + + /** + * The WHATWG `hostname` setter (URL §5): as [withHost] but a `:` and anything after it are + * ignored, so the existing port is preserved. Never throws. + * + * @param value the new host text; any `:` and trailing text are ignored. + * @return the updated [Url], or `this` when the setter is a WHATWG no-op. + */ + public fun withHostname(value: String): Url { + if (components.path is UrlPath.Opaque) return this + return applyOverride(value, StateOverride.HOSTNAME) + } + + /** WHATWG "cannot have a username/password/port": no host, empty host, or `file` scheme. */ + private fun canHaveCredentials(): Boolean { + val h = components.host + return h != null && h != Host.Empty && !scheme.equals(FILE_SCHEME, ignoreCase = true) + } + + /** Runs a setter [override] over [value], returning `this` on any error or WHATWG no-op. */ + private fun applyOverride( + value: String, + override: StateOverride, + ): Url = + when (val result = UrlParser.parseWithOverride(value, components, override)) { + is ParseResult.Ok -> if (result.value == components) this else Url(result.value) + is ParseResult.Err -> this + } + /** The canonical [href]; a parsed `Url` round-trips through `toString` then [parse]. */ override fun toString(): String = href @@ -528,7 +684,12 @@ public class Url internal constructor( encodedPassword = source.password host = source.hostName port = source.port - path = BuilderPath.verbatim(source.encodedPath) + // Pre-fill from the guarded full-URL path serialization, not the `encodedPath` getter: + // recompose concatenates the path directly onto `scheme:` with no authority, so an + // authority-less `//`-leading path must keep the NORM-18 `/.` guard or the rebuilt string + // re-parses with a spurious authority (the getter drops the guard per SPEC §11.2, which is + // correct for a standalone pathname but wrong as recompose input). + path = BuilderPath.verbatim(serializeUrlPath(source.components)) queryState = QueryState.Raw(source.query) encodedFragment = source.fragment } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/StateOverride.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/StateOverride.kt new file mode 100644 index 0000000..a6900a2 --- /dev/null +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/StateOverride.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.parser + +/** + * The WHATWG "state override" entry points used by the `Url` setter algorithms (WHATWG URL §5 + * setters; each maps to the basic URL parser state the setter re-enters). Unlike a full parse, + * an override run seeds the work area from an existing URL and processes only its one component. + */ +internal enum class StateOverride { + /** `protocol` setter — scheme-start entry; commits scheme + default-port elision only. */ + PROTOCOL, + + /** `host` setter — host entry; parses host and (on `:`) port. */ + HOST, + + /** `hostname` setter — host entry that stops at `:` (a port is never parsed). */ + HOSTNAME, + + /** `port` setter — port entry over the digit run. */ + PORT, + + /** `pathname` setter — path-start entry after the path list is cleared. */ + PATHNAME, + + /** `search` setter — query entry over the (leading-`?`-stripped) value. */ + QUERY, +} diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParser.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParser.kt index 607322c..9bd4090 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParser.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParser.kt @@ -55,6 +55,105 @@ internal object UrlParser { return if (failure != null) ParseResult.Err(failure) else ParseResult.Ok(finalize(state)) } + /** + * Runs the state machine with a setter [override] over [value], seeded from [seed] (WHATWG + * URL §5 "basic URL parser with … state override"). Unlike [parse], the leading/trailing + * C0-or-space trim and the fragment prune are skipped (the value is a single component); only + * tab/newline removal applies. Returns [ParseResult.Err] on a fatal component error and — via + * the [UrlTransition.Abort] path — signals a WHATWG no-op by returning the seed unchanged. + */ + internal fun parseWithOverride( + value: String, + seed: ParsedComponents, + override: StateOverride, + ): ParseResult { + val errors = mutableListOf() + val stripped = stripTabAndNewline(value, errors) + val state = UrlParserState(stripped, seed, override, errors) + // WHATWG pathname setter step "empty this's URL's path" before re-entering path-start + // (WHATWG URL §5); the seeded segments must not survive into the re-parse. + if (override == StateOverride.PATHNAME) state.path.clear() + // WHATWG search setter step "set this's URL's query to the empty string" (URL §5); the + // QUERY state handler appends to the seeded query, so it must be reset here or a + // withSearch call would concatenate onto the old query instead of replacing it. + if (override == StateOverride.QUERY) state.query = "" + // Map each override to the UrlState the basic URL parser re-enters (WHATWG URL §5 setters). + state.state = + when (override) { + StateOverride.PROTOCOL -> UrlState.SCHEME_START + StateOverride.HOST, StateOverride.HOSTNAME -> UrlState.HOST + StateOverride.PORT -> UrlState.PORT + StateOverride.PATHNAME -> UrlState.PATH_START + StateOverride.QUERY -> UrlState.QUERY + } + return when (val outcome = runOverride(state)) { + OverrideOutcome.ABORT -> ParseResult.Ok(seed) + OverrideOutcome.OK -> ParseResult.Ok(finalize(state).copy(fragment = seed.fragment)) + is OverrideOutcome.Fail -> ParseResult.Err(outcome.error) + } + } + + /** The three terminal outcomes of an override run. */ + private sealed interface OverrideOutcome { + data object OK : OverrideOutcome + + data object ABORT : OverrideOutcome + + data class Fail( + val error: UriParseError, + ) : OverrideOutcome + } + + /** + * Drives the override loop. Terminates on [UrlTransition.Done]/[UrlTransition.Abort], on a + * fatal [UrlTransition.Fail], or when an [UrlTransition.Advance]/[UrlTransition.Reconsume] + * reaches EOF — the natural end of a single-component value. A fixed iteration bound guards any + * reconsume chain. + */ + private fun runOverride(state: UrlParserState): OverrideOutcome { + val maxIterations = (state.input.length + 1).toLong() * MAX_REENTRIES_PER_CODE_POINT + var iterations = 0L + var outcome: OverrideOutcome? = null + while (outcome == null) { + check(iterations++ < maxIterations) { "override state machine exceeded its iteration bound" } + outcome = stepOverride(state, STATE_HANDLERS.getValue(state.state)(state)) + } + return outcome + } + + /** + * Applies one [transition] under an override run, returning the terminal [OverrideOutcome] or + * `null` to continue the loop. Mirrors [runStateMachine]'s handling exactly: [Reconsume] never + * inspects [UrlParserState.pos] itself — it only changes state and lets the loop re-invoke the + * new state's handler, even at EOF, since that handler is what finalizes a component (e.g. PATH + * appending the root segment, or FILE_HOST clearing an empty host). Only [Advance] terminates on + * EOF (after the *current* state has already run once, having just consumed the final code + * point), the natural end of a single-component value. + */ + private fun stepOverride( + state: UrlParserState, + transition: UrlTransition, + ): OverrideOutcome? = + when (transition) { + is UrlTransition.Done -> OverrideOutcome.OK + is UrlTransition.Abort -> OverrideOutcome.ABORT + is UrlTransition.Fail -> OverrideOutcome.Fail(transition.error) + is UrlTransition.Reconsume -> { + state.state = transition.next + null + } + is UrlTransition.Advance -> { + state.state = transition.next + when { + state.pos >= state.input.length -> OverrideOutcome.OK + else -> { + state.pos += 1 + null + } + } + } + } + /** The dispatch table mapping each [UrlState] to its single state function (§8.3). */ private val STATE_HANDLERS: Map UrlTransition> = mapOf( @@ -103,6 +202,8 @@ internal object UrlParser { if (state.pos == state.input.length) return null state.pos += 1 } + is UrlTransition.Done, is UrlTransition.Abort -> + error("Done/Abort are override-only transitions") } } } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt index 0647241..17bb2f7 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt @@ -134,6 +134,9 @@ internal object UrlParserAuthority { * host is fatal ([PARSE-31]). */ internal fun hostState(state: UrlParserState): UrlTransition { + if (state.stateOverride != null && state.scheme == FILE_SCHEME) { + return UrlTransition.Reconsume(UrlState.FILE_HOST) + } val scan = scanHost(state) val hostSlice = state.input.substring(state.pos, scan.first) return finishHost(state, hostSlice, scan.first, scan.second) @@ -174,10 +177,26 @@ internal object UrlParserAuthority { ): UrlTransition = when { isColon && hostSlice.isEmpty() -> UrlTransition.Fail(UriParseError.EmptyHost) + // WHATWG host state, `:` branch: "If state override is given and state override is + // hostname state, then return" -- BEFORE the buffer is ever parsed as a host, so a `:` + // in a `hostname` setter value is a no-op (the port-bearing suffix is for `host` only). + isColon && state.stateOverride == StateOverride.HOSTNAME -> UrlTransition.Done !isColon && state.special && hostSlice.isEmpty() -> UrlTransition.Fail(UriParseError.EmptyHost) + // WHATWG host state, delimiter branch: "if state override is given, buffer is the empty + // string, and either url includes credentials or url's port is non-null, then return" -- + // clearing a non-special host is refused when doing so would strand existing + // userinfo/port with no host to attach to. + !isColon && hostSlice.isEmpty() && state.stateOverride != null && hasCredentialsOrPort(state) -> { + state.pos = idx + UrlTransition.Done + } else -> parseAndStoreHost(state, hostSlice, idx, isColon) } + /** True when [state] has a non-empty username/password or an explicit port ([PARSE-30] guard). */ + private fun hasCredentialsOrPort(state: UrlParserState): Boolean = + state.username.isNotEmpty() || state.password.isNotEmpty() || state.port != null + /** Parses [hostSlice] via [HostParser] and advances to PORT (on `:`) or PATH_START. */ private fun parseAndStoreHost( state: UrlParserState, @@ -190,10 +209,21 @@ internal object UrlParserAuthority { is ParseResult.Ok -> { state.host = host.value state.pos = idx - if (isColon) UrlTransition.Advance(UrlState.PORT) else UrlTransition.Reconsume(UrlState.PATH_START) + hostTransition(state, isColon) } } + /** Post-host transition, honouring a HOST/HOSTNAME override ([SET-*]; WHATWG host state). */ + private fun hostTransition( + state: UrlParserState, + isColon: Boolean, + ): UrlTransition = + when (state.stateOverride) { + StateOverride.HOSTNAME -> UrlTransition.Done + StateOverride.HOST -> if (isColon) UrlTransition.Advance(UrlState.PORT) else UrlTransition.Done + else -> if (isColon) UrlTransition.Advance(UrlState.PORT) else UrlTransition.Reconsume(UrlState.PATH_START) + } + /** An empty non-special host is [Host.Empty]; otherwise the §7 host pipeline classifies it. */ private fun resolveHost( state: UrlParserState, @@ -235,7 +265,13 @@ internal object UrlParserAuthority { end: Int, terminator: Char?, ): UrlTransition { - val terminated = terminator == null || isAuthorityTerminator(state, terminator) + // Per the WHATWG port state, ANY state override ends the digit run on any trailing + // character -- not just an authority terminator, and not only for the `port` setter itself + // -- since there is no following state to reconsume into ([PARSE-34]; e.g. the `port` + // setter's "8080stuff" keeps port 8080, and the `host` setter's "h:8080stuff" must stop + // the same way once it falls through into port sub-parsing). + val terminated = + terminator == null || isAuthorityTerminator(state, terminator) || state.stateOverride != null if (!terminated) { return UrlTransition.Fail(UriParseError.InvalidPort(digits + terminator)) } @@ -248,13 +284,24 @@ internal object UrlParserAuthority { digits: String, end: Int, ): UrlTransition { - val value = computePort(digits) - if (digits.isNotEmpty() && value == null) { - return UrlTransition.Fail(UriParseError.InvalidPort(digits)) + val overridden = state.stateOverride != null + if (digits.isNotEmpty()) { + val value = computePort(digits) + if (value == null) { + // Outside an override, an out-of-range port is fatal (WHATWG "port-out-of-range, + // return failure"). Inside one, the setter algorithm mutates the live URL field by + // field as it goes, so a downstream failure here does not roll back a host/hostname + // that already committed earlier in the same run -- the port is simply left as-is. + if (!overridden) return UrlTransition.Fail(UriParseError.InvalidPort(digits)) + } else { + state.port = elidedPort(state, value) + } } - state.port = elidedPort(state, value) + // WHATWG port state: "if buffer is not the empty string" gates the assignment above, so an + // empty digit run (state override, delimiter reached with nothing scanned) leaves the port + // untouched rather than clearing it to null. state.pos = end - return UrlTransition.Reconsume(UrlState.PATH_START) + return if (overridden) UrlTransition.Done else UrlTransition.Reconsume(UrlState.PATH_START) } /** The base-10 value of [digits], or `null` when it exceeds the 16-bit ceiling ([PARSE-33]). */ @@ -416,15 +463,26 @@ internal object UrlParserAuthority { end: Int, ): UrlTransition = when { - isWindowsDriveLetter(buffer) -> UrlTransition.Reconsume(UrlState.PATH) + // WHATWG file-host state 1.1: the drive-letter reinterpretation applies only outside a + // state override; a `host`/`hostname` setter value that happens to look like a drive + // letter (e.g. "C:") is instead parsed as an ordinary host in the `else` branch below. + state.stateOverride == null && isWindowsDriveLetter(buffer) -> UrlTransition.Reconsume(UrlState.PATH) buffer.isEmpty() -> { state.host = Host.Empty state.pos = end - UrlTransition.Reconsume(UrlState.PATH_START) + fileHostTransition(state) } else -> parseFileHost(state, buffer, end) } + /** + * Post-file-host transition ([PARSE-37]; WHATWG "if state override is given, then return"): a + * `host`/`hostname` setter stops the moment the host is set, never touching the seeded path, + * while a full parse continues into path start state to build the path from scratch. + */ + private fun fileHostTransition(state: UrlParserState): UrlTransition = + if (state.stateOverride != null) UrlTransition.Done else UrlTransition.Reconsume(UrlState.PATH_START) + /** Parses a non-empty file host and maps `localhost` to the empty host ([PARSE-37]). */ private fun parseFileHost( state: UrlParserState, @@ -444,7 +502,7 @@ internal object UrlParserAuthority { is ParseResult.Ok -> { state.host = if (host.value == Host.RegName(LOCALHOST)) Host.Empty else host.value state.pos = end - UrlTransition.Reconsume(UrlState.PATH_START) + fileHostTransition(state) } } } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserHelpers.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserHelpers.kt index c6bc3b6..e0c9636 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserHelpers.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserHelpers.kt @@ -39,7 +39,10 @@ internal fun isAuthorityTerminator( /** * True when [ch] (or EOF, `null`) ends the current path segment (SPEC §8.3 PATH - * [PARSE-39]): EOF, `/`, `?`, or — for special schemes — `\` ([PARSE-49]). + * [PARSE-39]): EOF, `/`, or — for special schemes — `\` ([PARSE-49]); `?` joins that list only + * when no state override is active. A `pathname` setter value is parsed in isolation (WHATWG + * "state override is not given and c is U+003F (?)"), so under an override a literal `?` is + * ordinary path text to percent-encode, not a query-opening boundary. */ internal fun isPathSegmentEnd( state: UrlParserState, @@ -48,7 +51,8 @@ internal fun isPathSegmentEnd( if (ch == null) { return true } - return ch == '/' || ch == '?' || (state.special && ch == '\\') + val queryOpens = ch == '?' && state.stateOverride == null + return ch == '/' || queryOpens || (state.special && ch == '\\') } /** diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserState.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserState.kt index 08efdca..475861e 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserState.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserState.kt @@ -6,6 +6,7 @@ package org.dexpace.kuri.parser import org.dexpace.kuri.error.ValidationError import org.dexpace.kuri.host.Host +import org.dexpace.kuri.scheme.Scheme /** * The single mutable work area threaded through the §8.3 state machine (WHATWG basic URL parser @@ -37,6 +38,9 @@ internal class UrlParserState( /** The current state; the loop dispatches on this each iteration. */ var state: UrlState = UrlState.SCHEME_START + /** The active setter override, or `null` for a full parse; gates the override-only branches. */ + var stateOverride: StateOverride? = null + /** Scratch accumulator for the scheme and per-segment buffers (WHATWG `buffer`). */ val buffer: StringBuilder = StringBuilder() @@ -73,6 +77,34 @@ internal class UrlParserState( /** WHATWG `atSignSeen`: whether a userinfo `@` was consumed in the authority (§8.4). */ var atSignSeen: Boolean = false + /** + * Seeds the work area from an existing [seed] for a setter [override] run (WHATWG "set url to + * this's URL" then run the parser with a state override). The [value] is the component input; + * [base] is always `null` and the fragment is carried by the setter (not re-parsed here). + */ + internal constructor( + value: String, + seed: ParsedComponents, + override: StateOverride, + errors: MutableList, + ) : this(value, base = null, fragmentRaw = null, errors = errors) { + scheme = seed.scheme + special = seed.scheme?.let { Scheme.isSpecial(it) } ?: false + username = seed.username + password = seed.password + host = seed.host + port = seed.port + when (val p = seed.path) { + is UrlPath.Opaque -> { + isOpaque = true + opaque = p.path + } + is UrlPath.Segments -> path.addAll(p.segments) + } + query = seed.query + stateOverride = override + } + /** The current code point as a [Char], or `null` at EOF (`pos == length`, [PARSE-10]). */ fun currentChar(): Char? = if (pos < input.length) input[pos] else null diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt index 596f5f8..017f842 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt @@ -6,6 +6,7 @@ package org.dexpace.kuri.parser import org.dexpace.kuri.error.UriParseError import org.dexpace.kuri.error.ValidationError +import org.dexpace.kuri.host.Host import org.dexpace.kuri.percent.PercentCodec import org.dexpace.kuri.percent.PercentEncodeSets import org.dexpace.kuri.scheme.Scheme @@ -31,7 +32,10 @@ internal object UrlParserStates { * SCHEME_START (§8.3 [PARSE-13]; WHATWG scheme-start state). * * An ASCII-alpha first code point seeds the scheme buffer (lowercased) and enters SCHEME; - * anything else backs up into NO_SCHEME without consuming. + * anything else backs up into NO_SCHEME without consuming. Under a setter override (the + * `protocol` setter), that fallback is instead a hard failure — there is no base to resolve a + * "no scheme" reference against, so the WHATWG "otherwise, return failure" branch applies, and + * [UrlParser.parseWithOverride] turns the failure into the setter's documented no-op. */ internal fun schemeStartState(state: UrlParserState): UrlTransition { val c = state.currentChar() @@ -39,7 +43,11 @@ internal object UrlParserStates { state.buffer.append(c.asciiLowercased()) return UrlTransition.Advance(UrlState.SCHEME) } - return UrlTransition.Reconsume(UrlState.NO_SCHEME) + return if (state.stateOverride != null) { + UrlTransition.Fail(UriParseError.InvalidScheme(state.pos, "scheme must start with an ASCII alpha")) + } else { + UrlTransition.Reconsume(UrlState.NO_SCHEME) + } } /** @@ -47,7 +55,9 @@ internal object UrlParserStates { * * Accumulates valid scheme code points (lowercased); a `:` finalizes the scheme, any other * code point restarts the scan as NO_SCHEME from the first code point ([PARSE-16], the one - * sanctioned pointer reset). + * sanctioned pointer reset) — except under a setter override, where WHATWG has no "start over" + * option (there is no base to fall back to) and instead fails outright, letting + * [UrlParser.parseWithOverride] surface it as the setter's no-op. */ internal fun schemeState(state: UrlParserState): UrlTransition { val c = state.currentChar() @@ -57,6 +67,8 @@ internal object UrlParserStates { UrlTransition.Advance(UrlState.SCHEME) } c == ':' -> schemeColon(state) + state.stateOverride != null -> + UrlTransition.Fail(UriParseError.InvalidScheme(state.pos, "invalid scheme code point under override")) else -> schemeRestart(state) } } @@ -75,12 +87,39 @@ internal object UrlParserStates { private fun schemeColon(state: UrlParserState): UrlTransition { val scheme = state.buffer.toString() check(Scheme.isValidScheme(scheme)) { "scheme buffer must already be valid: $scheme" } + if (state.stateOverride == StateOverride.PROTOCOL) { + return protocolOverride(state, scheme) + } state.scheme = scheme state.special = Scheme.isSpecial(scheme) state.buffer.setLength(0) return dispatchAfterScheme(state, scheme) } + /** + * WHATWG `protocol` setter guards (URL §5): refuse a special↔non-special change, refuse a + * `file` scheme when credentials/port/host would be invalid, then commit the scheme and + * default-port-elide. Any refusal is a no-op ([UrlTransition.Abort]). + */ + private fun protocolOverride( + state: UrlParserState, + scheme: String, + ): UrlTransition { + val newSpecial = Scheme.isSpecial(scheme) + val oldSpecial = state.scheme?.let { Scheme.isSpecial(it) } ?: false + val hasCredentials = state.username.isNotEmpty() || state.password.isNotEmpty() + val hostEmptyOrAbsent = state.host == null || state.host == Host.Empty + val refuse = + newSpecial != oldSpecial || + (scheme == FILE_SCHEME && (hasCredentials || state.port != null)) || + (state.scheme == FILE_SCHEME && hostEmptyOrAbsent) + if (refuse) return UrlTransition.Abort + state.scheme = scheme + state.special = newSpecial + if (state.port != null && Scheme.defaultPort(scheme) == state.port) state.port = null + return UrlTransition.Done + } + /** The post-`:` dispatch of [PARSE-15]: file / special-relative / special / path-or-authority / opaque. */ private fun dispatchAfterScheme( state: UrlParserState, @@ -345,19 +384,27 @@ internal object UrlParserStates { } /** - * Non-special PATH_START: `?` opens the query; EOF terminates with an empty path (PATH never - * runs, so no root segment is synthesized); a leading `/` is consumed, anything else - * reconsumed, in PATH. + * Non-special PATH_START: `?` opens the query (only outside a state override — a `pathname` + * setter value keeps a literal `?` as path text, WHATWG "state override is not given and c is + * U+003F"); EOF under an override with no host appends the root segment directly (WHATWG + * path-start-state step 5) since PATH never otherwise runs to synthesize one, while a full, + * non-override parse leaves the path empty (no root is synthesized for a hostful non-special + * URL with no path, e.g. `sc://host`); a leading `/` is consumed, anything else reconsumed, in + * PATH. */ private fun pathStartNonSpecial( state: UrlParserState, c: Char?, ): UrlTransition = when { - c == '?' -> { + c == '?' && state.stateOverride == null -> { state.query = "" UrlTransition.Advance(UrlState.QUERY) } + c == null && state.stateOverride != null && state.host == null -> { + state.path.add("") + UrlTransition.Done + } c == null -> UrlTransition.Advance(UrlState.PATH) c == '/' -> UrlTransition.Advance(UrlState.PATH) else -> UrlTransition.Reconsume(UrlState.PATH) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlTransition.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlTransition.kt index b1cb24f..1aa3a86 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlTransition.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlTransition.kt @@ -31,4 +31,18 @@ internal sealed interface UrlTransition { data class Fail( val error: UriParseError, ) : UrlTransition + + /** + * A state-override run finished successfully at the boundary of its component ([SET-*]; + * WHATWG "if state override is given … return"). The loop commits the seeded state and + * terminates without consuming further input. + */ + data object Done : UrlTransition + + /** + * A state-override run hit a WHATWG no-op guard (e.g. special↔non-special scheme change, + * a port on a hostless URL). The seeded state is discarded and the setter returns its + * receiver unchanged; never an error, never a throw. + */ + data object Abort : UrlTransition } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/Serializer.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/Serializer.kt index c67148c..cd4e0d8 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/Serializer.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/Serializer.kt @@ -146,25 +146,39 @@ private fun credentialsPrefix(components: ParsedComponents): String { * The single home shared by the `Serializer` object and `Url.encodedPath`. * * @param components the stored components whose path is rendered. - * @return the encoded URL path string, with the [NORM-18] `/.` guard applied where required. + * @param guardAgainstAuthority whether to prepend the [NORM-18] `/.` guard where required + * (`true` for the full `href`, where an unguarded `//`-leading path would re-parse with a + * spurious authority); the standalone `pathname` getter passes `false` since it is never + * concatenated onto a bare `scheme:` and WHATWG's pathname-getter algorithm has no such guard. + * @return the encoded URL path string. */ -internal fun serializeUrlPath(components: ParsedComponents): String = +internal fun serializeUrlPath( + components: ParsedComponents, + guardAgainstAuthority: Boolean = true, +): String = when (val path = components.path) { is UrlPath.Opaque -> path.path - is UrlPath.Segments -> serializeUrlSegments(path.segments, noAuthority = components.host == null) + is UrlPath.Segments -> + serializeUrlSegments( + path.segments, + noAuthority = components.host == null, + guardAgainstAuthority = guardAgainstAuthority, + ) } /** * Joins URL path [segments] as `"/" + segment` runs, applying the [NORM-18] `/.` guard. * - * The guard fires only for a no-authority value whose first segment is empty and which has a - * further segment (so the serialization would open `//` and re-parse with a spurious authority). + * The guard fires only when [guardAgainstAuthority] is requested, for a no-authority value whose + * first segment is empty and which has a further segment (so the serialization would open `//` + * and re-parse with a spurious authority). */ private fun serializeUrlSegments( segments: List, noAuthority: Boolean, + guardAgainstAuthority: Boolean, ): String { - val needsGuard = noAuthority && segments.size > 1 && segments[0] == "" + val needsGuard = guardAgainstAuthority && noAuthority && segments.size > 1 && segments[0] == "" val prefix = if (needsGuard) LEADING_DOT_GUARD else "" return prefix + segments.joinToString("") { "$SLASH$it" } } diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt new file mode 100644 index 0000000..e76e77c --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UrlSetterTest { + private fun url(s: String): Url = Url.parseOrThrow(s) + + @Test + fun `withProtocol changes a special scheme to another special scheme`() { + assertEquals("https://example.net/", url("http://example.net/").withProtocol("https").href) + } + + @Test + fun `withProtocol is a no-op when crossing the special boundary`() { + val original = url("http://example.net/") + assertEquals(original.href, original.withProtocol("fake").href) + } + + @Test + fun `withUsername percent-encodes and sets the user`() { + assertEquals("wss://me@example.org/", url("wss://example.org/").withUsername("me").href) + } + + @Test + fun `withUsername is a no-op when the url has no host`() { + val original = url("mailto:x@example.com") + assertEquals(original.href, original.withUsername("me").href) + } + + @Test + fun `withPassword sets the password`() { + assertEquals("wss://:pw@example.org/", url("wss://example.org/").withPassword("pw").href) + } + + @Test + fun `withHostname replaces the host and preserves the port`() { + assertEquals("http://example.com:90/", url("http://h:90/").withHostname("example.com").href) + } + + @Test + fun `withHost replaces host and port together`() { + assertEquals("http://example.com:81/", url("http://h:90/").withHost("example.com:81").href) + } + + @Test + fun `withHostname is a no-op on an opaque-path url`() { + val original = url("mailto:x@example.com") + assertEquals(original.href, original.withHostname("example.org").href) + } + + @Test + fun `withHost stops at a path terminator and leaves the path unchanged`() { + assertEquals("http://example.com/p", url("http://h/p").withHost("example.com/x").href) + } + + @Test + fun `withPort string sets the port`() { + assertEquals("http://h:8080/", url("http://h/").withPort("8080").href) + } + + @Test + fun `withPort empty string removes the port`() { + assertEquals("http://h/", url("http://h:8080/").withPort("").href) + } + + @Test + fun `withPort ignores trailing junk after the digits`() { + assertEquals("http://h:8080/", url("http://h/").withPort("8080stuff").href) + } + + @Test + fun `withPort is a no-op on a hostless url`() { + val original = url("mailto:x@example.com") + assertEquals(original.href, original.withPort("80").href) + } + + @Test + fun `withPathname replaces the path`() { + assertEquals("http://h/a/b", url("http://h/old").withPathname("/a/b").href) + } + + @Test + fun `withPathname is a no-op on an opaque-path url`() { + val original = url("mailto:x@example.com") + assertEquals(original.href, original.withPathname("/a").href) + } + + @Test + fun `withSearch sets the query and strips a leading question mark`() { + assertEquals("http://h/?a=b", url("http://h/").withSearch("?a=b").href) + } + + @Test + fun `withSearch empty removes the query`() { + assertEquals("http://h/", url("http://h/?a=b").withSearch("").href) + } + + @Test + fun `withSearch replaces an existing query rather than appending`() { + assertEquals("http://h/?a=b", url("http://h/?old=1").withSearch("?a=b").href) + } + + @Test + fun `withHash sets the fragment and strips a leading hash`() { + assertEquals("http://h/#frag", url("http://h/").withHash("#frag").href) + } + + @Test + fun `withHash empty removes the fragment`() { + assertEquals("http://h/", url("http://h/#frag").withHash("").href) + } +} diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/RoundTripInvariantTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/RoundTripInvariantTest.kt new file mode 100644 index 0000000..0e3cfdb --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/RoundTripInvariantTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.parser + +import org.dexpace.kuri.Url +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RoundTripInvariantTest { + private fun parsedCases(): List = + URL_TEST_CASES + .filterNot { it.failure } + .mapNotNull { case -> Url.parseOrNull(case.input, case.base?.let { Url.parseOrNull(it) }) } + + @Test + fun `serialization is idempotent for every parsable case`() { + parsedCases().forEach { url -> + val reparsed = Url.parseOrThrow(url.href) + assertEquals(url.href, reparsed.href, "href not idempotent for ${url.href}") + } + } + + @Test + fun `newBuilder round-trips to the same href for every parsable case`() { + parsedCases().forEach { url -> + assertEquals(url.href, url.newBuilder().build().href, "builder round-trip drifted for ${url.href}") + } + } + + @Test + fun `the invariant corpus is substantial`() { + assertTrue(parsedCases().size > 500, "expected a substantial parsable corpus: ${parsedCases().size}") + } +} diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/SetterTestData.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/SetterTestData.kt new file mode 100644 index 0000000..440f6c6 --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/SetterTestData.kt @@ -0,0 +1,2193 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ + +// Generated bulk data, not hand-written logic: the chunked builders intentionally exceed +// detekt's method/class-size heuristics to stay within the 64 KB JVM method limit. +@file:Suppress("LongMethod", "LargeClass", "MatchingDeclarationName") + +package org.dexpace.kuri.parser + +/** + * One WPT `setters_tests.json` case. Assigning [newValue] through the [component] setter on + * the URL parsed from [href] must leave every getter named in [expected] (keyed by getter name, + * e.g. `href`, `protocol`, `hostname`) equal to its mapped string; [expected] is unordered. + */ +internal data class SetterCase( + val component: String, + val href: String, + val newValue: String, + val expected: Map, +) + +// Generated by ./gradlew generateSetterTestData from the WPT setters_tests corpus. +// Chunked into small builders so no single method exceeds the 64 KB JVM limit. +private object SetterCaseData { + fun all(): List = + part0() + + part1() + + part2() + + part3() + + part4() + + private fun part0(): List = + listOf( + SetterCase( + component = "hash", + href = "https://example.net", + newValue = "main", + expected = mapOf("hash" to "#main", "href" to "https://example.net/#main"), + ), + SetterCase( + component = "hash", + href = "https://example.net#nav", + newValue = "main", + expected = mapOf("hash" to "#main", "href" to "https://example.net/#main"), + ), + SetterCase( + component = "hash", + href = "https://example.net?lang=en-US", + newValue = "##nav", + expected = mapOf("hash" to "##nav", "href" to "https://example.net/?lang=en-US##nav"), + ), + SetterCase( + component = "hash", + href = "https://example.net?lang=en-US#nav", + newValue = "#main", + expected = mapOf("hash" to "#main", "href" to "https://example.net/?lang=en-US#main"), + ), + SetterCase( + component = "hash", + href = "https://example.net?lang=en-US#nav", + newValue = "#", + expected = mapOf("hash" to "", "href" to "https://example.net/?lang=en-US#"), + ), + SetterCase( + component = "hash", + href = "https://example.net?lang=en-US#nav", + newValue = "", + expected = mapOf("hash" to "", "href" to "https://example.net/?lang=en-US"), + ), + SetterCase( + component = "hash", + href = "http://example.net", + newValue = "#foo bar", + expected = mapOf("hash" to "#foo%20bar", "href" to "http://example.net/#foo%20bar"), + ), + SetterCase( + component = "hash", + href = "http://example.net", + newValue = "#foo\"bar", + expected = mapOf("hash" to "#foo%22bar", "href" to "http://example.net/#foo%22bar"), + ), + SetterCase( + component = "hash", + href = "http://example.net", + newValue = "#foo?@AZ[\\]^_`az{|}~\u007f\u0080" + + "\u0081\u00c9\u00e9", + expected = + mapOf( + "hash" to + "#%00%01%1F%20!%22#\$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_%60az{|}~%7F%C2%80%C2%81%C3%89%C3%A9", + "href" to + "a:/#%00%01%1F%20!%22#\$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_%60az{|}~%7F%C2%80%C2%81%C3%89%C3" + + "%A9", + ), + ), + SetterCase( + component = "hash", + href = "http://example.net", + newValue = "a\u0000b", + expected = mapOf("hash" to "#a%00b", "href" to "http://example.net/#a%00b"), + ), + SetterCase( + component = "hash", + href = "non-spec:/", + newValue = "a\u0000b", + expected = mapOf("hash" to "#a%00b", "href" to "non-spec:/#a%00b"), + ), + SetterCase( + component = "hash", + href = "http://example.net", + newValue = "%c3%89t\u00e9", + expected = mapOf("hash" to "#%c3%89t%C3%A9", "href" to "http://example.net/#%c3%89t%C3%A9"), + ), + SetterCase( + component = "hash", + href = "javascript:alert(1)", + newValue = "castle", + expected = mapOf("hash" to "#castle", "href" to "javascript:alert(1)#castle"), + ), + SetterCase( + component = "hash", + href = + "data:space " + + " #fragment", + newValue = "", + expected = + mapOf( + "hash" to "", + "href" to + "data:space " + + " %20", + "pathname" to + "space " + + " %20", + ), + ), + SetterCase( + component = "hash", + href = "sc:space #fragment", + newValue = "", + expected = mapOf("hash" to "", "href" to "sc:space %20", "pathname" to "space %20"), + ), + SetterCase( + component = "hash", + href = "data:space ?query#fragment", + newValue = "", + expected = mapOf("hash" to "", "href" to "data:space %20?query"), + ), + SetterCase( + component = "hash", + href = "sc:space ?query#fragment", + newValue = "", + expected = mapOf("hash" to "", "href" to "sc:space %20?query"), + ), + SetterCase( + component = "hash", + href = "http://example.net", + newValue = " ", + expected = mapOf("hash" to "#%20", "href" to "http://example.net/#%20"), + ), + SetterCase( + component = "hash", + href = "http://example.net", + newValue = "\u0000", + expected = mapOf("hash" to "#%00", "href" to "http://example.net/#%00"), + ), + SetterCase( + component = "hash", + href = "data:text/plain,test", + newValue = "# ", + expected = + mapOf( + "hash" to "#%3Cfoo%3E%20%3Cbar%3E", + "href" to "data:text/plain,test#%3Cfoo%3E%20%3Cbar%3E", + ), + ), + SetterCase( + component = "hash", + href = "about:blank", + newValue = "# ", + expected = mapOf("hash" to "#%3Cfoo%3E%20%3Cbar%3E", "href" to "about:blank#%3Cfoo%3E%20%3Cbar%3E"), + ), + SetterCase( + component = "hash", + href = "data:text/plain,test", + newValue = + "#\u0000\u0001\u0009\u000a\u000d\u001f !\"#\$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~\u007f\u0080" + + "\u0081\u00c9\u00e9", + expected = + mapOf( + "hash" to + "#%00%01%1F%20!%22#\$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_%60az{|}~%7F%C2%80%C2%81%C3%89%C3%A9", + "href" to + "data:text/plain,test#%00%01%1F%20!%22#\$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_%60az{|}~%7F%C2%" + + "80%C2%81%C3%89%C3%A9", + ), + ), + SetterCase( + component = "hash", + href = "about:blank", + newValue = + "#\u0000\u0001\u0009\u000a\u000d\u001f !\"#\$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~\u007f\u0080" + + "\u0081\u00c9\u00e9", + expected = + mapOf( + "hash" to + "#%00%01%1F%20!%22#\$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_%60az{|}~%7F%C2%80%C2%81%C3%89%C3%A9", + "href" to + "about:blank#%00%01%1F%20!%22#\$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_%60az{|}~%7F%C2%80%C2%81%" + + "C3%89%C3%A9", + ), + ), + SetterCase( + component = "host", + href = "sc://x/", + newValue = "\u0000", + expected = mapOf("host" to "x", "hostname" to "x", "href" to "sc://x/"), + ), + SetterCase( + component = "host", + href = "sc://x/", + newValue = "\u0009", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "host", + href = "sc://x/", + newValue = "\u000a", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "host", + href = "sc://x/", + newValue = "\u000d", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "host", + href = "sc://x/", + newValue = " ", + expected = mapOf("host" to "x", "hostname" to "x", "href" to "sc://x/"), + ), + SetterCase( + component = "host", + href = "sc://x/", + newValue = "#", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "host", + href = "sc://x/", + newValue = "/", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "host", + href = "sc://x/", + newValue = "?", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "host", + href = "sc://x/", + newValue = "@", + expected = mapOf("host" to "x", "hostname" to "x", "href" to "sc://x/"), + ), + SetterCase( + component = "host", + href = "sc://x/", + newValue = "\u00df", + expected = mapOf("host" to "%C3%9F", "hostname" to "%C3%9F", "href" to "sc://%C3%9F/"), + ), + SetterCase( + component = "host", + href = "https://x/", + newValue = "\u00df", + expected = mapOf("host" to "xn--zca", "hostname" to "xn--zca", "href" to "https://xn--zca/"), + ), + SetterCase( + component = "host", + href = "mailto:me@example.net", + newValue = "example.com", + expected = mapOf("host" to "", "href" to "mailto:me@example.net"), + ), + SetterCase( + component = "host", + href = "data:text/plain,Stuff", + newValue = "example.net", + expected = mapOf("host" to "", "href" to "data:text/plain,Stuff"), + ), + SetterCase( + component = "host", + href = "http://example.net", + newValue = "example.com:8080", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "http://example.com:8080/", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net:8080", + newValue = "example.com", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "http://example.com:8080/", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net:8080", + newValue = "example.com:", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "http://example.com:8080/", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net", + newValue = "", + expected = mapOf("host" to "example.net", "href" to "http://example.net/"), + ), + SetterCase( + component = "host", + href = "view-source+http://example.net/foo", + newValue = "", + expected = mapOf("host" to "", "href" to "view-source+http:///foo"), + ), + SetterCase( + component = "host", + href = "a:/foo", + newValue = "example.net", + expected = mapOf("host" to "example.net", "href" to "a://example.net/foo"), + ), + SetterCase( + component = "host", + href = "http://example.net", + newValue = "0x7F000001:8080", + expected = + mapOf( + "host" to "127.0.0.1:8080", + "hostname" to "127.0.0.1", + "href" to "http://127.0.0.1:8080/", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net", + newValue = "[::0:01]:2", + expected = + mapOf( + "host" to "[::1]:2", + "hostname" to "[::1]", + "href" to "http://[::1]:2/", + "port" to "2", + ), + ), + SetterCase( + component = "host", + href = "http://example.net", + newValue = "[2001:db8::2]:4002", + expected = + mapOf( + "host" to "[2001:db8::2]:4002", + "hostname" to "[2001:db8::2]", + "href" to "http://[2001:db8::2]:4002/", + "port" to "4002", + ), + ), + SetterCase( + component = "host", + href = "http://example.net", + newValue = "example.com:80", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/", + "port" to "", + ), + ), + SetterCase( + component = "host", + href = "https://example.net", + newValue = "example.com:443", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "https://example.com/", + "port" to "", + ), + ), + SetterCase( + component = "host", + href = "https://example.net", + newValue = "example.com:80", + expected = + mapOf( + "host" to "example.com:80", + "hostname" to "example.com", + "href" to "https://example.com:80/", + "port" to "80", + ), + ), + SetterCase( + component = "host", + href = "http://example.net:8080", + newValue = "example.com:80", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/", + "port" to "", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com/stuff", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/path", + "port" to "", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com:8080/stuff", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "http://example.com:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com?stuff", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/path", + "port" to "", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com?stuff:8080", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/path", + "port" to "", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com:8080?stuff", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "http://example.com:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com#stuff", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/path", + "port" to "", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com:8080#stuff", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "http://example.com:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com\\stuff", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/path", + "port" to "", + ), + ), + ) + + private fun part1(): List = + listOf( + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com:8080\\stuff", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "http://example.com:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "view-source+http://example.net/path", + newValue = "example.com\\stuff", + expected = + mapOf( + "host" to "example.net", + "hostname" to "example.net", + "href" to "view-source+http://example.net/path", + "port" to "", + ), + ), + SetterCase( + component = "host", + href = "view-source+http://example.net/path", + newValue = "example.com:8080stuff2", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "view-source+http://example.com:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com:8080stuff2", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "http://example.com:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com:8080+2", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "http://example.com:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net:8080", + newValue = "example.com:invalid", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "http://example.com:8080/", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net:8080/test", + newValue = "[::1]:invalid", + expected = + mapOf( + "host" to "[::1]:8080", + "hostname" to "[::1]", + "href" to "http://[::1]:8080/test", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net:8080/test", + newValue = "[::1]", + expected = + mapOf( + "host" to "[::1]:8080", + "hostname" to "[::1]", + "href" to "http://[::1]:8080/test", + "port" to "8080", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com:65535", + expected = + mapOf( + "host" to "example.com:65535", + "hostname" to "example.com", + "href" to "http://example.com:65535/path", + "port" to "65535", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/path", + newValue = "example.com:65536", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/path", + "port" to "", + ), + ), + SetterCase( + component = "host", + href = "http://example.net/", + newValue = "[google.com]", + expected = mapOf("host" to "example.net", "hostname" to "example.net", "href" to "http://example.net/"), + ), + SetterCase( + component = "host", + href = "http://example.net/", + newValue = "[::1.2.3.4x]", + expected = mapOf("host" to "example.net", "hostname" to "example.net", "href" to "http://example.net/"), + ), + SetterCase( + component = "host", + href = "http://example.net/", + newValue = "[::1.2.3.]", + expected = mapOf("host" to "example.net", "hostname" to "example.net", "href" to "http://example.net/"), + ), + SetterCase( + component = "host", + href = "http://example.net/", + newValue = "[::1.2.]", + expected = mapOf("host" to "example.net", "hostname" to "example.net", "href" to "http://example.net/"), + ), + SetterCase( + component = "host", + href = "http://example.net/", + newValue = "[::1.]", + expected = mapOf("host" to "example.net", "hostname" to "example.net", "href" to "http://example.net/"), + ), + SetterCase( + component = "host", + href = "file://y/", + newValue = "x:123", + expected = mapOf("host" to "y", "hostname" to "y", "href" to "file://y/", "port" to ""), + ), + SetterCase( + component = "host", + href = "file://y/", + newValue = "loc%41lhost", + expected = mapOf("host" to "", "hostname" to "", "href" to "file:///", "port" to ""), + ), + SetterCase( + component = "host", + href = "file://hi/x", + newValue = "", + expected = mapOf("host" to "", "hostname" to "", "href" to "file:///x", "port" to ""), + ), + SetterCase( + component = "host", + href = "sc://test@test/", + newValue = "", + expected = + mapOf( + "host" to "test", + "hostname" to "test", + "href" to "sc://test@test/", + "username" to "test", + ), + ), + SetterCase( + component = "host", + href = "sc://test:12/", + newValue = "", + expected = mapOf("host" to "test:12", "hostname" to "test", "href" to "sc://test:12/", "port" to "12"), + ), + SetterCase( + component = "host", + href = "http://example.com/", + newValue = "///bad.com", + expected = mapOf("host" to "example.com", "hostname" to "example.com", "href" to "http://example.com/"), + ), + SetterCase( + component = "host", + href = "sc://example.com/", + newValue = "///bad.com", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "host", + href = "https://example.com/", + newValue = "a%C2%ADb", + expected = mapOf("host" to "ab", "hostname" to "ab", "href" to "https://ab/"), + ), + SetterCase( + component = "host", + href = "https://example.com/", + newValue = "\u00ad", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "https://example.com/", + ), + ), + SetterCase( + component = "host", + href = "https://example.com/", + newValue = "%C2%AD", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "https://example.com/", + ), + ), + SetterCase( + component = "host", + href = "https://example.com/", + newValue = "xn--", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "https://example.com/", + ), + ), + SetterCase( + component = "host", + href = "https://test.invalid/", + newValue = "*", + expected = mapOf("host" to "*", "hostname" to "*", "href" to "https://*/"), + ), + SetterCase( + component = "host", + href = "https://test.invalid/", + newValue = "x@x", + expected = + mapOf( + "host" to "test.invalid", + "hostname" to "test.invalid", + "href" to "https://test.invalid/", + ), + ), + SetterCase( + component = "host", + href = "https://test.invalid/", + newValue = "foo\u0009\u000d\u000abar", + expected = mapOf("host" to "foobar", "hostname" to "foobar", "href" to "https://foobar/"), + ), + SetterCase( + component = "host", + href = "https://test.invalid/", + newValue = "><", + expected = + mapOf( + "host" to "test.invalid", + "hostname" to "test.invalid", + "href" to "https://test.invalid/", + ), + ), + SetterCase( + component = "host", + href = "https://test.invalid/", + newValue = "test/@aaa", + expected = mapOf("host" to "test", "hostname" to "test", "href" to "https://test/"), + ), + SetterCase( + component = "host", + href = "https://test.invalid/", + newValue = "test/:aaa", + expected = mapOf("host" to "test", "hostname" to "test", "href" to "https://test/"), + ), + SetterCase( + component = "host", + href = "foo://path/to", + newValue = ":80", + expected = mapOf("host" to "path", "href" to "foo://path/to", "port" to ""), + ), + SetterCase( + component = "hostname", + href = "sc://x/", + newValue = "\u0000", + expected = mapOf("host" to "x", "hostname" to "x", "href" to "sc://x/"), + ), + SetterCase( + component = "hostname", + href = "sc://x/", + newValue = "\u0009", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "hostname", + href = "sc://x/", + newValue = "\u000a", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "hostname", + href = "sc://x/", + newValue = "\u000d", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "hostname", + href = "sc://x/", + newValue = " ", + expected = mapOf("host" to "x", "hostname" to "x", "href" to "sc://x/"), + ), + SetterCase( + component = "hostname", + href = "sc://x/", + newValue = "#", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "hostname", + href = "sc://x/", + newValue = "/", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "hostname", + href = "sc://x/", + newValue = "?", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "hostname", + href = "sc://x/", + newValue = "@", + expected = mapOf("host" to "x", "hostname" to "x", "href" to "sc://x/"), + ), + SetterCase( + component = "hostname", + href = "mailto:me@example.net", + newValue = "example.com", + expected = mapOf("host" to "", "href" to "mailto:me@example.net"), + ), + SetterCase( + component = "hostname", + href = "data:text/plain,Stuff", + newValue = "example.net", + expected = mapOf("host" to "", "href" to "data:text/plain,Stuff"), + ), + SetterCase( + component = "hostname", + href = "http://example.net:8080", + newValue = "example.com", + expected = + mapOf( + "host" to "example.com:8080", + "hostname" to "example.com", + "href" to "http://example.com:8080/", + "port" to "8080", + ), + ), + SetterCase( + component = "hostname", + href = "http://example.net", + newValue = "", + expected = mapOf("host" to "example.net", "href" to "http://example.net/"), + ), + SetterCase( + component = "hostname", + href = "view-source+http://example.net/foo", + newValue = "", + expected = mapOf("host" to "", "href" to "view-source+http:///foo"), + ), + SetterCase( + component = "hostname", + href = "a:/foo", + newValue = "example.net", + expected = mapOf("host" to "example.net", "href" to "a://example.net/foo"), + ), + SetterCase( + component = "hostname", + href = "http://example.net:8080", + newValue = "0x7F000001", + expected = + mapOf( + "host" to "127.0.0.1:8080", + "hostname" to "127.0.0.1", + "href" to "http://127.0.0.1:8080/", + "port" to "8080", + ), + ), + SetterCase( + component = "hostname", + href = "http://example.net", + newValue = "[::0:01]", + expected = mapOf("host" to "[::1]", "hostname" to "[::1]", "href" to "http://[::1]/", "port" to ""), + ), + SetterCase( + component = "hostname", + href = "http://example.net/path", + newValue = "example.com:8080", + expected = + mapOf( + "host" to "example.net", + "hostname" to "example.net", + "href" to "http://example.net/path", + "port" to "", + ), + ), + SetterCase( + component = "hostname", + href = "http://example.net:8080/path", + newValue = "example.com:", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "http://example.net:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "hostname", + href = "http://example.net/path", + newValue = "example.com/stuff", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/path", + "port" to "", + ), + ), + SetterCase( + component = "hostname", + href = "http://example.net/path", + newValue = "example.com?stuff", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/path", + "port" to "", + ), + ), + SetterCase( + component = "hostname", + href = "http://example.net/path", + newValue = "example.com#stuff", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/path", + "port" to "", + ), + ), + SetterCase( + component = "hostname", + href = "http://example.net/path", + newValue = "example.com\\stuff", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "http://example.com/path", + "port" to "", + ), + ), + SetterCase( + component = "hostname", + href = "view-source+http://example.net/path", + newValue = "example.com\\stuff", + expected = + mapOf( + "host" to "example.net", + "hostname" to "example.net", + "href" to "view-source+http://example.net/path", + "port" to "", + ), + ), + SetterCase( + component = "hostname", + href = "http://example.net/", + newValue = "[google.com]", + expected = mapOf("host" to "example.net", "hostname" to "example.net", "href" to "http://example.net/"), + ), + SetterCase( + component = "hostname", + href = "http://example.net/", + newValue = "[::1.2.3.4x]", + expected = mapOf("host" to "example.net", "hostname" to "example.net", "href" to "http://example.net/"), + ), + SetterCase( + component = "hostname", + href = "http://example.net/", + newValue = "[::1.2.3.]", + expected = mapOf("host" to "example.net", "hostname" to "example.net", "href" to "http://example.net/"), + ), + ) + + private fun part2(): List = + listOf( + SetterCase( + component = "hostname", + href = "http://example.net/", + newValue = "[::1.2.]", + expected = mapOf("host" to "example.net", "hostname" to "example.net", "href" to "http://example.net/"), + ), + SetterCase( + component = "hostname", + href = "http://example.net/", + newValue = "[::1.]", + expected = mapOf("host" to "example.net", "hostname" to "example.net", "href" to "http://example.net/"), + ), + SetterCase( + component = "hostname", + href = "file://y/", + newValue = "x:123", + expected = mapOf("host" to "y", "hostname" to "y", "href" to "file://y/", "port" to ""), + ), + SetterCase( + component = "hostname", + href = "file://y/", + newValue = "loc%41lhost", + expected = mapOf("host" to "", "hostname" to "", "href" to "file:///", "port" to ""), + ), + SetterCase( + component = "hostname", + href = "file://hi/x", + newValue = "", + expected = mapOf("host" to "", "hostname" to "", "href" to "file:///x", "port" to ""), + ), + SetterCase( + component = "hostname", + href = "sc://test@test/", + newValue = "", + expected = + mapOf( + "host" to "test", + "hostname" to "test", + "href" to "sc://test@test/", + "username" to "test", + ), + ), + SetterCase( + component = "hostname", + href = "sc://test:12/", + newValue = "", + expected = mapOf("host" to "test:12", "hostname" to "test", "href" to "sc://test:12/", "port" to "12"), + ), + SetterCase( + component = "hostname", + href = "non-spec:/.//p", + newValue = "h", + expected = mapOf("host" to "h", "hostname" to "h", "href" to "non-spec://h//p", "pathname" to "//p"), + ), + SetterCase( + component = "hostname", + href = "non-spec:/.//p", + newValue = "", + expected = mapOf("host" to "", "hostname" to "", "href" to "non-spec:////p", "pathname" to "//p"), + ), + SetterCase( + component = "hostname", + href = "http://example.com/", + newValue = "///bad.com", + expected = mapOf("host" to "example.com", "hostname" to "example.com", "href" to "http://example.com/"), + ), + SetterCase( + component = "hostname", + href = "sc://example.com/", + newValue = "///bad.com", + expected = mapOf("host" to "", "hostname" to "", "href" to "sc:///"), + ), + SetterCase( + component = "hostname", + href = "https://example.com/", + newValue = "a%C2%ADb", + expected = mapOf("host" to "ab", "hostname" to "ab", "href" to "https://ab/"), + ), + SetterCase( + component = "hostname", + href = "https://example.com/", + newValue = "\u00ad", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "https://example.com/", + ), + ), + SetterCase( + component = "hostname", + href = "https://example.com/", + newValue = "%C2%AD", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "https://example.com/", + ), + ), + SetterCase( + component = "hostname", + href = "https://example.com/", + newValue = "xn--", + expected = + mapOf( + "host" to "example.com", + "hostname" to "example.com", + "href" to "https://example.com/", + ), + ), + SetterCase( + component = "hostname", + href = "https://test.invalid/", + newValue = "*", + expected = mapOf("host" to "*", "hostname" to "*", "href" to "https://*/"), + ), + SetterCase( + component = "hostname", + href = "https://test.invalid/", + newValue = "x@x", + expected = + mapOf( + "host" to "test.invalid", + "hostname" to "test.invalid", + "href" to "https://test.invalid/", + ), + ), + SetterCase( + component = "hostname", + href = "https://test.invalid/", + newValue = "foo\u0009\u000d\u000abar", + expected = mapOf("host" to "foobar", "hostname" to "foobar", "href" to "https://foobar/"), + ), + SetterCase( + component = "hostname", + href = "https://test.invalid/", + newValue = "><", + expected = + mapOf( + "host" to "test.invalid", + "hostname" to "test.invalid", + "href" to "https://test.invalid/", + ), + ), + SetterCase( + component = "hostname", + href = "https://test.invalid/", + newValue = "test/@aaa", + expected = mapOf("host" to "test", "hostname" to "test", "href" to "https://test/"), + ), + SetterCase( + component = "hostname", + href = "https://test.invalid/", + newValue = "test/:aaa", + expected = mapOf("host" to "test", "hostname" to "test", "href" to "https://test/"), + ), + SetterCase( + component = "href", + href = "file:///var/log/system.log", + newValue = "http://0300.168.0xF0", + expected = mapOf("href" to "http://192.168.0.240/", "protocol" to "http:"), + ), + SetterCase( + component = "password", + href = "file:///home/me/index.html", + newValue = "secret", + expected = mapOf("href" to "file:///home/me/index.html", "password" to ""), + ), + SetterCase( + component = "password", + href = "unix:/run/foo.socket", + newValue = "secret", + expected = mapOf("href" to "unix:/run/foo.socket", "password" to ""), + ), + SetterCase( + component = "password", + href = "mailto:me@example.net", + newValue = "secret", + expected = mapOf("href" to "mailto:me@example.net", "password" to ""), + ), + SetterCase( + component = "password", + href = "http://example.net", + newValue = "secret", + expected = mapOf("href" to "http://:secret@example.net/", "password" to "secret"), + ), + SetterCase( + component = "password", + href = "http://me@example.net", + newValue = "secret", + expected = mapOf("href" to "http://me:secret@example.net/", "password" to "secret"), + ), + SetterCase( + component = "password", + href = "http://:secret@example.net", + newValue = "", + expected = mapOf("href" to "http://example.net/", "password" to ""), + ), + SetterCase( + component = "password", + href = "http://me:secret@example.net", + newValue = "", + expected = mapOf("href" to "http://me@example.net/", "password" to ""), + ), + SetterCase( + component = "password", + href = "http://example.net", + newValue = + "\u0000\u0001\u0009\u000a\u000d\u001f !\"#\$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~\u007f\u0080" + + "\u0081\u00c9\u00e9", + expected = + mapOf( + "href" to + "http://:%00%01%09%0A%0D%1F%20!%22%23\$%&'()*+,-.%2F09%3A%3B%3C%3D%3E%3F%40AZ%5B%5C%5D%5E" + + "_%60az%7B%7C%7D~%7F%C2%80%C2%81%C3%89%C3%A9@example.net/", + "password" to + "%00%01%09%0A%0D%1F%20!%22%23\$%&'()*+,-.%2F09%3A%3B%3C%3D%3E%3F%40AZ%5B%5C%5D%5E_%60az%7" + + "B%7C%7D~%7F%C2%80%C2%81%C3%89%C3%A9", + ), + ), + SetterCase( + component = "password", + href = "http://example.net", + newValue = "%c3%89t\u00e9", + expected = mapOf("href" to "http://:%c3%89t%C3%A9@example.net/", "password" to "%c3%89t%C3%A9"), + ), + SetterCase( + component = "password", + href = "sc:///", + newValue = "x", + expected = mapOf("href" to "sc:///", "password" to ""), + ), + SetterCase( + component = "password", + href = "javascript://x/", + newValue = "bowser", + expected = mapOf("href" to "javascript://:bowser@x/", "password" to "bowser"), + ), + SetterCase( + component = "password", + href = "file://test/", + newValue = "test", + expected = mapOf("href" to "file://test/", "password" to ""), + ), + SetterCase( + component = "pathname", + href = "mailto:me@example.net", + newValue = "/foo", + expected = mapOf("href" to "mailto:me@example.net", "pathname" to "me@example.net"), + ), + SetterCase( + component = "pathname", + href = "data:original", + newValue = "new value", + expected = mapOf("href" to "data:original", "pathname" to "original"), + ), + SetterCase( + component = "pathname", + href = "sc:original", + newValue = "new value", + expected = mapOf("href" to "sc:original", "pathname" to "original"), + ), + SetterCase( + component = "pathname", + href = "file:///some/path", + newValue = "", + expected = mapOf("href" to "file:///", "pathname" to "/"), + ), + SetterCase( + component = "pathname", + href = "foo://somehost/some/path", + newValue = "", + expected = mapOf("href" to "foo://somehost", "pathname" to ""), + ), + SetterCase( + component = "pathname", + href = "foo:///some/path", + newValue = "", + expected = mapOf("href" to "foo://", "pathname" to ""), + ), + SetterCase( + component = "pathname", + href = "foo:/some/path", + newValue = "", + expected = mapOf("href" to "foo:/", "pathname" to "/"), + ), + SetterCase( + component = "pathname", + href = "foo:/some/path", + newValue = "test", + expected = mapOf("href" to "foo:/test", "pathname" to "/test"), + ), + SetterCase( + component = "pathname", + href = "unix:/run/foo.socket?timeout=10", + newValue = "/var/log/../run/bar.socket", + expected = mapOf("href" to "unix:/var/run/bar.socket?timeout=10", "pathname" to "/var/run/bar.socket"), + ), + SetterCase( + component = "pathname", + href = "https://example.net#nav", + newValue = "home", + expected = mapOf("href" to "https://example.net/home#nav", "pathname" to "/home"), + ), + SetterCase( + component = "pathname", + href = "https://example.net#nav", + newValue = "../home", + expected = mapOf("href" to "https://example.net/home#nav", "pathname" to "/home"), + ), + SetterCase( + component = "pathname", + href = "http://example.net/home?lang=fr#nav", + newValue = "\\a\\%2E\\b\\%2e.\\c", + expected = mapOf("href" to "http://example.net/a/c?lang=fr#nav", "pathname" to "/a/c"), + ), + SetterCase( + component = "pathname", + href = "view-source+http://example.net/home?lang=fr#nav", + newValue = "\\a\\%2E\\b\\%2e.\\c", + expected = + mapOf( + "href" to "view-source+http://example.net/\\a\\%2E\\b\\%2e.\\c?lang=fr#nav", + "pathname" to "/\\a\\%2E\\b\\%2e.\\c", + ), + ), + SetterCase( + component = "pathname", + href = "a:/", + newValue = + "\u0000\u0001\u0009\u000a\u000d\u001f !\"#\$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~\u007f\u0080" + + "\u0081\u00c9\u00e9", + expected = + mapOf( + "href" to + "a:/%00%01%1F%20!%22%23\$%&'()*+,-./09:;%3C=%3E%3F@AZ[\\]%5E_%60az%7B|%7D~%7F%C2%80%C2%81" + + "%C3%89%C3%A9", + "pathname" to + "/%00%01%1F%20!%22%23\$%&'()*+,-./09:;%3C=%3E%3F@AZ[\\]%5E_%60az%7B|%7D~%7F%C2%80%C2%81%C" + + "3%89%C3%A9", + ), + ), + SetterCase( + component = "pathname", + href = "http://example.net", + newValue = "%2e%2E%c3%89t\u00e9", + expected = + mapOf( + "href" to "http://example.net/%2e%2E%c3%89t%C3%A9", + "pathname" to "/%2e%2E%c3%89t%C3%A9", + ), + ), + SetterCase( + component = "pathname", + href = "http://example.net", + newValue = "?", + expected = mapOf("href" to "http://example.net/%3F", "pathname" to "/%3F"), + ), + SetterCase( + component = "pathname", + href = "http://example.net", + newValue = "#", + expected = mapOf("href" to "http://example.net/%23", "pathname" to "/%23"), + ), + SetterCase( + component = "pathname", + href = "sc://example.net", + newValue = "?", + expected = mapOf("href" to "sc://example.net/%3F", "pathname" to "/%3F"), + ), + SetterCase( + component = "pathname", + href = "sc://example.net", + newValue = "#", + expected = mapOf("href" to "sc://example.net/%23", "pathname" to "/%23"), + ), + SetterCase( + component = "pathname", + href = "http://example.net", + newValue = "/?\u00e9", + expected = mapOf("href" to "http://example.net/%3F%C3%A9", "pathname" to "/%3F%C3%A9"), + ), + SetterCase( + component = "pathname", + href = "http://example.net", + newValue = "/#\u00e9", + expected = mapOf("href" to "http://example.net/%23%C3%A9", "pathname" to "/%23%C3%A9"), + ), + SetterCase( + component = "pathname", + href = "file://monkey/", + newValue = "\\\\", + expected = mapOf("href" to "file://monkey//", "pathname" to "//"), + ), + SetterCase( + component = "pathname", + href = "file:///unicorn", + newValue = "//\\/", + expected = mapOf("href" to "file://////", "pathname" to "////"), + ), + SetterCase( + component = "pathname", + href = "file:///unicorn", + newValue = "//monkey/..//", + expected = mapOf("href" to "file://///", "pathname" to "///"), + ), + SetterCase( + component = "pathname", + href = "non-spec:/", + newValue = "/.//p", + expected = mapOf("href" to "non-spec:/.//p", "pathname" to "//p"), + ), + SetterCase( + component = "pathname", + href = "non-spec:/", + newValue = "/..//p", + expected = mapOf("href" to "non-spec:/.//p", "pathname" to "//p"), + ), + ) + + private fun part3(): List = + listOf( + SetterCase( + component = "pathname", + href = "non-spec:/", + newValue = "//p", + expected = mapOf("href" to "non-spec:/.//p", "pathname" to "//p"), + ), + SetterCase( + component = "pathname", + href = "non-spec:/.//", + newValue = "p", + expected = mapOf("href" to "non-spec:/p", "pathname" to "/p"), + ), + SetterCase( + component = "pathname", + href = "data:/nospace", + newValue = "space ", + expected = mapOf("href" to "data:/space%20", "pathname" to "/space%20"), + ), + SetterCase( + component = "pathname", + href = "sc:/nospace", + newValue = "space ", + expected = mapOf("href" to "sc:/space%20", "pathname" to "/space%20"), + ), + SetterCase( + component = "pathname", + href = "http://example.net", + newValue = " ", + expected = mapOf("href" to "http://example.net/%20", "pathname" to "/%20"), + ), + SetterCase( + component = "pathname", + href = "http://example.net", + newValue = "\u0000", + expected = mapOf("href" to "http://example.net/%00", "pathname" to "/%00"), + ), + SetterCase( + component = "pathname", + href = "foo://path/to", + newValue = "/..", + expected = mapOf("href" to "foo://path/", "pathname" to "/"), + ), + SetterCase( + component = "port", + href = "http://example.net", + newValue = "8080", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "http://example.net:8080/", + "port" to "8080", + ), + ), + SetterCase( + component = "port", + href = "http://example.net:8080", + newValue = "", + expected = + mapOf( + "host" to "example.net", + "hostname" to "example.net", + "href" to "http://example.net/", + "port" to "", + ), + ), + SetterCase( + component = "port", + href = "http://example.net:8080", + newValue = "80", + expected = + mapOf( + "host" to "example.net", + "hostname" to "example.net", + "href" to "http://example.net/", + "port" to "", + ), + ), + SetterCase( + component = "port", + href = "https://example.net:4433", + newValue = "443", + expected = + mapOf( + "host" to "example.net", + "hostname" to "example.net", + "href" to "https://example.net/", + "port" to "", + ), + ), + SetterCase( + component = "port", + href = "https://example.net", + newValue = "80", + expected = + mapOf( + "host" to "example.net:80", + "hostname" to "example.net", + "href" to "https://example.net:80/", + "port" to "80", + ), + ), + SetterCase( + component = "port", + href = "http://example.net/path", + newValue = "8080/stuff", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "http://example.net:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "port", + href = "http://example.net/path", + newValue = "8080?stuff", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "http://example.net:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "port", + href = "http://example.net/path", + newValue = "8080#stuff", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "http://example.net:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "port", + href = "http://example.net/path", + newValue = "8080\\stuff", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "http://example.net:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "port", + href = "view-source+http://example.net/path", + newValue = "8080stuff2", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "view-source+http://example.net:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "port", + href = "http://example.net/path", + newValue = "8080stuff2", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "http://example.net:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "port", + href = "http://example.net/path", + newValue = "8080+2", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "http://example.net:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "port", + href = "http://example.net/path", + newValue = "65535", + expected = + mapOf( + "host" to "example.net:65535", + "hostname" to "example.net", + "href" to "http://example.net:65535/path", + "port" to "65535", + ), + ), + SetterCase( + component = "port", + href = "http://example.net:8080/path", + newValue = "65536", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "http://example.net:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "port", + href = "http://example.net:8080/path", + newValue = "randomstring", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "http://example.net:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "port", + href = "non-special://example.net:8080/path", + newValue = "65536", + expected = + mapOf( + "host" to "example.net:8080", + "hostname" to "example.net", + "href" to "non-special://example.net:8080/path", + "port" to "8080", + ), + ), + SetterCase( + component = "port", + href = "file://test/", + newValue = "12", + expected = mapOf("href" to "file://test/", "port" to ""), + ), + SetterCase( + component = "port", + href = "file://localhost/", + newValue = "12", + expected = mapOf("href" to "file:///", "port" to ""), + ), + SetterCase( + component = "port", + href = "non-base:value", + newValue = "12", + expected = mapOf("href" to "non-base:value", "port" to ""), + ), + SetterCase( + component = "port", + href = "sc:///", + newValue = "12", + expected = mapOf("href" to "sc:///", "port" to ""), + ), + SetterCase( + component = "port", + href = "sc://x/", + newValue = "12", + expected = mapOf("href" to "sc://x:12/", "port" to "12"), + ), + SetterCase( + component = "port", + href = "javascript://x/", + newValue = "12", + expected = mapOf("href" to "javascript://x:12/", "port" to "12"), + ), + SetterCase( + component = "port", + href = "https://domain.com:443", + newValue = "\u00098080", + expected = mapOf("port" to "8080"), + ), + SetterCase( + component = "port", + href = "wpt++://domain.com:443", + newValue = "\u00098080", + expected = mapOf("port" to "8080"), + ), + SetterCase( + component = "port", + href = "https://www.google.com:4343", + newValue = "4wpt", + expected = mapOf("port" to "4"), + ), + SetterCase( + component = "port", + href = "https://domain.com:3000", + newValue = "\u000a\u000980\u000a\u000980\u000a\u0009", + expected = mapOf("port" to "8080"), + ), + SetterCase( + component = "port", + href = "https://domain.com:3000", + newValue = "\u000a\u000a\u0009\u0009", + expected = mapOf("port" to "3000"), + ), + SetterCase( + component = "protocol", + href = "a://example.net", + newValue = "", + expected = mapOf("href" to "a://example.net", "protocol" to "a:"), + ), + SetterCase( + component = "protocol", + href = "a://example.net", + newValue = "b", + expected = mapOf("href" to "b://example.net", "protocol" to "b:"), + ), + SetterCase( + component = "protocol", + href = "javascript:alert(1)", + newValue = "defuse", + expected = mapOf("href" to "defuse:alert(1)", "protocol" to "defuse:"), + ), + SetterCase( + component = "protocol", + href = "a://example.net", + newValue = "B", + expected = mapOf("href" to "b://example.net", "protocol" to "b:"), + ), + SetterCase( + component = "protocol", + href = "a://example.net", + newValue = "\u00e9", + expected = mapOf("href" to "a://example.net", "protocol" to "a:"), + ), + SetterCase( + component = "protocol", + href = "a://example.net", + newValue = "0b", + expected = mapOf("href" to "a://example.net", "protocol" to "a:"), + ), + SetterCase( + component = "protocol", + href = "a://example.net", + newValue = "+b", + expected = mapOf("href" to "a://example.net", "protocol" to "a:"), + ), + SetterCase( + component = "protocol", + href = "a://example.net", + newValue = "bC0+-.", + expected = mapOf("href" to "bc0+-.://example.net", "protocol" to "bc0+-.:"), + ), + SetterCase( + component = "protocol", + href = "a://example.net", + newValue = "b,c", + expected = mapOf("href" to "a://example.net", "protocol" to "a:"), + ), + SetterCase( + component = "protocol", + href = "a://example.net", + newValue = "b\u00e9", + expected = mapOf("href" to "a://example.net", "protocol" to "a:"), + ), + SetterCase( + component = "protocol", + href = "http://test@example.net", + newValue = "file", + expected = mapOf("href" to "http://test@example.net/", "protocol" to "http:"), + ), + SetterCase( + component = "protocol", + href = "https://example.net:1234", + newValue = "file", + expected = mapOf("href" to "https://example.net:1234/", "protocol" to "https:"), + ), + SetterCase( + component = "protocol", + href = "wss://x:x@example.net:1234", + newValue = "file", + expected = mapOf("href" to "wss://x:x@example.net:1234/", "protocol" to "wss:"), + ), + SetterCase( + component = "protocol", + href = "file://localhost/", + newValue = "http", + expected = mapOf("href" to "file:///", "protocol" to "file:"), + ), + SetterCase( + component = "protocol", + href = "file:///test", + newValue = "https", + expected = mapOf("href" to "file:///test", "protocol" to "file:"), + ), + SetterCase( + component = "protocol", + href = "file:", + newValue = "wss", + expected = mapOf("href" to "file:///", "protocol" to "file:"), + ), + SetterCase( + component = "protocol", + href = "http://example.net", + newValue = "b", + expected = mapOf("href" to "http://example.net/", "protocol" to "http:"), + ), + SetterCase( + component = "protocol", + href = "file://hi/path", + newValue = "s", + expected = mapOf("href" to "file://hi/path", "protocol" to "file:"), + ), + SetterCase( + component = "protocol", + href = "https://example.net", + newValue = "s", + expected = mapOf("href" to "https://example.net/", "protocol" to "https:"), + ), + SetterCase( + component = "protocol", + href = "ftp://example.net", + newValue = "test", + expected = mapOf("href" to "ftp://example.net/", "protocol" to "ftp:"), + ), + SetterCase( + component = "protocol", + href = "mailto:me@example.net", + newValue = "http", + expected = mapOf("href" to "mailto:me@example.net", "protocol" to "mailto:"), + ), + SetterCase( + component = "protocol", + href = "ssh://me@example.net", + newValue = "http", + expected = mapOf("href" to "ssh://me@example.net", "protocol" to "ssh:"), + ), + SetterCase( + component = "protocol", + href = "ssh://me@example.net", + newValue = "https", + expected = mapOf("href" to "ssh://me@example.net", "protocol" to "ssh:"), + ), + SetterCase( + component = "protocol", + href = "ssh://me@example.net", + newValue = "file", + expected = mapOf("href" to "ssh://me@example.net", "protocol" to "ssh:"), + ), + SetterCase( + component = "protocol", + href = "ssh://example.net", + newValue = "file", + expected = mapOf("href" to "ssh://example.net", "protocol" to "ssh:"), + ), + SetterCase( + component = "protocol", + href = "nonsense:///test", + newValue = "https", + expected = mapOf("href" to "nonsense:///test", "protocol" to "nonsense:"), + ), + ) + + private fun part4(): List = + listOf( + SetterCase( + component = "protocol", + href = "http://example.net", + newValue = "https:foo : bar", + expected = mapOf("href" to "https://example.net/", "protocol" to "https:"), + ), + SetterCase( + component = "protocol", + href = "data:text/html,

Test", + newValue = "view-source+data:foo : bar", + expected = mapOf("href" to "view-source+data:text/html,

Test", "protocol" to "view-source+data:"), + ), + SetterCase( + component = "protocol", + href = "http://foo.com:443/", + newValue = "https", + expected = mapOf("href" to "https://foo.com/", "port" to "", "protocol" to "https:"), + ), + SetterCase( + component = "protocol", + href = "http://test/", + newValue = "h\u000d\u000att\u0009ps", + expected = mapOf("href" to "https://test/", "port" to "", "protocol" to "https:"), + ), + SetterCase( + component = "protocol", + href = "http://test/", + newValue = "https\u000d", + expected = mapOf("href" to "https://test/", "protocol" to "https:"), + ), + SetterCase( + component = "protocol", + href = "http://test/", + newValue = "https\u0000", + expected = mapOf("href" to "http://test/", "protocol" to "http:"), + ), + SetterCase( + component = "protocol", + href = "http://test/", + newValue = "https\u000c", + expected = mapOf("href" to "http://test/", "protocol" to "http:"), + ), + SetterCase( + component = "protocol", + href = "http://test/", + newValue = "https\u000e", + expected = mapOf("href" to "http://test/", "protocol" to "http:"), + ), + SetterCase( + component = "protocol", + href = "http://test/", + newValue = "https ", + expected = mapOf("href" to "http://test/", "protocol" to "http:"), + ), + SetterCase( + component = "search", + href = "https://example.net#nav", + newValue = "lang=fr", + expected = mapOf("href" to "https://example.net/?lang=fr#nav", "search" to "?lang=fr"), + ), + SetterCase( + component = "search", + href = "https://example.net?lang=en-US#nav", + newValue = "lang=fr", + expected = mapOf("href" to "https://example.net/?lang=fr#nav", "search" to "?lang=fr"), + ), + SetterCase( + component = "search", + href = "https://example.net?lang=en-US#nav", + newValue = "?lang=fr", + expected = mapOf("href" to "https://example.net/?lang=fr#nav", "search" to "?lang=fr"), + ), + SetterCase( + component = "search", + href = "https://example.net?lang=en-US#nav", + newValue = "??lang=fr", + expected = mapOf("href" to "https://example.net/??lang=fr#nav", "search" to "??lang=fr"), + ), + SetterCase( + component = "search", + href = "https://example.net?lang=en-US#nav", + newValue = "?", + expected = mapOf("href" to "https://example.net/?#nav", "search" to ""), + ), + SetterCase( + component = "search", + href = "https://example.net?lang=en-US#nav", + newValue = "", + expected = mapOf("href" to "https://example.net/#nav", "search" to ""), + ), + SetterCase( + component = "search", + href = "https://example.net?lang=en-US", + newValue = "", + expected = mapOf("href" to "https://example.net/", "search" to ""), + ), + SetterCase( + component = "search", + href = "https://example.net", + newValue = "", + expected = mapOf("href" to "https://example.net/", "search" to ""), + ), + SetterCase( + component = "search", + href = "a:/", + newValue = + "\u0000\u0001\u0009\u000a\u000d\u001f !\"#\$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~\u007f\u0080" + + "\u0081\u00c9\u00e9", + expected = + mapOf( + "href" to + "a:/?%00%01%1F%20!%22%23\$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_`az{|}~%7F%C2%80%C2%81%C3%89%C3" + + "%A9", + "search" to + "?%00%01%1F%20!%22%23\$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_`az{|}~%7F%C2%80%C2%81%C3%89%C3%A9", + ), + ), + SetterCase( + component = "search", + href = "http://example.net", + newValue = "%c3%89t\u00e9", + expected = mapOf("href" to "http://example.net/?%c3%89t%C3%A9", "search" to "?%c3%89t%C3%A9"), + ), + SetterCase( + component = "search", + href = "data:space ?query", + newValue = "", + expected = mapOf("href" to "data:space%20", "pathname" to "space%20", "search" to ""), + ), + SetterCase( + component = "search", + href = "sc:space ?query", + newValue = "", + expected = mapOf("href" to "sc:space%20", "pathname" to "space%20", "search" to ""), + ), + SetterCase( + component = "search", + href = "data:space ?query#fragment", + newValue = "", + expected = mapOf("href" to "data:space %20#fragment", "search" to ""), + ), + SetterCase( + component = "search", + href = "sc:space ?query#fragment", + newValue = "", + expected = mapOf("href" to "sc:space %20#fragment", "search" to ""), + ), + SetterCase( + component = "search", + href = "http://example.net", + newValue = " ", + expected = mapOf("href" to "http://example.net/?%20", "search" to "?%20"), + ), + SetterCase( + component = "search", + href = "http://example.net", + newValue = "\u0000", + expected = mapOf("href" to "http://example.net/?%00", "search" to "?%00"), + ), + SetterCase( + component = "username", + href = "file:///home/you/index.html", + newValue = "me", + expected = mapOf("href" to "file:///home/you/index.html", "username" to ""), + ), + SetterCase( + component = "username", + href = "unix:/run/foo.socket", + newValue = "me", + expected = mapOf("href" to "unix:/run/foo.socket", "username" to ""), + ), + SetterCase( + component = "username", + href = "mailto:you@example.net", + newValue = "me", + expected = mapOf("href" to "mailto:you@example.net", "username" to ""), + ), + SetterCase( + component = "username", + href = "javascript:alert(1)", + newValue = "wario", + expected = mapOf("href" to "javascript:alert(1)", "username" to ""), + ), + SetterCase( + component = "username", + href = "http://example.net", + newValue = "me", + expected = mapOf("href" to "http://me@example.net/", "username" to "me"), + ), + SetterCase( + component = "username", + href = "http://:secret@example.net", + newValue = "me", + expected = mapOf("href" to "http://me:secret@example.net/", "username" to "me"), + ), + SetterCase( + component = "username", + href = "http://me@example.net", + newValue = "", + expected = mapOf("href" to "http://example.net/", "username" to ""), + ), + SetterCase( + component = "username", + href = "http://me:secret@example.net", + newValue = "", + expected = mapOf("href" to "http://:secret@example.net/", "username" to ""), + ), + SetterCase( + component = "username", + href = "http://example.net", + newValue = + "\u0000\u0001\u0009\u000a\u000d\u001f !\"#\$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~\u007f\u0080" + + "\u0081\u00c9\u00e9", + expected = + mapOf( + "href" to + "http://%00%01%09%0A%0D%1F%20!%22%23\$%&'()*+,-.%2F09%3A%3B%3C%3D%3E%3F%40AZ%5B%5C%5D%5E_" + + "%60az%7B%7C%7D~%7F%C2%80%C2%81%C3%89%C3%A9@example.net/", + "username" to + "%00%01%09%0A%0D%1F%20!%22%23\$%&'()*+,-.%2F09%3A%3B%3C%3D%3E%3F%40AZ%5B%5C%5D%5E_%60az%7" + + "B%7C%7D~%7F%C2%80%C2%81%C3%89%C3%A9", + ), + ), + SetterCase( + component = "username", + href = "http://example.net", + newValue = "%c3%89t\u00e9", + expected = mapOf("href" to "http://%c3%89t%C3%A9@example.net/", "username" to "%c3%89t%C3%A9"), + ), + SetterCase( + component = "username", + href = "sc:///", + newValue = "x", + expected = mapOf("href" to "sc:///", "username" to ""), + ), + SetterCase( + component = "username", + href = "javascript://x/", + newValue = "wario", + expected = mapOf("href" to "javascript://wario@x/", "username" to "wario"), + ), + SetterCase( + component = "username", + href = "file://test/", + newValue = "test", + expected = mapOf("href" to "file://test/", "username" to ""), + ), + ) +} + +/** Every WPT `setters_tests.json` case, flattened across components. */ +internal val SETTER_TEST_CASES: List = SetterCaseData.all() diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt new file mode 100644 index 0000000..61df34d --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.parser + +import org.dexpace.kuri.error.ParseResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class StateOverrideParseTest { + private fun seed(input: String): ParsedComponents = (UrlParser.parse(input) as ParseResult.Ok).value + + @Test + fun `pathname override replaces the path and preserves other components`() { + val result = UrlParser.parseWithOverride("/new/path", seed("https://h/old?q#f"), StateOverride.PATHNAME) + assertTrue(result is ParseResult.Ok) + val c = result.value + assertEquals("https", c.scheme) + assertEquals(listOf("new", "path"), (c.path as UrlPath.Segments).segments) + assertEquals("q", c.query) + assertEquals("f", c.fragment) + } + + @Test + fun `host override sets host and port from the value`() { + val result = UrlParser.parseWithOverride("example.com:81", seed("http://h:80/"), StateOverride.HOST) + val c = (result as ParseResult.Ok).value + assertEquals("example.com", c.host?.asText()) + assertEquals(81, c.port) + } + + @Test + fun `hostname override with an embedded colon is a total no-op`() { + // WHATWG host state, `:` branch: "If state override is given and state override is + // hostname state, then return" -- BEFORE the buffer is parsed as a host at all, so neither + // the host nor the port changes (WPT setters_tests.json exercises this exact shape: e.g. + // hostname "example.com:8080" on "http://example.net/path" leaves host "example.net"). + val result = UrlParser.parseWithOverride("example.com:81", seed("http://h:90/"), StateOverride.HOSTNAME) + val c = (result as ParseResult.Ok).value + assertEquals("h", c.host?.asText()) + assertEquals(90, c.port) + } + + @Test + fun `port override replaces the port`() { + val c = (UrlParser.parseWithOverride("8080", seed("http://h/"), StateOverride.PORT) as ParseResult.Ok).value + assertEquals(8080, c.port) + } +} diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlFuzzTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlFuzzTest.kt new file mode 100644 index 0000000..4365e3c --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlFuzzTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.parser + +import org.dexpace.kuri.Url +import org.dexpace.kuri.error.ParseResult +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class UrlFuzzTest { + private val alphabet: String = "abc019:/?#@[]%.\\ \t\n\r&=+-_~<>^`{}|!$(),;*'\"" + + private fun randomInput(random: Random): String { + val length = random.nextInt(0, MAX_LEN) + return buildString(length) { repeat(length) { append(alphabet[random.nextInt(alphabet.length)]) } } + } + + @Test + fun `parsing arbitrary input never throws and success round-trips`() { + val random = Random(SEED) + repeat(ITERATIONS) { + val input = randomInput(random) + when (val result = Url.parse(input)) { + is ParseResult.Ok -> { + val href = result.value.href + assertEquals(href, Url.parseOrThrow(href).href, "fuzz href not idempotent for input <$input>") + } + is ParseResult.Err -> Unit // a rejected input is a valid outcome + } + } + } + + private companion object { + private const val SEED: Long = 0x5EEDL + private const val ITERATIONS: Int = 20_000 + private const val MAX_LEN: Int = 24 + } +} diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlSetterConformanceTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlSetterConformanceTest.kt new file mode 100644 index 0000000..7f1d8e5 Binary files /dev/null and b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlSetterConformanceTest.kt differ diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingConformanceTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingConformanceTest.kt new file mode 100644 index 0000000..3674604 --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingConformanceTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.kuri.percent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Runs every WPT `percent-encoding.json` case carrying a `utf-8` expectation (modelled as + * [PERCENT_ENCODING_TEST_CASES]) through [PercentCodec.encode] under [PercentEncodeSets.C0_CONTROL] + * and ratchets a tracked known-failures baseline (SPEC §5.2, [PCT-1]). + * + * The corpus's `utf-8` expectations correspond to the C0-control percent-encode behavior over + * UTF-8 bytes: only C0 controls, DEL, and non-ASCII code points are ever encoded, and the + * trailing-`A` cases exist precisely to keep an adjacent C0 control from swallowing the following + * ASCII text. There is no residual divergence: [KNOWN_FAILURES] is empty and the suite is at full + * conformance. It still ratchets in both directions -- a brand-new failure breaks the + * untracked-regressions test, and a regression that repopulates the live set breaks the + * baseline-equality test until the baseline is updated. + * + * Per-component percent-encode-set membership (query/path/userinfo/fragment sets) is validated + * by the URL and setter conformance suites; this suite validates the charset -> UTF-8-byte + * percent-encoding mapping over the WPT percent-encoding corpus. + */ +class PercentEncodingConformanceTest { + /** True when encoding [case]'s input under the C0-control set reproduces its `utf-8` expectation. */ + private fun caseMatches(case: PercentEncodingCase): Boolean = + PercentCodec.encode(case.input, PercentEncodeSets.C0_CONTROL) == case.utf8 + + /** The inputs of every case the live codec currently does not satisfy. */ + private fun liveFailingInputs(): Set = + PERCENT_ENCODING_TEST_CASES.filterNot { caseMatches(it) }.map { it.input }.toSet() + + @Test + fun `every percent-encoding case outside the known-failures set matches WPT`() { + val untracked = liveFailingInputs() - KNOWN_FAILURES + assertTrue(untracked.isEmpty(), "new percent-encoding regressions (untracked failures): $untracked") + } + + @Test + fun `the known-failures set exactly equals the live failing set`() { + // Ratchet: a fixed gap (tracked failure now passing) or a brand-new failure breaks this + // until the baseline is regenerated, so the deferred debt can never drift silently. + assertEquals(KNOWN_FAILURES, liveFailingInputs()) + } + + @Test + fun `every tracked known failure is a real corpus case`() { + val inputs = PERCENT_ENCODING_TEST_CASES.map { it.input }.toSet() + val orphans = KNOWN_FAILURES - inputs + assertTrue(orphans.isEmpty(), "known failures absent from the corpus: $orphans") + } + + @Test + fun `the corpus is non-empty and fully conformant`() { + assertTrue(PERCENT_ENCODING_TEST_CASES.isNotEmpty(), "the WPT corpus should not be empty") + val failing = liveFailingInputs() + assertTrue(failing.isEmpty(), "every WPT case must encode per spec; failing: $failing") + } + + private companion object { + /** + * The tracked baseline of currently-failing case inputs. It is empty: the live UTF-8 percent + * codec satisfies every in-scope (utf-8-carrying) WPT `percent-encoding.json` case under the + * C0-control set. The ratcheting `the known-failures set exactly equals the live failing set` + * test pins the suite at full conformance: any regression repopulates the live set and breaks + * the build until this baseline is updated. + */ + private val KNOWN_FAILURES: Set = emptySet() + } +} diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingTestData.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingTestData.kt new file mode 100644 index 0000000..48f4212 --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingTestData.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ + +// Generated bulk data, not hand-written logic: the chunked builders intentionally exceed +// detekt's method/class-size heuristics to stay within the 64 KB JVM method limit. +@file:Suppress("LongMethod", "LargeClass", "MatchingDeclarationName") + +package org.dexpace.kuri.percent + +/** + * One WPT `percent-encoding.json` case carrying a `utf-8` expectation (the only charset in + * scope for kuri). Percent-encoding [input] under the C0-control set over UTF-8 bytes must + * equal [utf8]. + */ +internal data class PercentEncodingCase( + val input: String, + val utf8: String, +) + +// Generated by ./gradlew generatePercentEncodingTestData from the WPT percent-encoding corpus. +// Chunked into small builders so no single method exceeds the 64 KB JVM limit. +private object PercentEncodingCaseData { + fun all(): List = part0() + + private fun part0(): List = + listOf( + PercentEncodingCase( + input = "\u2020", + utf8 = "%E2%80%A0", + ), + PercentEncodingCase( + input = "\u000eA", + utf8 = "%0EA", + ), + PercentEncodingCase( + input = "\u203e\\", + utf8 = "%E2%80%BE\\", + ), + PercentEncodingCase( + input = "\ue5e5", + utf8 = "%EE%97%A5", + ), + PercentEncodingCase( + input = "\u2212", + utf8 = "%E2%88%92", + ), + PercentEncodingCase( + input = "\u00a2", + utf8 = "%C2%A2", + ), + PercentEncodingCase( + input = "\u00e1|", + utf8 = "%C3%A1|", + ), + ) +} + +/** Every WPT `percent-encoding.json` case carrying a `utf-8` expectation. */ +internal val PERCENT_ENCODING_TEST_CASES: List = PercentEncodingCaseData.all() diff --git a/kuri/src/jvmTest/kotlin/org/dexpace/kuri/JavaNetDifferentialTest.kt b/kuri/src/jvmTest/kotlin/org/dexpace/kuri/JavaNetDifferentialTest.kt new file mode 100644 index 0000000..09a7247 --- /dev/null +++ b/kuri/src/jvmTest/kotlin/org/dexpace/kuri/JavaNetDifferentialTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri + +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Differentially checks kuri (WHATWG URL) against `java.net.URI` (RFC 3986) on a small, + * hand-curated input set. Where the two specs agree, [agreeing] asserts kuri and `java.net.URI` + * produce the same scheme/host/path. Where they intentionally diverge, [divergences] documents + * kuri's REQUIRED WHATWG output with a one-line rationale per row — this is the honesty layer: + * it documents difference, it does not paper over it. + */ +class JavaNetDifferentialTest { + /** Inputs where kuri (WHATWG) and java.net (RFC 3986) MUST agree on scheme + host + path. */ + private val agreeing: List = + listOf( + "http://example.com/a/b", + "https://user@host.example/p?q=1", + "ftp://ftp.example.org/dir/file.txt", + ) + + /** + * Inputs where the specs intentionally diverge; the value is kuri's REQUIRED WHATWG output, + * with a one-line rationale. This table is the honesty layer — it documents difference, it + * does not paper over it. + */ + private val divergences: Map = + mapOf( + // WHATWG lower-cases ASCII hosts for special schemes; java.net preserves the source case. + "http://EXAMPLE.com/" to "http://example.com/", + // WHATWG collapses backslashes to slashes for special schemes; RFC 3986 has no such rule. + "http://h/a\\b" to "http://h/a/b", + ) + + @Test + fun `kuri and java-net agree on scheme host and path for aligned inputs`() { + agreeing.forEach { input -> + val kuri = Url.parseOrThrow(input) + val uri = URI(input) + assertEquals(uri.scheme, kuri.scheme, "scheme mismatch for $input") + assertEquals(uri.host, kuri.hostName, "host mismatch for $input") + assertEquals(uri.rawPath, kuri.encodedPath, "path mismatch for $input") + } + } + + @Test + fun `kuri produces the documented WHATWG value where the specs diverge`() { + divergences.forEach { (input, expected) -> + assertEquals(expected, Url.parseOrThrow(input).href, "divergence drift for $input") + } + } +} diff --git a/tools/internal/codegen/main.go b/tools/internal/codegen/main.go index 6673624..51dad6f 100644 --- a/tools/internal/codegen/main.go +++ b/tools/internal/codegen/main.go @@ -17,7 +17,7 @@ import ( // usage describes the command line for error messages. const usage = "usage: codegen [--stdout]\n" + - " names: url, idna-mapping, idna-validity, nfc-tables, nfc-test, conformance\n" + + " names: url, idna-mapping, idna-validity, nfc-tables, nfc-test, conformance, setters, percent-encoding\n" + " conformance also accepts --stdout-data / --stdout-known to print one of its two files" // singleGenerator pairs a single-file generator's source builder with a resolver @@ -51,6 +51,14 @@ var singleGenerators = map[string]singleGenerator{ generate: generateNfcTest, output: outputPath("kuri", "src", "commonTest", "kotlin", "org", "dexpace", "kuri", "idna", "NfcTestData.kt"), }, + "setters": { + generate: generateSetters, + output: outputPath("kuri", "src", "commonTest", "kotlin", "org", "dexpace", "kuri", "parser", "SetterTestData.kt"), + }, + "percent-encoding": { + generate: generatePercentEncoding, + output: outputPath("kuri", "src", "commonTest", "kotlin", "org", "dexpace", "kuri", "percent", "PercentEncodingTestData.kt"), + }, } // Main dispatches to the named generator. The generator name is the first diff --git a/tools/internal/codegen/percentencoding.go b/tools/internal/codegen/percentencoding.go new file mode 100644 index 0000000..cdf7057 --- /dev/null +++ b/tools/internal/codegen/percentencoding.go @@ -0,0 +1,174 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah +// SPDX-License-Identifier: MIT + +// The percent-encoding generator reads the vendored WPT percent-encoding.json corpus and +// materializes the Kotlin PercentEncodingTestData fixture. Each corpus entry pins one +// percent-encoding expectation across several charsets; only the utf-8 expectation is emitted, +// since UTF-8 is kuri's sole charset. Like setters.go, entries carry no lone-surrogate inputs, +// so cases decode straight into Go strings rather than through url.go's lossless UTF-16 path. +package codegen + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "unicode/utf16" +) + +// percentChunkSize is the number of cases per generated builder method, kept well under the +// 64 KB JVM method limit so no single method overflows. +const percentChunkSize = 60 + +// percentCase is one in-scope corpus entry: percent-encoding input under the C0-control set +// over UTF-8 bytes must equal utf8. +type percentCase struct { + input string + utf8 string +} + +// percentEntry mirrors one array element of percent-encoding.json: {input, output: {utf-8, ...}}. +// Charsets other than utf-8 (big5, shift_jis, ...) are intentionally not modelled; kuri is +// UTF-8-only, so a case whose only expectations are non-UTF-8 charsets is out of scope. +type percentEntry struct { + Input *string `json:"input"` + Output map[string]string `json:"output"` +} + +// percentInputPath returns the absolute path of the vendored WPT corpus. +func percentInputPath() (string, error) { + root, err := Root() + if err != nil { + return "", err + } + return filepath.Join(root, ".claude", "references", "ada", "tests", "wpt", "percent-encoding.json"), nil +} + +// generatePercentEncoding reads the corpus and returns the complete Kotlin source string. +func generatePercentEncoding() (string, error) { + path, err := percentInputPath() + if err != nil { + return "", err + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + cases, err := percentLoadCases(data) + if err != nil { + return "", err + } + return percentEmitFixture(cases), nil +} + +// percentLoadCases decodes the top-level JSON array and keeps only objects carrying both an +// `input` key and a `utf-8` output expectation, preserving document order with no sorting or +// deduplication. Documentation strings (skipped via firstNonSpace, as url.go does) and objects +// lacking a utf-8 expectation are dropped. +func percentLoadCases(data []byte) ([]percentCase, error) { + var elements []json.RawMessage + if err := json.Unmarshal(data, &elements); err != nil { + return nil, fmt.Errorf("percent-encoding: decoding corpus array: %w", err) + } + cases := make([]percentCase, 0, len(elements)) + for index, element := range elements { + if firstNonSpace(element) != '{' { + continue // documentation string + } + var entry percentEntry + if err := json.Unmarshal(element, &entry); err != nil { + return nil, fmt.Errorf("percent-encoding: decoding object at index %d: %w", index, err) + } + if entry.Input == nil { + continue + } + utf8, ok := entry.Output["utf-8"] + if !ok { + fmt.Fprintf(os.Stderr, "percent-encoding: skipping entry %q (no utf-8 output)\n", *entry.Input) + continue // only UTF-8 is in scope + } + cases = append(cases, percentCase{input: *entry.Input, utf8: utf8}) + } + return cases, nil +} + +// percentEmitFixture renders the complete fixture: license header, suppress block, package, +// data class, and the chunked builder object, terminated by exactly one newline. The structure +// mirrors urlEmitFixture and settersEmitFixture. +func percentEmitFixture(cases []percentCase) string { + parts := Chunk(cases, percentChunkSize) + lines := []string{LicenseHeader, ""} + lines = append(lines, FileSuppressBlock(bulkDataComments, "LongMethod", "LargeClass", "MatchingDeclarationName")...) + lines = append(lines, + "package org.dexpace.kuri.percent", + "", + ) + lines = append(lines, percentDataClassLines()...) + lines = append(lines, + "", + "// Generated by ./gradlew generatePercentEncodingTestData from the WPT percent-encoding corpus.", + "// Chunked into small builders so no single method exceeds the 64 KB JVM limit.", + "private object PercentEncodingCaseData {", + ) + lines = append(lines, allAccessorLines(parts)...) + for index, part := range parts { + lines = append(lines, "") + lines = append(lines, fmt.Sprintf(" private fun part%d(): List =", index)) + lines = append(lines, " listOf(") + for _, current := range part { + lines = append(lines, percentCaseLines(current)...) + } + lines = append(lines, " )") + } + lines = append(lines, + "}", + "", + "/** Every WPT `percent-encoding.json` case carrying a `utf-8` expectation. */", + "internal val PERCENT_ENCODING_TEST_CASES: List = PercentEncodingCaseData.all()", + ) + return JoinLines(lines) +} + +// allAccessorLines renders the `fun all(): List = ...` accessor. With a +// single chunk (the whole corpus is a handful of cases, unlike url.go's/setters.go's larger +// corpora) PartSumExpression collapses to one term, `part0()`, and ktlint's function-signature +// rule then requires that single-line body to join the signature rather than wrap to its own +// line; with more than one chunk the body spans multiple lines and must wrap regardless. +func allAccessorLines(parts [][]percentCase) []string { + signature := " fun all(): List =" + body := PartSumExpression(len(parts)) + if !strings.Contains(body, "\n") { + combined := signature + " " + body + if len(combined) <= MaxCols { + return []string{combined} + } + } + return []string{signature, " " + body} +} + +// percentCaseLines renders one PercentEncodingCase constructor call as indented named +// arguments, reusing FieldLines so an unusually long input/utf8 pair wraps the same way +// url.go's UrlCase fields do. +func percentCaseLines(c percentCase) []string { + lines := []string{" PercentEncodingCase("} + lines = append(lines, FieldLines("input", utf16.Encode([]rune(c.input)), fieldIndent)...) + lines = append(lines, FieldLines("utf8", utf16.Encode([]rune(c.utf8)), fieldIndent)...) + lines = append(lines, " ),") + return lines +} + +// percentDataClassLines is the fixed KDoc + PercentEncodingCase data-class declaration. +func percentDataClassLines() []string { + return []string{ + "/**", + " * One WPT `percent-encoding.json` case carrying a `utf-8` expectation (the only charset in", + " * scope for kuri). Percent-encoding [input] under the C0-control set over UTF-8 bytes must", + " * equal [utf8].", + " */", + "internal data class PercentEncodingCase(", + " val input: String,", + " val utf8: String,", + ")", + } +} diff --git a/tools/internal/codegen/setters.go b/tools/internal/codegen/setters.go new file mode 100644 index 0000000..de14b3e --- /dev/null +++ b/tools/internal/codegen/setters.go @@ -0,0 +1,240 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah +// SPDX-License-Identifier: MIT + +// The setters generator reads the vendored WPT setters_tests.json corpus and +// materializes the Kotlin SetterTestData fixture. Each corpus entry pins one URL +// setter invocation: parse `href`, assign `newValue` through the named component +// setter, then assert every getter listed in `expected` afterward. Unlike +// urltestdata.json, this corpus carries no lone-surrogate inputs, so cases decode +// straight into Go strings rather than through the lossless UTF-16 path url.go +// needs; the values still round-trip through UTF-16 code units when a line needs +// wrapping, reusing the same column-aware helpers. +package codegen + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "unicode/utf16" +) + +// settersChunkSize is the number of cases per generated builder method, kept well +// under the 64 KB JVM method limit so no single method overflows. +const settersChunkSize = 60 + +// setterCase is one in-scope corpus entry: assigning newValue through component's +// setter on the URL parsed from href must leave every getter named in expected +// equal to its mapped string. expected is unordered — the Task 10 assertion +// checks each entry independently, so key order carries no meaning here. +type setterCase struct { + component string + href string + newValue string + expected map[string]string +} + +// setterEntry mirrors one array element of setters_tests.json: {href, new_value, +// expected}. comment is intentionally not modelled; it is documentation only and +// never reaches the generated fixture. +type setterEntry struct { + Href string `json:"href"` + NewValue string `json:"new_value"` + Expected map[string]string `json:"expected"` +} + +// settersInputPath returns the absolute path of the vendored WPT corpus. +func settersInputPath() (string, error) { + root, err := Root() + if err != nil { + return "", err + } + return filepath.Join(root, ".claude", "references", "ada", "tests", "wpt", "setters_tests.json"), nil +} + +// generateSetters reads the corpus and returns the complete Kotlin source string. +func generateSetters() (string, error) { + path, err := settersInputPath() + if err != nil { + return "", err + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + cases, err := settersLoadCases(data) + if err != nil { + return "", err + } + return settersEmitFixture(cases), nil +} + +// settersLoadCases decodes the top-level JSON object and flattens it into one +// case list. The object's keys are component names (`protocol`, ..., `href`) +// plus a documentation-only `comment` key, which is skipped; components are +// visited in sorted order for deterministic output, while each component's +// entries keep their original corpus order (no sort, no dedup). +func settersLoadCases(data []byte) ([]setterCase, error) { + var byComponent map[string]json.RawMessage + if err := json.Unmarshal(data, &byComponent); err != nil { + return nil, fmt.Errorf("setters: decoding corpus object: %w", err) + } + components := make([]string, 0, len(byComponent)) + for name := range byComponent { + if name == "comment" { + continue + } + components = append(components, name) + } + sort.Strings(components) + var cases []setterCase + for _, name := range components { + var entries []setterEntry + if err := json.Unmarshal(byComponent[name], &entries); err != nil { + return nil, fmt.Errorf("setters: decoding %s entries: %w", name, err) + } + for index, entry := range entries { + if entry.Expected == nil { + return nil, fmt.Errorf("setters: %s entry %d has no expected object", name, index) + } + cases = append(cases, setterCase{ + component: name, + href: entry.Href, + newValue: entry.NewValue, + expected: entry.Expected, + }) + } + } + return cases, nil +} + +// settersEmitFixture renders the complete fixture: license header, suppress +// block, package, data class, and the chunked builder object, terminated by +// exactly one newline. The structure mirrors urlEmitFixture. +func settersEmitFixture(cases []setterCase) string { + parts := Chunk(cases, settersChunkSize) + lines := []string{LicenseHeader, ""} + lines = append(lines, FileSuppressBlock(bulkDataComments, "LongMethod", "LargeClass", "MatchingDeclarationName")...) + lines = append(lines, + "package org.dexpace.kuri.parser", + "", + ) + lines = append(lines, settersDataClassLines()...) + lines = append(lines, + "", + "// Generated by ./gradlew generateSetterTestData from the WPT setters_tests corpus.", + "// Chunked into small builders so no single method exceeds the 64 KB JVM limit.", + "private object SetterCaseData {", + ) + lines = append(lines, " fun all(): List =") + lines = append(lines, " "+PartSumExpression(len(parts))) + for index, part := range parts { + lines = append(lines, "") + lines = append(lines, fmt.Sprintf(" private fun part%d(): List =", index)) + lines = append(lines, " listOf(") + for _, current := range part { + lines = append(lines, setterCaseLines(current)...) + } + lines = append(lines, " )") + } + lines = append(lines, + "}", + "", + "/** Every WPT `setters_tests.json` case, flattened across components. */", + "internal val SETTER_TEST_CASES: List = SetterCaseData.all()", + ) + return JoinLines(lines) +} + +// setterCaseLines renders one SetterCase constructor call as indented named +// arguments, reusing FieldLines for the three string fields so an unusually +// long href/newValue (the corpus has entries whose escaped form exceeds 100 +// columns) wraps the same way url.go's UrlCase fields do. +func setterCaseLines(c setterCase) []string { + lines := []string{" SetterCase("} + lines = append(lines, FieldLines("component", utf16.Encode([]rune(c.component)), fieldIndent)...) + lines = append(lines, FieldLines("href", utf16.Encode([]rune(c.href)), fieldIndent)...) + lines = append(lines, FieldLines("newValue", utf16.Encode([]rune(c.newValue)), fieldIndent)...) + lines = append(lines, expectedFieldLines(c.expected, fieldIndent)...) + lines = append(lines, " ),") + return lines +} + +// expectedFieldLines renders the `expected = mapOf(...)` field, keys sorted for +// deterministic output. The whole map collapses onto one line when it fits +// within MaxCols (the common case: most entries carry one or two getters); +// otherwise it wraps to one key per line via expectedEntryLines, which itself +// wraps an individual over-long value (the corpus has `expected` values whose +// escaped form alone exceeds 120 columns). +func expectedFieldLines(expected map[string]string, indent int) []string { + keys := make([]string, 0, len(expected)) + for key := range expected { + keys = append(keys, key) + } + sort.Strings(keys) + pad := strings.Repeat(" ", indent) + pairs := make([]string, len(keys)) + for i, key := range keys { + pairs[i] = KotlinString(key) + " to " + KotlinString(expected[key]) + } + single := pad + "expected = mapOf(" + strings.Join(pairs, ", ") + ")," + if len(single) <= MaxCols { + return []string{single} + } + // ktlint requires a multiline call on the right of an `=` to start on its own + // line, so "mapOf(" cannot trail "expected =" on the same line here. + mapPad := strings.Repeat(" ", indent+4) + lines := []string{pad + "expected =", mapPad + "mapOf("} + for _, key := range keys { + lines = append(lines, expectedEntryLines(key, expected[key], indent+8)...) + } + lines = append(lines, mapPad+"),") + return lines +} + +// expectedEntryLines renders one `"key" to "value",` line inside a wrapped +// mapOf(...) call. When even that single line would exceed MaxCols, the value +// itself wraps across `"..." +` continuation segments at a single continuation +// indent (unlike FieldLinesIndented's two-tier first/rest split), matching what +// ktlint accepts for a continuation nested this deep inside a `to` infix call. +func expectedEntryLines(key, value string, indent int) []string { + pad := strings.Repeat(" ", indent) + single := pad + KotlinString(key) + " to " + KotlinString(value) + "," + if len(single) <= MaxCols { + return []string{single} + } + units := utf16.Encode([]rune(value)) + contIndent := indent + 4 + budget := MaxCols - contIndent - len(" +") + segments := packSegments(EscapeTokensUTF16(units), budget) + contPad := strings.Repeat(" ", contIndent) + lines := make([]string, 0, len(segments)+1) + lines = append(lines, pad+KotlinString(key)+" to") + for index, segment := range segments { + tail := " +" + if index == len(segments)-1 { + tail = "," + } + lines = append(lines, contPad+`"`+segment+`"`+tail) + } + return lines +} + +// settersDataClassLines is the fixed KDoc + SetterCase data-class declaration. +func settersDataClassLines() []string { + return []string{ + "/**", + " * One WPT `setters_tests.json` case. Assigning [newValue] through the [component] setter on", + " * the URL parsed from [href] must leave every getter named in [expected] (keyed by getter name,", + " * e.g. `href`, `protocol`, `hostname`) equal to its mapped string; [expected] is unordered.", + " */", + "internal data class SetterCase(", + " val component: String,", + " val href: String,", + " val newValue: String,", + " val expected: Map,", + ")", + } +}