Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6a5f7c8
feat: add state-override entry point to the URL parser
OmarAlJarrah Jul 8, 2026
74bf522
feat: honour host/hostname/port state overrides in the parser
OmarAlJarrah Jul 8, 2026
f3684c2
feat: add Url.withProtocol/withUsername/withPassword setters
OmarAlJarrah Jul 8, 2026
46ee644
feat: add Url.withHost/withHostname setters
OmarAlJarrah Jul 8, 2026
05e2741
feat: add WHATWG string Url.withPort(String) setter
OmarAlJarrah Jul 8, 2026
911726e
feat: add Url.withPathname setter
OmarAlJarrah Jul 8, 2026
c623fd7
feat: add Url.withSearch/withHash setters
OmarAlJarrah Jul 8, 2026
1c2729e
docs: complete KDoc tags on the Url setter family
OmarAlJarrah Jul 8, 2026
61fa34f
chore: update API snapshot for the Url setter family
OmarAlJarrah Jul 8, 2026
1c1d958
chore: update Android API snapshot for the Url setter family
OmarAlJarrah Jul 8, 2026
551152d
chore: generate the WPT setters conformance fixture
OmarAlJarrah Jul 8, 2026
66aef12
test: run the WPT setter conformance corpus against the Url setters
OmarAlJarrah Jul 8, 2026
5ee467d
chore: generate the WPT percent-encoding conformance fixture
OmarAlJarrah Jul 8, 2026
040e969
test: run the WPT percent-encoding corpus against the percent codec
OmarAlJarrah Jul 8, 2026
651f39d
test: assert serialization idempotence and builder round-trip invariants
OmarAlJarrah Jul 8, 2026
8ac6bff
test: add a seeded generative URL parser fuzzer
OmarAlJarrah Jul 8, 2026
d0c01b1
test: add a java.net differential suite with a documented divergence …
OmarAlJarrah Jul 8, 2026
a7b64ee
fix: preserve the /. path prefix in Url.Builder recompose for authori…
OmarAlJarrah Jul 8, 2026
cb82f28
chore: polish percent-encoding/fuzz test coverage and generator logging
OmarAlJarrah Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ val generators: List<Triple<String, String, String>> =
"conformance",
"Regenerate the IDNA conformance fixture from the WPT IdnaTestV2 and toascii corpora.",
),
Triple(
"generateSetterTestData",
"setters",
"Regenerate the WPT setters_tests conformance fixture from the vendored ada WPT corpus.",
),
Triple(
"generatePercentEncodingTestData",
"percent-encoding",
"Regenerate the WPT percent-encoding conformance fixture from the vendored ada WPT corpus.",
),
)

val codegenTasks: List<TaskProvider<Exec>> =
Expand Down
9 changes: 9 additions & 0 deletions kuri/api/android/kuri.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
9 changes: 9 additions & 0 deletions kuri/api/jvm/kuri.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
165 changes: 163 additions & 2 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -219,7 +220,11 @@ public class Url internal constructor(
private val decodedPathSegments: List<String> 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) }

/**
Expand Down Expand Up @@ -382,6 +387,73 @@ public class Url internal constructor(
return newBuilder().port(port).build()
}

/**
* The WHATWG `port` setter (URL §5): returns a copy whose port is parsed from the leading
* digits of [value] (trailing non-digits are ignored, per the port state), or with the port
* removed when [value] is empty. A no-op when the URL cannot have a port (no host, empty host,
* or `file` scheme). Never throws.
*
* @param value the new port as text; `""` removes the port.
* @return the updated [Url], or `this` when the setter is a WHATWG no-op.
*/
public fun withPort(value: String): Url {
// No pre-check on value[0]: the WHATWG pre-processing strips tab/newline from `value`
// before the port state ever sees it (e.g. "\t8080" is a valid port once stripped), and an
// invalid leading character is itself a no-op the port state machine already resolves.
return when {
!canHaveCredentials() -> this
value.isEmpty() -> Url(components.copy(port = null))
else -> applyOverride(value, StateOverride.PORT)
}
}

/**
* The WHATWG `pathname` setter (URL §5): returns a copy whose path is parsed from [value]
* (the existing path is discarded first), or `this` when the URL has an opaque path. Never
* throws.
*
* @param value the new path text.
* @return the updated [Url], or `this` when the setter is a WHATWG no-op.
*/
public fun withPathname(value: String): Url {
if (components.path is UrlPath.Opaque) return this
return applyOverride(value, StateOverride.PATHNAME)
}

/**
* The WHATWG `search` setter (URL §5): returns a copy whose query is [value] with a single
* leading `?` stripped and percent-encoded with the (special-)query set, or with the query
* removed when [value] is empty. Never throws.
*
* @param value the new query text, with or without a leading `?`; `""` removes the query.
* @return the updated [Url].
*/
public fun withSearch(value: String): Url {
if (value.isEmpty()) return Url(components.copy(query = null))
val stripped = if (value.startsWith('?')) value.substring(1) else value
return applyOverride(stripped, StateOverride.QUERY)
}

/**
* The WHATWG `hash` setter (URL §5): returns a copy whose fragment is [value] with a single
* leading `#` stripped and percent-encoded with the fragment set, or with the fragment
* removed when [value] is empty. Never throws.
*
* @param value the new fragment text, with or without a leading `#`; `""` removes the fragment.
* @return the updated [Url].
*/
public fun withHash(value: String): Url {
if (value.isEmpty()) return Url(components.copy(fragment = null))
val withoutHash = if (value.startsWith('#')) value.substring(1) else value
// WHATWG's hash setter basic-URL-parses this input with fragment state as the override,
// which unconditionally strips every ASCII tab/LF/CR first (SPEC §8.1); withHash bypasses
// the shared engine (there is no FRAGMENT StateOverride), so it must replicate that step
// itself rather than percent-encode a literal tab/newline into the fragment.
val stripped = withoutHash.filterNot { it == '\t' || it == '\n' || it == '\r' }
val encoded = PercentCodec.encode(stripped, PercentEncodeSets.FRAGMENT)
return Url(components.copy(fragment = encoded))
}

/**
* Returns a copy of this URL with its [fragment] set (or removed when `null`).
*
Expand All @@ -403,6 +475,90 @@ public class Url internal constructor(
*/
public fun withoutFragment(): Url = withFragment(null)

/**
* The WHATWG `protocol` setter (URL §5): returns a copy with [value]'s scheme, or this URL
* unchanged when the change is not permitted (special↔non-special, or an invalid `file`
* transition). Never throws — an invalid scheme is a no-op.
*
* @param value the new scheme, with or without a trailing `:`.
* @return the updated [Url], or `this` when the setter is a WHATWG no-op.
*/
public fun withProtocol(value: String): Url {
// No pre-validation here: WHATWG appends `:` to the raw value and basic-URL-parses it with
// scheme-start state as the override, so the state machine itself (which strips tab/newline
// first) decides validity and reports an invalid scheme as a no-op (SCHEME_START/SCHEME
// failing under an override; see UrlParserStates). Pre-checking the unstripped value here
// previously rejected e.g. "h\r\ntt\tps" even though it strips to the valid scheme "https".
return applyOverride("$value:", StateOverride.PROTOCOL)
}

/**
* The WHATWG `username` setter (URL §5): returns a copy whose userinfo user is [value]
* percent-encoded with the userinfo set, or `this` when the URL cannot have credentials
* (no host, empty host, or a `file` scheme). Never throws.
*
* @param value the decoded username to set.
* @return the updated [Url], or `this` when the setter is a WHATWG no-op.
*/
public fun withUsername(value: String): Url {
if (!canHaveCredentials()) return this
val encoded = PercentCodec.encode(value, PercentEncodeSets.USERINFO)
return Url(components.copy(username = encoded))
}

/**
* The WHATWG `password` setter (URL §5): as [withUsername], for the password half.
*
* @param value the decoded password to set.
* @return the updated [Url], or `this` when the setter is a WHATWG no-op.
*/
public fun withPassword(value: String): Url {
if (!canHaveCredentials()) return this
val encoded = PercentCodec.encode(value, PercentEncodeSets.USERINFO)
return Url(components.copy(password = encoded))
}

/**
* The WHATWG `host` setter (URL §5): returns a copy with host and (optional) port parsed
* from [value], or `this` when the URL has an opaque path or [value] is not a valid host.
* Never throws.
*
* @param value the new host text, optionally followed by `:` and a port.
* @return the updated [Url], or `this` when the setter is a WHATWG no-op.
*/
public fun withHost(value: String): Url {
if (components.path is UrlPath.Opaque) return this
return applyOverride(value, StateOverride.HOST)
}

/**
* The WHATWG `hostname` setter (URL §5): as [withHost] but a `:` and anything after it are
* ignored, so the existing port is preserved. Never throws.
*
* @param value the new host text; any `:` and trailing text are ignored.
* @return the updated [Url], or `this` when the setter is a WHATWG no-op.
*/
public fun withHostname(value: String): Url {
if (components.path is UrlPath.Opaque) return this
return applyOverride(value, StateOverride.HOSTNAME)
}

/** WHATWG "cannot have a username/password/port": no host, empty host, or `file` scheme. */
private fun canHaveCredentials(): Boolean {
val h = components.host
return h != null && h != Host.Empty && !scheme.equals(FILE_SCHEME, ignoreCase = true)
}

/** Runs a setter [override] over [value], returning `this` on any error or WHATWG no-op. */
private fun applyOverride(
value: String,
override: StateOverride,
): Url =
when (val result = UrlParser.parseWithOverride(value, components, override)) {
is ParseResult.Ok -> if (result.value == components) this else Url(result.value)
is ParseResult.Err -> this
}

/** The canonical [href]; a parsed `Url` round-trips through `toString` then [parse]. */
override fun toString(): String = href

Expand Down Expand Up @@ -528,7 +684,12 @@ public class Url internal constructor(
encodedPassword = source.password
host = source.hostName
port = source.port
path = BuilderPath.verbatim(source.encodedPath)
// Pre-fill from the guarded full-URL path serialization, not the `encodedPath` getter:
// recompose concatenates the path directly onto `scheme:` with no authority, so an
// authority-less `//`-leading path must keep the NORM-18 `/.` guard or the rebuilt string
// re-parses with a spurious authority (the getter drops the guard per SPEC §11.2, which is
// correct for a standalone pathname but wrong as recompose input).
path = BuilderPath.verbatim(serializeUrlPath(source.components))
queryState = QueryState.Raw(source.query)
encodedFragment = source.fragment
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}
101 changes: 101 additions & 0 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,105 @@ internal object UrlParser {
return if (failure != null) ParseResult.Err(failure) else ParseResult.Ok(finalize(state))
}

/**
* Runs the state machine with a setter [override] over [value], seeded from [seed] (WHATWG
* URL §5 "basic URL parser with … state override"). Unlike [parse], the leading/trailing
* C0-or-space trim and the fragment prune are skipped (the value is a single component); only
* tab/newline removal applies. Returns [ParseResult.Err] on a fatal component error and — via
* the [UrlTransition.Abort] path — signals a WHATWG no-op by returning the seed unchanged.
*/
internal fun parseWithOverride(
value: String,
seed: ParsedComponents,
override: StateOverride,
): ParseResult<ParsedComponents> {
val errors = mutableListOf<ValidationError>()
val stripped = stripTabAndNewline(value, errors)
val state = UrlParserState(stripped, seed, override, errors)
// WHATWG pathname setter step "empty this's URL's path" before re-entering path-start
// (WHATWG URL §5); the seeded segments must not survive into the re-parse.
if (override == StateOverride.PATHNAME) state.path.clear()
// WHATWG search setter step "set this's URL's query to the empty string" (URL §5); the
// QUERY state handler appends to the seeded query, so it must be reset here or a
// withSearch call would concatenate onto the old query instead of replacing it.
if (override == StateOverride.QUERY) state.query = ""
// Map each override to the UrlState the basic URL parser re-enters (WHATWG URL §5 setters).
state.state =
when (override) {
StateOverride.PROTOCOL -> UrlState.SCHEME_START
StateOverride.HOST, StateOverride.HOSTNAME -> UrlState.HOST
StateOverride.PORT -> UrlState.PORT
StateOverride.PATHNAME -> UrlState.PATH_START
StateOverride.QUERY -> UrlState.QUERY
}
return when (val outcome = runOverride(state)) {
OverrideOutcome.ABORT -> ParseResult.Ok(seed)
OverrideOutcome.OK -> ParseResult.Ok(finalize(state).copy(fragment = seed.fragment))
is OverrideOutcome.Fail -> ParseResult.Err(outcome.error)
}
}

/** The three terminal outcomes of an override run. */
private sealed interface OverrideOutcome {
data object OK : OverrideOutcome

data object ABORT : OverrideOutcome

data class Fail(
val error: UriParseError,
) : OverrideOutcome
}

/**
* Drives the override loop. Terminates on [UrlTransition.Done]/[UrlTransition.Abort], on a
* fatal [UrlTransition.Fail], or when an [UrlTransition.Advance]/[UrlTransition.Reconsume]
* reaches EOF — the natural end of a single-component value. A fixed iteration bound guards any
* reconsume chain.
*/
private fun runOverride(state: UrlParserState): OverrideOutcome {
val maxIterations = (state.input.length + 1).toLong() * MAX_REENTRIES_PER_CODE_POINT
var iterations = 0L
var outcome: OverrideOutcome? = null
while (outcome == null) {
check(iterations++ < maxIterations) { "override state machine exceeded its iteration bound" }
outcome = stepOverride(state, STATE_HANDLERS.getValue(state.state)(state))
}
return outcome
}

/**
* Applies one [transition] under an override run, returning the terminal [OverrideOutcome] or
* `null` to continue the loop. Mirrors [runStateMachine]'s handling exactly: [Reconsume] never
* inspects [UrlParserState.pos] itself — it only changes state and lets the loop re-invoke the
* new state's handler, even at EOF, since that handler is what finalizes a component (e.g. PATH
* appending the root segment, or FILE_HOST clearing an empty host). Only [Advance] terminates on
* EOF (after the *current* state has already run once, having just consumed the final code
* point), the natural end of a single-component value.
*/
private fun stepOverride(
state: UrlParserState,
transition: UrlTransition,
): OverrideOutcome? =
when (transition) {
is UrlTransition.Done -> OverrideOutcome.OK
is UrlTransition.Abort -> OverrideOutcome.ABORT
is UrlTransition.Fail -> OverrideOutcome.Fail(transition.error)
is UrlTransition.Reconsume -> {
state.state = transition.next
null
}
is UrlTransition.Advance -> {
state.state = transition.next
when {
state.pos >= state.input.length -> OverrideOutcome.OK
else -> {
state.pos += 1
null
}
}
}
}

/** The dispatch table mapping each [UrlState] to its single state function (§8.3). */
private val STATE_HANDLERS: Map<UrlState, (UrlParserState) -> UrlTransition> =
mapOf(
Expand Down Expand Up @@ -103,6 +202,8 @@ internal object UrlParser {
if (state.pos == state.input.length) return null
state.pos += 1
}
is UrlTransition.Done, is UrlTransition.Abort ->
error("Done/Abort are override-only transitions")
}
}
}
Expand Down
Loading
Loading