From 6a5f7c83aadb759994c94cd8e29c1820fc96b104 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 22:41:57 +0300 Subject: [PATCH 01/19] feat: add state-override entry point to the URL parser --- .../org/dexpace/kuri/parser/StateOverride.kt | 30 ++++++ .../org/dexpace/kuri/parser/UrlParser.kt | 100 ++++++++++++++++++ .../org/dexpace/kuri/parser/UrlParserState.kt | 32 ++++++ .../org/dexpace/kuri/parser/UrlTransition.kt | 14 +++ .../kuri/parser/StateOverrideParseTest.kt | 24 +++++ 5 files changed, 200 insertions(+) create mode 100644 kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/StateOverride.kt create mode 100644 kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt 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..51667a4 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,104 @@ 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() + // 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. [UrlTransition.Advance]/[UrlTransition.Reconsume] terminate with + * [OverrideOutcome.OK] at EOF (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 + if (state.pos >= state.input.length && atComponentEnd(state)) OverrideOutcome.OK else null + } + is UrlTransition.Advance -> { + state.state = transition.next + when { + state.pos >= state.input.length -> OverrideOutcome.OK + else -> { + state.pos += 1 + null + } + } + } + } + + /** True when the override has finished its component and further states would over-run. */ + private fun atComponentEnd(state: UrlParserState): Boolean = + when (state.stateOverride) { + StateOverride.PATHNAME -> state.state == UrlState.PATH_START || state.state == UrlState.PATH + else -> true + } + /** The dispatch table mapping each [UrlState] to its single state function (§8.3). */ private val STATE_HANDLERS: Map UrlTransition> = mapOf( @@ -103,6 +201,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/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/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/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..c6f19da --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt @@ -0,0 +1,24 @@ +/* + * 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) + } +} From 74bf522e928f8385f217f040612fdc3daf073cc7 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 22:48:41 +0300 Subject: [PATCH 02/19] feat: honour host/hostname/port state overrides in the parser --- .../dexpace/kuri/parser/UrlParserAuthority.kt | 22 ++++++++++++++++-- .../kuri/parser/StateOverrideParseTest.kt | 23 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) 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..d6db67e 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) @@ -190,10 +193,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, @@ -254,7 +268,11 @@ internal object UrlParserAuthority { } state.port = elidedPort(state, value) state.pos = end - return UrlTransition.Reconsume(UrlState.PATH_START) + return if (state.stateOverride == StateOverride.PORT) { + 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]). */ diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt index c6f19da..aac01f5 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt @@ -20,5 +20,28 @@ class StateOverrideParseTest { 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 ignores a colon and keeps the existing port`() { + val result = UrlParser.parseWithOverride("example.com:81", seed("http://h:90/"), StateOverride.HOSTNAME) + val c = (result as ParseResult.Ok).value + assertEquals("example.com", 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) } } From f3684c24754985f80cf5ed5c0c9fe15fadc65be1 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 22:58:13 +0300 Subject: [PATCH 03/19] feat: add Url.withProtocol/withUsername/withPassword setters --- .../commonMain/kotlin/org/dexpace/kuri/Url.kt | 57 +++++++++++++++++++ .../dexpace/kuri/parser/UrlParserStates.kt | 28 +++++++++ .../kotlin/org/dexpace/kuri/UrlSetterTest.kt | 39 +++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index f851832..1ab43ea 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 @@ -403,6 +404,62 @@ 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 { + val trimmed = value.substringBefore(':') + if (!Scheme.isValidScheme(Scheme.normalize(trimmed))) return this + return applyOverride("$trimmed:", 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)) + } + + /** 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 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..3f4f7af 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 @@ -75,12 +76,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, 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..92b8190 --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt @@ -0,0 +1,39 @@ +/* + * 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) + } +} From 46ee644abd081c89dfd049105e284ec37c5d48fd Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 23:08:31 +0300 Subject: [PATCH 04/19] feat: add Url.withHost/withHostname setters --- .../commonMain/kotlin/org/dexpace/kuri/Url.kt | 19 +++++++++++++++++ .../kotlin/org/dexpace/kuri/UrlSetterTest.kt | 21 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index 1ab43ea..9dc3d57 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt @@ -444,6 +444,25 @@ public class Url internal constructor( 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. + */ + 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. + */ + 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 diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt index 92b8190..5f21fc5 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt @@ -36,4 +36,25 @@ class UrlSetterTest { 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) + } } From 05e2741936aecb4b0a7047a11393c728a8c4be3c Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 23:13:48 +0300 Subject: [PATCH 05/19] feat: add WHATWG string Url.withPort(String) setter Also fix the port state to end the digit run on any trailing character when a state override is in effect (WHATWG URL port state), not just an authority terminator, so the port setter accepts trailing junk (e.g. "8080stuff" -> port 8080) per spec. --- .../commonMain/kotlin/org/dexpace/kuri/Url.kt | 18 ++++++++++++++++ .../dexpace/kuri/parser/UrlParserAuthority.kt | 6 +++++- .../kotlin/org/dexpace/kuri/UrlSetterTest.kt | 21 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index 9dc3d57..14cd6e3 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt @@ -383,6 +383,24 @@ 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 { + if (!canHaveCredentials()) return this + return when { + value.isEmpty() -> Url(components.copy(port = null)) + !value[0].isDigit() -> this + else -> applyOverride(value, StateOverride.PORT) + } + } + /** * Returns a copy of this URL with its [fragment] set (or removed when `null`). * 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 d6db67e..d1c6352 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt @@ -249,7 +249,11 @@ internal object UrlParserAuthority { end: Int, terminator: Char?, ): UrlTransition { - val terminated = terminator == null || isAuthorityTerminator(state, terminator) + // Per the WHATWG port state, a state override ends the digit run on *any* trailing + // character — not just an authority terminator — since there is no following state to + // reconsume into ([PARSE-34]; e.g. the `port` setter's "8080stuff" keeps port 8080). + val terminated = + terminator == null || isAuthorityTerminator(state, terminator) || state.stateOverride == StateOverride.PORT if (!terminated) { return UrlTransition.Fail(UriParseError.InvalidPort(digits + terminator)) } diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt index 5f21fc5..671ae8f 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt @@ -57,4 +57,25 @@ class UrlSetterTest { 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) + } } From 911726e046dbc56b8e532d44b2d0f9f804e05b09 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 23:17:50 +0300 Subject: [PATCH 06/19] feat: add Url.withPathname setter --- kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt | 10 ++++++++++ .../kotlin/org/dexpace/kuri/UrlSetterTest.kt | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index 14cd6e3..43c854b 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt @@ -401,6 +401,16 @@ public class Url internal constructor( } } + /** + * 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. + */ + public fun withPathname(value: String): Url { + if (components.path is UrlPath.Opaque) return this + return applyOverride(value, StateOverride.PATHNAME) + } + /** * Returns a copy of this URL with its [fragment] set (or removed when `null`). * diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt index 671ae8f..109d286 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt @@ -78,4 +78,15 @@ class UrlSetterTest { 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) + } } From c623fd7325553af41a938cd58dc836d24c208002 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 23:22:13 +0300 Subject: [PATCH 07/19] feat: add Url.withSearch/withHash setters --- .../commonMain/kotlin/org/dexpace/kuri/Url.kt | 23 +++++++++++++++++ .../org/dexpace/kuri/parser/UrlParser.kt | 4 +++ .../kotlin/org/dexpace/kuri/UrlSetterTest.kt | 25 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index 43c854b..5172eec 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt @@ -411,6 +411,29 @@ public class Url internal constructor( 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. + */ + 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. + */ + public fun withHash(value: String): Url { + if (value.isEmpty()) return Url(components.copy(fragment = null)) + val stripped = if (value.startsWith('#')) value.substring(1) else value + 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`). * 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 51667a4..1a4c9a3 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParser.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParser.kt @@ -73,6 +73,10 @@ internal object UrlParser { // 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) { diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt index 109d286..e76e77c 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlSetterTest.kt @@ -89,4 +89,29 @@ class UrlSetterTest { 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) + } } From 1c2729eafe651471e644ce4a29dfecb87cd5b5e7 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 23:26:48 +0300 Subject: [PATCH 08/19] docs: complete KDoc tags on the Url setter family --- .../src/commonMain/kotlin/org/dexpace/kuri/Url.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index 5172eec..858968b 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt @@ -405,6 +405,9 @@ public class Url internal constructor( * 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 @@ -415,6 +418,9 @@ public class Url internal constructor( * 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)) @@ -426,6 +432,9 @@ public class Url internal constructor( * 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)) @@ -499,6 +508,9 @@ public class Url internal constructor( * 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 @@ -508,6 +520,9 @@ public class Url internal constructor( /** * 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 From 61fa34fc9c90072dc5698bfc3cade6c66748eda2 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 23:28:29 +0300 Subject: [PATCH 09/19] chore: update API snapshot for the Url setter family --- kuri/api/jvm/kuri.api | 9 +++++++++ 1 file changed, 9 insertions(+) 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; } From 1c1d9582fbe8be0e25df7603b65d314a295a3be3 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 23:31:55 +0300 Subject: [PATCH 10/19] chore: update Android API snapshot for the Url setter family --- kuri/api/android/kuri.api | 9 +++++++++ 1 file changed, 9 insertions(+) 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; } From 551152d6246d05c0bb1f1bd8fa9c61537d5fd998 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 23:42:01 +0300 Subject: [PATCH 11/19] chore: generate the WPT setters conformance fixture Add a Go generator (setters.go) that turns the vendored WPT setters_tests.json corpus into SetterTestData.kt, following the same chunked-builder shape as the existing url/nfc-test generators. Wires up a generateSetterTestData Gradle task alongside the other codegen tasks. This is fixture generation only; the test that consumes SETTER_TEST_CASES comes next. --- build.gradle.kts | 5 + .../org/dexpace/kuri/parser/SetterTestData.kt | 2193 +++++++++++++++++ tools/internal/codegen/main.go | 6 +- tools/internal/codegen/setters.go | 240 ++ 4 files changed, 2443 insertions(+), 1 deletion(-) create mode 100644 kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/SetterTestData.kt create mode 100644 tools/internal/codegen/setters.go diff --git a/build.gradle.kts b/build.gradle.kts index 0000481..86fca15 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,6 +49,11 @@ 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.", + ), ) val codegenTasks: List> = 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/tools/internal/codegen/main.go b/tools/internal/codegen/main.go index 6673624..7fcfae2 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\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,10 @@ 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"), + }, } // Main dispatches to the named generator. The generator name is the first 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,", + ")", + } +} From 66aef121a773028a72fadcbb9042e230bfcbcad1 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Thu, 9 Jul 2026 00:23:10 +0300 Subject: [PATCH 12/19] test: run the WPT setter conformance corpus against the Url setters Adds UrlSetterConformanceTest, driving all 278 WPT setters_tests.json cases against the Url.with* setter family with a ratcheted known-failures baseline (kuri architecture: URL setters live behind UrlParser.parseWithOverride, a shared engine with the full parser). Getting this corpus green surfaced a dozen real bugs in the setter override machinery, all fixed here: - withPort/withProtocol pre-validated the raw value before the engine's own tab/newline stripping, rejecting inputs that are valid once stripped (e.g. "\t8080", "h\r\ntt\tps"). - schemeStartState/schemeState didn't fail cleanly under a setter override, so an invalid protocol value could restart parsing instead of resolving to a no-op. - Port-state override handling only recognized StateOverride.PORT, so a host/hostname setter whose value carried a port (e.g. "example.com:8080/stuff") kept parsing past the port into the path, corrupting it; and an empty digit run wrongly cleared an existing port to null. - Host-state was missing two WHATWG no-op guards: a colon under a hostname override must return before ever touching the host, and clearing a host to empty must be refused when the URL already has credentials or a port. - FILE_HOST always fell through into path-start regardless of override, and the override loop's Reconsume handling terminated before ever invoking the target state at EOF, together breaking file: URL host clearing and root-path synthesis under override. - Path state treated '?' as always opening a query, even under a pathname-setter override where it must stay literal path text. - withHash bypassed the shared engine and so missed the mandatory tab/newline strip. - Serializer's NORM-18 "/." anti-authority guard (needed only to keep a full href re-parseable) was leaking into the standalone pathname getter. One known, out-of-scope residual remains and is recorded in KNOWN_FAILURES with justification: HostParser accepts the ACE label "xn--" (empty Punycode suffix) instead of rejecting it, verified as a pre-existing IDNA-validity gap independent of the setter path. Also corrects a pre-existing StateOverrideParseTest expectation that predates this corpus and turns out to be wrong per the WPT ground truth: a hostname setter value containing a colon is a total no-op, not a partial host change. --- .../commonMain/kotlin/org/dexpace/kuri/Url.kt | 28 ++++++-- .../org/dexpace/kuri/parser/UrlParser.kt | 17 ++--- .../dexpace/kuri/parser/UrlParserAuthority.kt | 68 +++++++++++++----- .../dexpace/kuri/parser/UrlParserHelpers.kt | 8 ++- .../dexpace/kuri/parser/UrlParserStates.kt | 33 +++++++-- .../org/dexpace/kuri/serialize/Serializer.kt | 26 +++++-- .../kuri/parser/StateOverrideParseTest.kt | 8 ++- .../kuri/parser/UrlSetterConformanceTest.kt | Bin 0 -> 6398 bytes 8 files changed, 138 insertions(+), 50 deletions(-) create mode 100644 kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlSetterConformanceTest.kt diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index 858968b..7dae591 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt @@ -220,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) } /** @@ -393,10 +397,12 @@ public class Url internal constructor( * @return the updated [Url], or `this` when the setter is a WHATWG no-op. */ public fun withPort(value: String): Url { - if (!canHaveCredentials()) return this + // 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)) - !value[0].isDigit() -> this else -> applyOverride(value, StateOverride.PORT) } } @@ -438,7 +444,12 @@ public class Url internal constructor( */ public fun withHash(value: String): Url { if (value.isEmpty()) return Url(components.copy(fragment = null)) - val stripped = if (value.startsWith('#')) value.substring(1) else value + 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)) } @@ -473,9 +484,12 @@ public class Url internal constructor( * @return the updated [Url], or `this` when the setter is a WHATWG no-op. */ public fun withProtocol(value: String): Url { - val trimmed = value.substringBefore(':') - if (!Scheme.isValidScheme(Scheme.normalize(trimmed))) return this - return applyOverride("$trimmed:", StateOverride.PROTOCOL) + // 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) } /** 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 1a4c9a3..9bd4090 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParser.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParser.kt @@ -123,8 +123,12 @@ internal object UrlParser { /** * Applies one [transition] under an override run, returning the terminal [OverrideOutcome] or - * `null` to continue the loop. [UrlTransition.Advance]/[UrlTransition.Reconsume] terminate with - * [OverrideOutcome.OK] at EOF (the natural end of a single-component value). + * `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, @@ -136,7 +140,7 @@ internal object UrlParser { is UrlTransition.Fail -> OverrideOutcome.Fail(transition.error) is UrlTransition.Reconsume -> { state.state = transition.next - if (state.pos >= state.input.length && atComponentEnd(state)) OverrideOutcome.OK else null + null } is UrlTransition.Advance -> { state.state = transition.next @@ -150,13 +154,6 @@ internal object UrlParser { } } - /** True when the override has finished its component and further states would over-run. */ - private fun atComponentEnd(state: UrlParserState): Boolean = - when (state.stateOverride) { - StateOverride.PATHNAME -> state.state == UrlState.PATH_START || state.state == UrlState.PATH - else -> true - } - /** The dispatch table mapping each [UrlState] to its single state function (§8.3). */ private val STATE_HANDLERS: Map UrlTransition> = mapOf( 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 d1c6352..17bb2f7 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt @@ -177,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, @@ -249,11 +265,13 @@ internal object UrlParserAuthority { end: Int, terminator: Char?, ): UrlTransition { - // Per the WHATWG port state, a state override ends the digit run on *any* trailing - // character — not just an authority terminator — since there is no following state to - // reconsume into ([PARSE-34]; e.g. the `port` setter's "8080stuff" keeps port 8080). + // 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 == StateOverride.PORT + terminator == null || isAuthorityTerminator(state, terminator) || state.stateOverride != null if (!terminated) { return UrlTransition.Fail(UriParseError.InvalidPort(digits + terminator)) } @@ -266,17 +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 if (state.stateOverride == StateOverride.PORT) { - UrlTransition.Done - } else { - 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]). */ @@ -438,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, @@ -466,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/UrlParserStates.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt index 3f4f7af..017f842 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt @@ -32,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() @@ -40,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) + } } /** @@ -48,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() @@ -58,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) } } @@ -373,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/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/parser/StateOverrideParseTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt index aac01f5..61df34d 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt @@ -32,10 +32,14 @@ class StateOverrideParseTest { } @Test - fun `hostname override ignores a colon and keeps the existing port`() { + 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("example.com", c.host?.asText()) + assertEquals("h", c.host?.asText()) assertEquals(90, c.port) } 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 0000000000000000000000000000000000000000..7f1d8e5fab9f2b1455bb6d99158c0c22d56d0d66 GIT binary patch literal 6398 zcmbtY>uwvl74C08#eo_iQdXBBK>I^zl6oCw<0fkxmfUn37#2N4NsLFsS&|c54FdEb z`VM`MK1!dY-yu1p8AHDxH(&L|A$K)Hc5k6Ai(0D16;-t*?w+T=&^F zY_JA_VJ`3gDzZ{ulggbYT&7zPhH74bAONUb%2BGUSl_B;DPT8p(A7aSNl?;Rx>s(cOGk;$GG&#{ zhvBu1`hxRV&aI;_BBS?{Uv8$8i{s1V+4<#|rp~ZsUQ*|Go!XF>Wp0T@-gr6ZK~Q_c z4`zp-t`2x$^SP0f8Y4`fSe1 zR_cqj$~NA(c&%h5p^YGCfHTm&%k_OeSO}HDM0RW6?#Fk>vyVT~)#V5JpuVsUQ-=o< zCZ+%U`wx9)$V>`p2KCEEmbS;n;oXTyRzVYw-Zmh@!;>hAOht=Fk#n(zXt$dd8fq@r z{xO*RL9q0_s(f0oM#0R>0EIvp0BPQYyUN7L^qRy{D1OJNTQotuD^gJ~dib`HBE24x zcZgEq{cFo&_g1?V&}2XqII;BKfBp*wzcsMl0Fb6=Y*yeFgzf^^$~->Fz{n*8XN+M7 zhzJS^+O9)D;qblCMVZxb&D0n8r*sXfo=0?$j>v2=^7VfLbd+(PIJc%LoU) zh8*^oR1uwNQNn{kaufJ=nJPS~jx4<~3-vk*z&F|PJh_*=6GG*=nZY;i{p}%Rt>>BW zaU&5Ba=Ks19AO*Ox525`pvp%(2oz4k>pL`f<#~7yyNIjdb`_|$9Q@T15tb3xzIqi` zAH7<@^7q=L?W-4Y_0g*+XYr0zardfpW8)Tee|l8 z!X&FUzo}n)^x8_;olEbq^7s`1#`9_5>8#AMol7(tQ4A9du?p@lU(y1H0knY!dyPEC zh0K6^fGNj`dgGh_@7t08!#;J15MwLaF$8@=+Px;dSkR}^WXPik8M#FS;P4C=<2nJ} zClXF=$jfYHJMvD8ymH9RIFNN~Awbl-7P4{tfMJ!x!XkXHN@?|>;72(6xbuMe&?5h= zCH+91ai_k|ygDss;iJ_;`&i2{_*k{jK2|#cAK@=&FvU=t$+~cxZVv)5aHr)0#X%Qm zc08AF)DS&PqRp>%11}OtzaBar4At#z`R-h5Is#vmd4hbOvMaPtHgJ#P1v1O@JnF*t zel1Z-^y%sAr@MMoz&~_rVZ?HcdJGqTzjv{MzgHe#TJlr~md0q)?UcE%A}FAAOiuuX z&Q_8Js5xQI$84-QS#Nxx*(agsM02+MJW}eT$fbFwGY^s4V;nvl3Nm7sKE zf(PNxDRP*qd^U<8c(!6(A@SRt#&F_g-KKOd;s6o0hoveh}^Hfp`>iU~Q zWjTsf;OX_K(+L5o^CXaX;?JR0Wv^t#xdjLh;BzvEnmW&;+TfCCqhy9)6-uqDoQjXg zxw(`FmE_F(voA@l534&l{zt7dDe^`_1}TJ8TV@Ug^=^gGqu1jqvoFxppgfJAnC^X| zucR9agy*l+1yuV%#2&|F9}feIJ~uf%LMZnd&|ZZ0)n90b>Jkc901y)`G)7<1D2DpP zIUKmxoM{4M)z zcxxu&-M6YeeZLhN4)kIM(FOHhs=dEDvdC0+u{o$1Q+SIu1o(On9$ofR>HZWHdxiJj z>wbm{m4V-xX4hp&Dl942gAcDofpxZ5_>OoiUAGt1`7>9r9w1z7&Vx1)&0Z<=dRC=D zxt$&f$7mg3%kA47=_HiG3^#^oUCKl1J+){+@6XOZp544XKK*cYIhocl4IMeVTeqeF ze~l@tPaVcgCF8ah2WIPj0kOBf8Y6IwzB>w|!`W^9SS|S=5`Hn?Dulkozbv;b?D#m- zRyW8TrU>1(Uj2RDLr(a=0jXt9@Kp^$$s}Fi|HO@&FMdEkpG)h9YiS>;C@&FZv(2E)Q9XH6 zAs-CA!kd+H0L}&cQum-~pSQr~zd0ZMhuNJCs)!evs(9gu>YdH&5~SWI~w$eyH=1 z4+b6TA_dR+-#HJ?AY`yE*I%Jrjg?##r#Uu?6XTiu1bFxiSk7`!5wP+YS`03M?Z%n{D zJO(2Xe72Li@HRa^dUPuUI{cizIXiAL2-*$2sywwxWdQ)h7LUthDpV1o1t-}2siO@- zc%pd}Iv98qSiP2@&&C6AyuxXd2P&1y9WZU Date: Thu, 9 Jul 2026 00:17:33 +0300 Subject: [PATCH 13/19] chore: generate the WPT percent-encoding conformance fixture Add a Go generator (percentencoding.go) that turns the vendored WPT percent-encoding.json corpus into PercentEncodingTestData.kt, following the same chunked-builder shape as the url/setters generators. Only the utf-8 expectation is emitted per case, since UTF-8 is kuri's sole charset; other charsets carried by the corpus (big5, shift_jis, ...) are out of scope. Wires up a generatePercentEncodingTestData Gradle task alongside the other codegen tasks. This is fixture generation only; the test that consumes PERCENT_ENCODING_TEST_CASES comes next. --- build.gradle.kts | 5 + .../kuri/percent/PercentEncodingTestData.kt | 61 ++++++ tools/internal/codegen/main.go | 6 +- tools/internal/codegen/percentencoding.go | 173 ++++++++++++++++++ 4 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingTestData.kt create mode 100644 tools/internal/codegen/percentencoding.go diff --git a/build.gradle.kts b/build.gradle.kts index 86fca15..db75bd6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,6 +54,11 @@ val generators: List> = "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/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/tools/internal/codegen/main.go b/tools/internal/codegen/main.go index 7fcfae2..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, setters\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 @@ -55,6 +55,10 @@ var singleGenerators = map[string]singleGenerator{ 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..17f6919 --- /dev/null +++ b/tools/internal/codegen/percentencoding.go @@ -0,0 +1,173 @@ +// 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 { + 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,", + ")", + } +} From 040e969e63fdc38ab65896bf39198935689ed88f Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Thu, 9 Jul 2026 00:17:38 +0300 Subject: [PATCH 14/19] test: run the WPT percent-encoding corpus against the percent codec Assert PercentCodec.encode(input, PercentEncodeSets.C0_CONTROL) reproduces every WPT percent-encoding.json utf-8 expectation, mirroring UrlConformanceTest's ratchet shape (untracked-regressions test plus a known-failures-equals-live-failures test). All 7 in-scope cases pass against the existing codec with no known failures. --- .../percent/PercentEncodingConformanceTest.kt | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingConformanceTest.kt 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..4e1c879 --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingConformanceTest.kt @@ -0,0 +1,71 @@ +/* + * 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. + */ +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() + } +} From 651f39de1c8aac4815f3133eaa6651c6ccae6398 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Thu, 9 Jul 2026 00:12:50 +0300 Subject: [PATCH 15/19] test: assert serialization idempotence and builder round-trip invariants --- .../kuri/parser/RoundTripInvariantTest.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/RoundTripInvariantTest.kt 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}") + } +} From 8ac6bff24dd99d28dd3ca4f5433b835043fa42c6 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Thu, 9 Jul 2026 00:13:49 +0300 Subject: [PATCH 16/19] test: add a seeded generative URL parser fuzzer --- .../org/dexpace/kuri/parser/UrlFuzzTest.kt | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlFuzzTest.kt 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..9d72df1 --- /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&=+-_~<>^`{}|!$(),;*'\"" + + 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 + } +} From d0c01b1c024e220fd329299af0b4161f988c8bcd Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Thu, 9 Jul 2026 00:13:20 +0300 Subject: [PATCH 17/19] test: add a java.net differential suite with a documented divergence table --- .../dexpace/kuri/JavaNetDifferentialTest.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 kuri/src/jvmTest/kotlin/org/dexpace/kuri/JavaNetDifferentialTest.kt 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") + } + } +} From a7b64ee96e0a93402e06f6ccf7aba50dd791a503 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Thu, 9 Jul 2026 00:31:15 +0300 Subject: [PATCH 18/19] fix: preserve the /. path prefix in Url.Builder recompose for authority-less // paths Url.newBuilder().build() threw IllegalArgumentException ("an authority-less path cannot begin with '//'") for authority-less non-special URLs whose path begins with an empty segment, e.g. non-spec:/.//path. The builder's pre-fill constructor sourced its path from the encodedPath getter, which correctly omits the NORM-18 /. anti-authority guard per the WHATWG standalone-pathname algorithm. But the builder's recompose concatenates that path directly onto scheme: with no authority, so the unguarded // re-parses as a spurious authority and the parser rejects it. Pre-fill instead from the guarded full-URL path serialization (serializeUrlPath's default), matching the canonical href and leaving the pathname getter unchanged. --- kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index 7dae591..b8b591b 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt @@ -684,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 } From cb82f28e5566bbda21a10761fc131ab4cf7c4500 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Thu, 9 Jul 2026 00:47:12 +0300 Subject: [PATCH 19/19] chore: polish percent-encoding/fuzz test coverage and generator logging --- .../commonTest/kotlin/org/dexpace/kuri/parser/UrlFuzzTest.kt | 2 +- .../dexpace/kuri/percent/PercentEncodingConformanceTest.kt | 4 ++++ tools/internal/codegen/percentencoding.go | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlFuzzTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlFuzzTest.kt index 9d72df1..4365e3c 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlFuzzTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlFuzzTest.kt @@ -11,7 +11,7 @@ import kotlin.test.Test import kotlin.test.assertEquals class UrlFuzzTest { - private val alphabet: String = "abc019:/?#@[]%.\\ \t\n&=+-_~<>^`{}|!$(),;*'\"" + private val alphabet: String = "abc019:/?#@[]%.\\ \t\n\r&=+-_~<>^`{}|!$(),;*'\"" private fun randomInput(random: Random): String { val length = random.nextInt(0, MAX_LEN) diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingConformanceTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingConformanceTest.kt index 4e1c879..3674604 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingConformanceTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/percent/PercentEncodingConformanceTest.kt @@ -21,6 +21,10 @@ import kotlin.test.assertTrue * 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. */ diff --git a/tools/internal/codegen/percentencoding.go b/tools/internal/codegen/percentencoding.go index 17f6919..cdf7057 100644 --- a/tools/internal/codegen/percentencoding.go +++ b/tools/internal/codegen/percentencoding.go @@ -85,6 +85,7 @@ func percentLoadCases(data []byte) ([]percentCase, error) { } 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})