diff --git a/CLAUDE.md b/CLAUDE.md index d76ae4a..a8e8df5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,8 +67,8 @@ conventions are wired into the build and are authoritative: (synchronized pins carrier threads under Loom). Blocking calls must respect `Thread.interrupt()` — catch `InterruptedException`, restore the interrupt flag, and throw `InterruptedIOException` (or attach the interrupt as suppressed). -- **Commit style:** `feat:` / `test:` / `docs:` / `chore:` prefixes (`merge:` for merge - commits). **PR titles follow the same prefixed style** (e.g. `docs: add CLAUDE.md`). +- **Commit style:** `feat:` / `test:` / `docs:` / `chore:` / `refactor:` prefixes (`merge:` + for merge commits). **PR titles follow the same prefixed style** (e.g. `docs: add CLAUDE.md`). ## Generated data & codegen diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/ParseProfile.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/ParseProfile.kt deleted file mode 100644 index 38611ee..0000000 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/ParseProfile.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2026 dexpace and Omar Aljarrah - * SPDX-License-Identifier: MIT - */ -package org.dexpace.kuri - -/** - * Selects which parsing posture the shared engine applies (SPEC §1.2). - * - * `kuri` runs one parsing engine, one host module, and one percent-encoding module, - * parameterized by this profile, so that `Uri` and `Url` are produced by the same code - * paths configured differently rather than by two independent parsers. The - * two members are exhaustive and mutually exclusive; a single parse never mixes - * behaviours from both. - * - * - [URI] models the RFC 3986/3987 generic-URI syntax: scheme-agnostic, no special - * schemes or default ports, no backslash rewriting, and **preserve-by-default** with - * normalization only on explicit opt-in. - * - [URL] models the WHATWG URL Living Standard: special-scheme aware, applies **eager - * canonicalization** on every parse and build, runs the full WHATWG host pipeline, and - * elides default ports. - * - * Modelled as two distinct types at layer 2 (`Uri`/`Url`) rather than a runtime mode flag - * so the type system communicates which contract a value holds. - */ -internal enum class ParseProfile { - /** RFC 3986/3987 generic-URI profile: preserve-by-default, scheme-agnostic. */ - URI, - - /** WHATWG URL profile: eager canonicalization, special schemes, full host pipeline. */ - URL, - ; - - /** - * True when this profile selects WHATWG semantics ([URL]). - * - * Convenience for the many profile-gated branches that ask "is this the WHATWG - * profile?" rather than comparing against an enum constant at each call site. - */ - internal val isWhatwg: Boolean - get() = this == URL -} diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt index 0248e7a..605948b 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt @@ -9,10 +9,10 @@ import org.dexpace.kuri.error.UriSyntaxException import org.dexpace.kuri.error.map import org.dexpace.kuri.host.Host import org.dexpace.kuri.parser.BuilderPath +import org.dexpace.kuri.parser.ComponentPath import org.dexpace.kuri.parser.ParsedComponents import org.dexpace.kuri.parser.Resolver import org.dexpace.kuri.parser.UriParser -import org.dexpace.kuri.parser.UrlPath import org.dexpace.kuri.parser.decodedSegments import org.dexpace.kuri.parser.fileExtensionOf import org.dexpace.kuri.parser.fileNameOf @@ -25,8 +25,8 @@ import org.dexpace.kuri.query.QueryParametersBuilder import org.dexpace.kuri.query.QueryState import org.dexpace.kuri.query.applyParameterEdit import org.dexpace.kuri.scheme.Scheme -import org.dexpace.kuri.serialize.Serializer import org.dexpace.kuri.serialize.UriNormalizer +import org.dexpace.kuri.serialize.UriSerializer import org.dexpace.kuri.serialize.guardRecomposedUriPath import kotlin.jvm.JvmName import kotlin.jvm.JvmOverloads @@ -187,7 +187,7 @@ public class Uri internal constructor( public fun fileExtension(): String = fileExtensionOf(fileName()) /** Cached canonical-but-unnormalized serialization, computed once. */ - private val canonicalUri: String by lazy { Serializer.serialize(components, ParseProfile.URI) } + private val canonicalUri: String by lazy { UriSerializer.serialize(components) } /** * The canonical-but-UNNORMALIZED RFC 3986 §5.3 serialization; the basis of equality. @@ -444,7 +444,7 @@ public class Uri internal constructor( * * @return `true` iff this URI is absolute with an authority-less, rootless (opaque) path. */ - public fun isOpaquePath(): Boolean = components.path is UrlPath.Opaque || hasRootlessSchemePath() + public fun isOpaquePath(): Boolean = components.path is ComponentPath.Opaque || hasRootlessSchemePath() /** * The port a consumer should connect to: the explicit [port], else this scheme's default. @@ -534,13 +534,14 @@ public class Uri internal constructor( * Reuses the already-decoded [decodedPathSegments] rather than decoding every segment a second * time: an opaque path is that single decoded value, and a segment path rejoins the decoded * segments through [toUriPathString] so the empty-vs-root-only and rooted-vs-rootless ordering - * keeps its single source of truth (UrlPath.kt). Reading [decodedPathSegments] here is safe — + * keeps its single source of truth (ComponentPath.kt). Reading [decodedPathSegments] here is safe — * it does not read [decodedPath], so there is no lazy cycle. */ private fun computeDecodedPath(): String = when (val storedPath = components.path) { - is UrlPath.Opaque -> decodedPathSegments.single() - is UrlPath.Segments -> UrlPath.Segments(decodedPathSegments, storedPath.rooted).toUriPathString() + is ComponentPath.Opaque -> decodedPathSegments.single() + is ComponentPath.Segments -> + ComponentPath.Segments(decodedPathSegments, storedPath.rooted).toUriPathString() } /** The decoded segments backing [pathSegments]; an opaque path yields its single decoded value. */ @@ -550,7 +551,7 @@ public class Uri internal constructor( /** * True for the RFC 3986 opaque shape: an absolute URI with no authority and a rootless path. * - * Reads the structured [UrlPath.Segments.rooted] flag rather than re-serializing the path and + * Reads the structured [ComponentPath.Segments.rooted] flag rather than re-serializing the path and * inspecting its first character. A rootless path serializes with a leading `/` only when its first * segment is empty, so a non-empty first segment is the same condition as `!startsWith("/")` — * without the `O(path)` string build on every [isOpaquePath]/[relativize] call. @@ -558,7 +559,7 @@ public class Uri internal constructor( private fun hasRootlessSchemePath(): Boolean { if (scheme == null || components.host != null) return false val storedPath = components.path - return storedPath is UrlPath.Segments && + return storedPath is ComponentPath.Segments && !storedPath.rooted && storedPath.segments.firstOrNull()?.isNotEmpty() == true } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index fe33479..04929a2 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt @@ -10,10 +10,10 @@ import org.dexpace.kuri.error.ValidationError import org.dexpace.kuri.error.map import org.dexpace.kuri.host.Host import org.dexpace.kuri.parser.BuilderPath +import org.dexpace.kuri.parser.ComponentPath 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 import org.dexpace.kuri.parser.fileExtensionOf import org.dexpace.kuri.parser.fileNameOf @@ -25,7 +25,7 @@ import org.dexpace.kuri.query.QueryParametersBuilder import org.dexpace.kuri.query.QueryState import org.dexpace.kuri.query.applyParameterEdit import org.dexpace.kuri.scheme.Scheme -import org.dexpace.kuri.serialize.Serializer +import org.dexpace.kuri.serialize.UrlSerializer import org.dexpace.kuri.serialize.serializeAuthority import org.dexpace.kuri.serialize.serializeUrlPath import kotlin.jvm.JvmName @@ -214,7 +214,7 @@ public class Url internal constructor( public fun hasOpaqueOrigin(): Boolean = origin == OPAQUE_ORIGIN /** Cached canonical serialization, computed once (permits caching an immutable value). */ - private val canonicalHref: String by lazy { Serializer.serialize(components, ParseProfile.URL) } + private val canonicalHref: String by lazy { UrlSerializer.serialize(components) } /** Path/query projections, each computed once; every value is immutable, mirroring [canonicalHref]. */ private val decodedPathSegments: List by lazy { @@ -356,7 +356,7 @@ public class Url internal constructor( * * @return the last non-empty decoded segment, or `""` when there is none or the path is opaque. */ - public fun fileName(): String = if (components.path is UrlPath.Opaque) "" else fileNameOf(pathSegments) + public fun fileName(): String = if (components.path is ComponentPath.Opaque) "" else fileNameOf(pathSegments) /** * The extension of [fileName]: the text after its last `.`, or `""` when it has none (SPEC §3.3). @@ -416,7 +416,7 @@ public class Url internal constructor( * @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 + if (components.path is ComponentPath.Opaque) return this return applyOverride(value, StateOverride.PATHNAME) } @@ -527,7 +527,7 @@ public class Url internal constructor( * @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 + if (components.path is ComponentPath.Opaque) return this return applyOverride(value, StateOverride.HOST) } @@ -540,7 +540,7 @@ public class Url internal constructor( * @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 + if (components.path is ComponentPath.Opaque) return this return applyOverride(value, StateOverride.HOSTNAME) } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/HostParsing.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/HostParsing.kt new file mode 100644 index 0000000..6a80998 --- /dev/null +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/HostParsing.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.host + +import org.dexpace.kuri.error.HostError +import org.dexpace.kuri.error.ParseResult +import org.dexpace.kuri.error.UriParseError + +/** + * Opening delimiter of an IP-literal; a host beginning with it dispatches to the IPv6/IPvFuture + * grammars. Shared by [UriHostParser] and [UrlHostParser] ([HOST-4]/[HOST-5]). + */ +internal const val BRACKET_OPEN: Char = '[' + +/** Closing delimiter an IP-literal MUST carry ([HOST-4]/[HOST-5]); shared by both host parsers. */ +internal const val BRACKET_CLOSE: Char = ']' + +/** + * Strips the surrounding `[`/`]`, returning the bracket contents ([HOST-4]/[HOST-5]). + * + * Shared by both the WHATWG ([UrlHostParser]) and RFC 3986 ([UriHostParser]) bracketed-literal + * paths, which both isolate the interior before delegating to the IPv6/IPvFuture grammars. + */ +internal fun bracketInterior(input: String): String { + require(input.length >= 2) { "bracketed literal too short: $input" } + return input.substring(1, input.length - 1) +} + +/** + * The malformed-literal failure for a bracketed host that does not close ([HOST-4]/[HOST-5]). + * + * Shared by both host parsers so an unterminated `[`…` reports the same [HostError.Ipv6Malformed]. + */ +internal fun bracketError(input: String): ParseResult = + ParseResult.Err(UriParseError.InvalidHost(input, HostError.Ipv6Malformed)) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv4.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv4.kt index dcb244f..fcc7620 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv4.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv4.kt @@ -4,62 +4,15 @@ */ package org.dexpace.kuri.host -import org.dexpace.kuri.ParseProfile import org.dexpace.kuri.error.HostError import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.error.UriParseError -import org.dexpace.kuri.text.hexDigitToInt import org.dexpace.kuri.text.isAsciiDigit import org.dexpace.kuri.text.isAsciiHexDigit -/** Label separator and serialization joiner for dotted-quad addresses. */ -private const val DOT: Char = '.' - -/** String form of [DOT] used to join the four serialized octets. */ +/** String form of [Ipv4.DOT] used to join the four serialized octets. */ private const val SEPARATOR: String = "." -/** Maximum dotted parts the `Url` IPv4 number parser accepts (§7.3.1 [HOST-21]). */ -private const val MAX_PARTS: Int = 4 - -/** The fixed octet count of an IPv4 address: four 8-bit groups. */ -private const val IPV4_OCTET_COUNT: Int = 4 - -/** Largest value a single octet may hold, also the low-byte mask (`0xFF`). */ -private const val MAX_OCTET: Int = 0xFF - -/** Most digits a decimal `dec-octet` may have (`255` is three; §7.3.2 [HOST-24]). */ -private const val MAX_OCTET_DIGITS: Int = 3 - -/** Bit width of one octet; the shift between adjacent octets in the packed value. */ -private const val BITS_PER_OCTET: Int = 8 - -/** Bit width of the whole IPv4 address. */ -private const val ADDRESS_BITS: Int = 32 - -/** Index of the most-significant octet, i.e. `IPV4_OCTET_COUNT - 1`. */ -private const val HIGH_OCTET_INDEX: Int = 3 - -/** Decimal radix for plain `dec-octet` parts and zero-padded-free numbers. */ -private const val RADIX_DECIMAL: Int = 10 - -/** Octal radix selected by a leading `0` with at least one further digit. */ -private const val RADIX_OCTAL: Int = 8 - -/** Hexadecimal radix selected by a `0x`/`0X` part prefix. */ -private const val RADIX_HEX: Int = 16 - -/** Length of the `0x`/`0X` prefix stripped from a hexadecimal part before its digits ([HOST-21] step 3). */ -private const val HEX_PREFIX_LENGTH: Int = 2 - -/** Length of the single leading `0` stripped from an octal part before its digits ([HOST-21] step 3). */ -private const val OCTAL_PREFIX_LENGTH: Int = 1 - -/** Sentinel returned by [parsePart]/[decOctetOrNull] for an unparsable part. */ -private const val INVALID_PART: Long = -1L - -/** Exclusive upper bound of a 32-bit address (`2^32`); also the per-part overflow cap. */ -private const val UINT32_LIMIT: Long = 1L shl 32 - /** * Unpacks a packed 32-bit IPv4 address into its four octets, most-significant first. * @@ -72,33 +25,45 @@ private const val UINT32_LIMIT: Long = 1L shl 32 */ internal fun ipv4Octets(value: Int): IntArray { val octets = - IntArray(IPV4_OCTET_COUNT) { index -> - (value ushr (BITS_PER_OCTET * (HIGH_OCTET_INDEX - index))) and MAX_OCTET + IntArray(Ipv4.IPV4_OCTET_COUNT) { index -> + (value ushr (Ipv4.BITS_PER_OCTET * (Ipv4.HIGH_OCTET_INDEX - index))) and Ipv4.MAX_OCTET } - check(octets.size == IPV4_OCTET_COUNT) { "expected $IPV4_OCTET_COUNT octets, got ${octets.size}" } - check(octets.all { it in 0..MAX_OCTET }) { "octet out of range in ${octets.toList()}" } + check(octets.size == Ipv4.IPV4_OCTET_COUNT) { "expected ${Ipv4.IPV4_OCTET_COUNT} octets, got ${octets.size}" } + check(octets.all { it in 0..Ipv4.MAX_OCTET }) { "octet out of range in ${octets.toList()}" } return octets } /** - * Parser and serializer for the IPv4 host form (SPEC §7.3). - * - * Two parse postures share one type: the `Url` profile runs the WHATWG number - * parser (1–4 parts, per-part radix detection, width-aware overflow) per §7.3.1, - * while the `Uri` profile accepts only the exact RFC 3986 dotted-decimal - * `IPv4address` per §7.3.2. Both yield a [Host.Ipv4] holding the address as 32 - * unsigned bits packed into a signed [Int]; the original radix is intentionally - * discarded and never round-tripped — [serialize] always emits canonical - * dotted-decimal (§7.3.3 [HOST-25]). + * Shared IPv4 plumbing for SPEC §7.3: the WHATWG "ends in a number" domain classifier, + * canonical dotted-decimal serialization, and the predicates/failure builder the two + * parse postures both draw on. * - * The WHATWG number parser, the RFC 3986 grammar, and the canonical serializer are each - * multi-step procedures ([HOST-19]..[HOST-25]); they are decomposed here into - * single-purpose helpers (each well under the line/return/complexity budgets), which is - * the intent of the "small functions" rule and legitimately exceeds the per-object - * function count. + * The actual parsing lives in two sibling objects — [Ipv4Rfc3986] for the exact RFC 3986 + * `IPv4address` grammar (§7.3.2) and [Ipv4Whatwg] for the WHATWG number parser (§7.3.1) — + * each calling back into this object for the pieces they share, so neither half ever + * imports the other. Both parsers yield a [Host.Ipv4] holding the address as 32 unsigned + * bits packed into a signed [Int]; the original radix is intentionally discarded and never + * round-tripped — [serialize] always emits canonical dotted-decimal (§7.3.3 [HOST-25]). */ -@Suppress("TooManyFunctions") internal object Ipv4 { + /** Label separator and serialization joiner for dotted-quad addresses; shared by both parse halves. */ + internal const val DOT: Char = '.' + + /** The fixed octet count of an IPv4 address: four 8-bit groups; shared by [Ipv4Rfc3986]. */ + internal const val IPV4_OCTET_COUNT: Int = 4 + + /** Largest value a single octet may hold, also the low-byte mask (`0xFF`); shared by both parse halves. */ + internal const val MAX_OCTET: Int = 0xFF + + /** Bit width of one octet; the shift between adjacent octets in the packed value; shared by both parse halves. */ + internal const val BITS_PER_OCTET: Int = 8 + + /** Index of the most-significant octet, i.e. `IPV4_OCTET_COUNT - 1`; shared by [Ipv4Whatwg]. */ + internal const val HIGH_OCTET_INDEX: Int = 3 + + /** Sentinel returned by a per-part parser for an unparsable part; shared by both parse halves. */ + internal const val INVALID_PART: Long = -1L + /** * The WHATWG "ends in a number" decision that classifies a host as IPv4 vs a * registered name (§7.3.1 [HOST-19]). @@ -116,23 +81,6 @@ internal object Ipv4 { return lastLabel.isNotEmpty() && (isDecimalLabel(lastLabel) || isHexLabel(lastLabel)) } - /** - * Parses [input] as an IPv4 address under the given [profile] (§7.3). - * - * In the `Url` profile this is the WHATWG number parser; in the `Uri` profile - * it is the exact RFC 3986 `IPv4address` grammar. A rejected input is returned - * as [ParseResult.Err] with a [HostError] cause rather than thrown ([ERR-1]). - * - * @param input the host text (no surrounding brackets); the caller decides, - * via [endsInANumber] in the `Url` profile, that this is IPv4-shaped. - * @param profile selects the WHATWG vs RFC 3986 acceptance rules. - * @return the parsed [Host.Ipv4], or an [HostError]-tagged failure. - */ - internal fun parse( - input: String, - profile: ParseProfile, - ): ParseResult = if (profile.isWhatwg) parseWhatwg(input) else parseRfc3986(input) - /** * Serializes a 32-bit address into canonical dotted-decimal form (§7.3.3 * [HOST-25]): the four octets, most-significant first, base-10, joined by `.`. @@ -143,142 +91,32 @@ internal object Ipv4 { internal fun serialize(value: Int): String = ipv4Octets(value).joinToString(SEPARATOR) /** - * The WHATWG IPv4 number parser of §7.3.1 ([HOST-21], [HOST-22]). + * Removes a single trailing `.` ([HOST-19]/[HOST-21] step 1); other dots are kept. * - * More than [MAX_PARTS] parts is an over-wide address that cannot fit 32 bits, so it - * is classified as [HostError.Ipv4Overflow] ([HOST-21] step 2); an empty or - * non-numeric part instead fails as [HostError.Ipv4NonNumeric]. - */ - private fun parseWhatwg(input: String): ParseResult { - val parts = stripSingleTrailingDot(input).split(DOT) - if (parts.size > MAX_PARTS) return fail(input, HostError.Ipv4Overflow) - val numbers = parseNumericParts(parts) - return if (numbers == null) fail(input, HostError.Ipv4NonNumeric) else assemble(input, numbers) - } - - /** - * Parses each part in its detected radix once the part count is known valid; - * `null` signals an empty part or a digit outside its radix ([HOST-21] step 3). + * Called both by [endsInANumber] and, directly, by [Ipv4Whatwg.parse] ([HOST-21] step 1). */ - private fun parseNumericParts(parts: List): List? { - val numbers = if (parts.none { it.isEmpty() }) parts.map { parsePart(it) } else null - return numbers?.takeIf { values -> values.none { it == INVALID_PART } } - } - - /** Parses one `Url`-profile part, detecting radix per [HOST-21] step 3. */ - private fun parsePart(part: String): Long { - require(part.isNotEmpty()) { "empty part reached parsePart" } - val hex = isHexPrefixed(part) - val octal = !hex && part.length > 1 && part[0] == '0' - // One decision selects both the radix and the prefix length to strip; a decimal part carries - // offset 0 so it reuses `part` verbatim, materializing no substring on the common path. - val radix: Int - val prefixLength: Int - when { - hex -> { - radix = RADIX_HEX - prefixLength = HEX_PREFIX_LENGTH - } - octal -> { - radix = RADIX_OCTAL - prefixLength = OCTAL_PREFIX_LENGTH - } - else -> { - radix = RADIX_DECIMAL - prefixLength = 0 - } - } - val digits = if (prefixLength == 0) part else part.substring(prefixLength) - return parseInRadix(digits, radix) - } - - /** - * Accumulates [digits] in [radix], capping at [UINT32_LIMIT] so an overflowing - * part still fails the later width check instead of wrapping; returns - * [INVALID_PART] on a digit outside the radix. Empty [digits] (the `0x` case) - * denotes zero. - */ - private fun parseInRadix( - digits: String, - radix: Int, - ): Long { - require(radix == RADIX_DECIMAL || radix == RADIX_OCTAL || radix == RADIX_HEX) { "bad radix $radix" } - var acc = 0L - for (ch in digits) { - val d = hexDigitToInt(ch) - acc = if (d in 0 until radix) minOf(acc * radix + d, UINT32_LIMIT) else INVALID_PART - if (acc == INVALID_PART) break - } - return acc - } - - /** Applies width-aware overflow ([HOST-22]) and packs the parts into 32 bits. */ - private fun assemble( - input: String, - numbers: List, - ): ParseResult { - val n = numbers.size - require(n in 1..MAX_PARTS) { "part count out of range: $n" } - val finalMax = 1L shl (ADDRESS_BITS - BITS_PER_OCTET * (n - 1)) - val headOk = numbers.dropLast(1).all { it <= MAX_OCTET } - val tailOk = numbers.last() < finalMax - return if (headOk && tailOk) { - ParseResult.Ok(Host.Ipv4(combine(numbers).toInt())) - } else { - fail(input, HostError.Ipv4Overflow) - } - } - - /** Folds the validated [numbers] into a single 32-bit value (final part absorbs the low bits). */ - private fun combine(numbers: List): Long { - require(numbers.isNotEmpty()) { "no octets to combine" } - return numbers.dropLast(1).foldIndexed(numbers.last()) { i, acc, value -> - acc + (value shl (BITS_PER_OCTET * (HIGH_OCTET_INDEX - i))) - } - } - - /** The exact RFC 3986 `IPv4address` grammar of §7.3.2 ([HOST-24]). */ - private fun parseRfc3986(input: String): ParseResult { - val parts = input.split(DOT) - val octets = if (parts.size == IPV4_OCTET_COUNT) parts.map { decOctetOrNull(it) } else null - return when { - octets == null || octets.any { it == INVALID_PART } -> fail(input, HostError.Ipv4NonNumeric) - else -> ParseResult.Ok(Host.Ipv4(packOctets(octets.map { it.toInt() }))) - } - } - - /** - * Parses one RFC 3986 `dec-octet`: 1–3 ASCII digits, value `0..255`, with no - * leading zero beyond a lone `0`. Returns [INVALID_PART] when any rule fails. - */ - private fun decOctetOrNull(part: String): Long { - val digitsOk = part.length in 1..MAX_OCTET_DIGITS && part.all { it.isAsciiDigit() } - val noLeadingZero = part.length == 1 || part.firstOrNull() != '0' - val value = if (digitsOk && noLeadingZero) part.toInt() else MAX_OCTET + 1 - return if (value <= MAX_OCTET) value.toLong() else INVALID_PART - } - - /** Packs exactly four octets (high to low) into a single 32-bit [Int]. */ - private fun packOctets(octets: List): Int { - require(octets.size == IPV4_OCTET_COUNT) { "expected four octets, got ${octets.size}" } - return octets.fold(0) { acc, octet -> (acc shl BITS_PER_OCTET) or octet } - } - - /** Removes a single trailing `.` ([HOST-19]/[HOST-21] step 1); other dots are kept. */ - private fun stripSingleTrailingDot(host: String): String = if (host.endsWith(DOT)) host.dropLast(1) else host + internal fun stripSingleTrailingDot(host: String): String = if (host.endsWith(DOT)) host.dropLast(1) else host /** True when [label] is a non-empty run of ASCII decimal digits. */ private fun isDecimalLabel(label: String): Boolean = label.all { it.isAsciiDigit() } - /** True when [label] begins with the two-character `0x`/`0X` hex prefix. */ - private fun isHexPrefixed(label: String): Boolean = + /** + * True when [label] begins with the two-character `0x`/`0X` hex prefix. + * + * Called both by [isHexLabel] and, directly, by [Ipv4Whatwg]'s per-part radix detection. + */ + internal fun isHexPrefixed(label: String): Boolean = label.length >= 2 && label[0] == '0' && (label[1] == 'x' || label[1] == 'X') /** True when [label] is `0x`/`0X` followed by zero or more ASCII hex digits. */ private fun isHexLabel(label: String): Boolean = isHexPrefixed(label) && label.drop(2).all { it.isAsciiHexDigit() } - /** Builds the fatal-host failure carrying the offending [host] text and [reason]. */ - private fun fail( + /** + * Builds the fatal-host failure carrying the offending [host] text and [reason]. + * + * The single failure constructor for both [Ipv4Rfc3986.parse] and [Ipv4Whatwg.parse]. + */ + internal fun fail( host: String, reason: HostError, ): ParseResult = ParseResult.Err(UriParseError.InvalidHost(host, reason)) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv4Rfc3986.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv4Rfc3986.kt new file mode 100644 index 0000000..275c970 --- /dev/null +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv4Rfc3986.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.host + +import org.dexpace.kuri.error.HostError +import org.dexpace.kuri.error.ParseResult +import org.dexpace.kuri.text.isAsciiDigit + +/** Most digits a decimal `dec-octet` may have (`255` is three; §7.3.2 [HOST-24]). */ +private const val MAX_OCTET_DIGITS: Int = 3 + +/** + * The `Uri`-profile IPv4 parser: the exact RFC 3986 `IPv4address` grammar of §7.3.2 + * ([HOST-24]). + * + * Draws the dotted-decimal split point, octet width, and failure builder from [Ipv4]; + * never imports [Ipv4Whatwg]. + */ +internal object Ipv4Rfc3986 { + /** The exact RFC 3986 `IPv4address` grammar of §7.3.2 ([HOST-24]). */ + internal fun parse(input: String): ParseResult { + val parts = input.split(Ipv4.DOT) + val octets = if (parts.size == Ipv4.IPV4_OCTET_COUNT) parts.map { decOctetOrNull(it) } else null + return when { + octets == null || octets.any { it == Ipv4.INVALID_PART } -> Ipv4.fail(input, HostError.Ipv4NonNumeric) + else -> ParseResult.Ok(Host.Ipv4(packOctets(octets.map { it.toInt() }))) + } + } + + /** + * Parses one RFC 3986 `dec-octet`: 1–3 ASCII digits, value `0..255`, with no + * leading zero beyond a lone `0`. Returns [Ipv4.INVALID_PART] when any rule fails. + */ + private fun decOctetOrNull(part: String): Long { + val digitsOk = part.length in 1..MAX_OCTET_DIGITS && part.all { it.isAsciiDigit() } + val noLeadingZero = part.length == 1 || part.firstOrNull() != '0' + val value = if (digitsOk && noLeadingZero) part.toInt() else Ipv4.MAX_OCTET + 1 + return if (value <= Ipv4.MAX_OCTET) value.toLong() else Ipv4.INVALID_PART + } + + /** Packs exactly four octets (high to low) into a single 32-bit [Int]. */ + private fun packOctets(octets: List): Int { + require(octets.size == Ipv4.IPV4_OCTET_COUNT) { "expected four octets, got ${octets.size}" } + return octets.fold(0) { acc, octet -> (acc shl Ipv4.BITS_PER_OCTET) or octet } + } +} diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv4Whatwg.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv4Whatwg.kt new file mode 100644 index 0000000..00791ef --- /dev/null +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv4Whatwg.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.host + +import org.dexpace.kuri.error.HostError +import org.dexpace.kuri.error.ParseResult +import org.dexpace.kuri.text.hexDigitToInt + +/** Maximum dotted parts the `Url` IPv4 number parser accepts (§7.3.1 [HOST-21]). */ +private const val MAX_PARTS: Int = 4 + +/** Bit width of the whole IPv4 address. */ +private const val ADDRESS_BITS: Int = 32 + +/** Decimal radix for plain `dec-octet` parts and zero-padded-free numbers. */ +private const val RADIX_DECIMAL: Int = 10 + +/** Octal radix selected by a leading `0` with at least one further digit. */ +private const val RADIX_OCTAL: Int = 8 + +/** Hexadecimal radix selected by a `0x`/`0X` part prefix. */ +private const val RADIX_HEX: Int = 16 + +/** Length of the `0x`/`0X` prefix stripped from a hexadecimal part before its digits ([HOST-21] step 3). */ +private const val HEX_PREFIX_LENGTH: Int = 2 + +/** Length of the single leading `0` stripped from an octal part before its digits ([HOST-21] step 3). */ +private const val OCTAL_PREFIX_LENGTH: Int = 1 + +/** Exclusive upper bound of a 32-bit address (`2^32`); also the per-part overflow cap. */ +private const val UINT32_LIMIT: Long = 1L shl 32 + +/** + * The `Url`-profile IPv4 parser: the WHATWG number parser of §7.3.1 ([HOST-21], + * [HOST-22]) — 1–4 dotted parts, per-part radix detection, width-aware overflow. + * + * Draws the dotted-decimal split point, octet width, and failure builder from [Ipv4]; + * never imports [Ipv4Rfc3986]. + */ +internal object Ipv4Whatwg { + /** + * More than [MAX_PARTS] parts is an over-wide address that cannot fit 32 bits, so it + * is classified as [HostError.Ipv4Overflow] ([HOST-21] step 2); an empty or + * non-numeric part instead fails as [HostError.Ipv4NonNumeric]. + */ + internal fun parse(input: String): ParseResult { + val parts = Ipv4.stripSingleTrailingDot(input).split(Ipv4.DOT) + if (parts.size > MAX_PARTS) return Ipv4.fail(input, HostError.Ipv4Overflow) + val numbers = parseNumericParts(parts) + return if (numbers == null) Ipv4.fail(input, HostError.Ipv4NonNumeric) else assemble(input, numbers) + } + + /** + * Parses each part in its detected radix once the part count is known valid; + * `null` signals an empty part or a digit outside its radix ([HOST-21] step 3). + */ + private fun parseNumericParts(parts: List): List? { + val numbers = if (parts.none { it.isEmpty() }) parts.map { parsePart(it) } else null + return numbers?.takeIf { values -> values.none { it == Ipv4.INVALID_PART } } + } + + /** Parses one `Url`-profile part, detecting radix per [HOST-21] step 3. */ + private fun parsePart(part: String): Long { + require(part.isNotEmpty()) { "empty part reached parsePart" } + val hex = Ipv4.isHexPrefixed(part) + val octal = !hex && part.length > 1 && part[0] == '0' + // One decision selects both the radix and the prefix length to strip; a decimal part carries + // offset 0 so it reuses `part` verbatim, materializing no substring on the common path. + val radix: Int + val prefixLength: Int + when { + hex -> { + radix = RADIX_HEX + prefixLength = HEX_PREFIX_LENGTH + } + octal -> { + radix = RADIX_OCTAL + prefixLength = OCTAL_PREFIX_LENGTH + } + else -> { + radix = RADIX_DECIMAL + prefixLength = 0 + } + } + val digits = if (prefixLength == 0) part else part.substring(prefixLength) + return parseInRadix(digits, radix) + } + + /** + * Accumulates [digits] in [radix], capping at [UINT32_LIMIT] so an overflowing + * part still fails the later width check instead of wrapping; returns + * [Ipv4.INVALID_PART] on a digit outside the radix. Empty [digits] (the `0x` case) + * denotes zero. + */ + private fun parseInRadix( + digits: String, + radix: Int, + ): Long { + require(radix == RADIX_DECIMAL || radix == RADIX_OCTAL || radix == RADIX_HEX) { "bad radix $radix" } + var acc = 0L + for (ch in digits) { + val d = hexDigitToInt(ch) + acc = if (d in 0 until radix) minOf(acc * radix + d, UINT32_LIMIT) else Ipv4.INVALID_PART + if (acc == Ipv4.INVALID_PART) break + } + return acc + } + + /** Applies width-aware overflow ([HOST-22]) and packs the parts into 32 bits. */ + private fun assemble( + input: String, + numbers: List, + ): ParseResult { + val n = numbers.size + require(n in 1..MAX_PARTS) { "part count out of range: $n" } + val finalMax = 1L shl (ADDRESS_BITS - Ipv4.BITS_PER_OCTET * (n - 1)) + val headOk = numbers.dropLast(1).all { it <= Ipv4.MAX_OCTET } + val tailOk = numbers.last() < finalMax + return if (headOk && tailOk) { + ParseResult.Ok(Host.Ipv4(combine(numbers).toInt())) + } else { + Ipv4.fail(input, HostError.Ipv4Overflow) + } + } + + /** Folds the validated [numbers] into a single 32-bit value (final part absorbs the low bits). */ + private fun combine(numbers: List): Long { + require(numbers.isNotEmpty()) { "no octets to combine" } + return numbers.dropLast(1).foldIndexed(numbers.last()) { i, acc, value -> + acc + (value shl (Ipv4.BITS_PER_OCTET * (Ipv4.HIGH_OCTET_INDEX - i))) + } + } +} diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/HostParser.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/UriHostParser.kt similarity index 52% rename from kuri/src/commonMain/kotlin/org/dexpace/kuri/host/HostParser.kt rename to kuri/src/commonMain/kotlin/org/dexpace/kuri/host/UriHostParser.kt index 9210166..5d36907 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/HostParser.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/UriHostParser.kt @@ -4,22 +4,13 @@ */ package org.dexpace.kuri.host -import org.dexpace.kuri.ParseProfile import org.dexpace.kuri.error.HostError import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.error.UriParseError -import org.dexpace.kuri.idna.Idna -import org.dexpace.kuri.percent.PercentCodec import org.dexpace.kuri.text.hasPercentHexPairAt import org.dexpace.kuri.text.isAsciiHexDigit import org.dexpace.kuri.text.isUnreserved -/** Opening delimiter of an IP-literal; a host beginning with it dispatches to §7.2/§7.8. */ -private const val BRACKET_OPEN: Char = '[' - -/** Closing delimiter an IP-literal MUST carry ([HOST-4]/[HOST-5]). */ -private const val BRACKET_CLOSE: Char = ']' - /** Sentinel for "no offending index found" returned by the host scanners. */ private const val NOT_FOUND: Int = -1 @@ -36,112 +27,32 @@ private const val IP_FUTURE_DOT: Char = '.' private const val SUB_DELIMS: String = "!\$&'()*+,;=" /** - * The profile-aware host-parser dispatch of SPEC §7.1 ([HOST-3]). + * The RFC 3986 §3.2.2 host parser (SPEC §7.2/§7.5/§7.8, [HOST-3] row 5). * - * Composes the already-implemented host pieces — [Ipv6], [Ipv4], [OpaqueHost], - * [Idna], the forbidden-code-point tables, and [PercentCodec] — into the single - * ordered branch selection of [HOST-3], parameterized by [ParseProfile]. The `Url` - * profile runs the WHATWG host parser (IPv6 literal, opaque host, or the special - * domain/IPv4 pipeline); the `Uri` profile runs the RFC 3986 §3.2.2 grammar - * (`IP-literal` / `IPvFuture` / `IPv4address` / `reg-name`). No piece is - * reimplemented here; this layer only selects and validates. + * Composes the already-implemented host pieces — [Ipv6], [Ipv4], the forbidden-code-point tables — + * into the RFC grammar (`IP-literal` / `IPvFuture` / `IPv4address` / `reg-name`): a `[`-prefixed + * literal first, then an empty host, then the IPv4/reg-name split. No piece is reimplemented here; + * this layer only selects and validates. * - * The two profile pipelines are each a short ordered procedure; they are decomposed - * into single-purpose helpers (each well under the line/return budgets), which is the - * intent of the "small functions" rule and legitimately exceeds the per-object count. + * The pipeline is a short ordered procedure decomposed into single-purpose helpers (each well under + * the line/return budgets), which is the intent of the "small functions" rule and legitimately + * exceeds the per-object count. */ @Suppress("TooManyFunctions") -internal object HostParser { +internal object UriHostParser { /** - * Parses [input] (the host substring, brackets retained) into a [Host] under [profile] (§7.1). + * Parses [input] (the host substring, brackets retained) into a [Host] under the RFC 3986 rules. * - * The branch is selected by [HOST-3]: a `[`-prefixed literal first, then empty/opaque/domain - * per profile and specialness. Failures are returned as [ParseResult.Err] rather than thrown - * ([ERR-1]). + * The branch is selected by [HOST-3]: a `[`-prefixed literal first, then empty, then the + * IPv4/reg-name split. Failures are returned as [ParseResult.Err] rather than thrown ([ERR-1]). * * @param input the host text isolated by the authority splitter (§8), `[`/`]` still present. - * @param profile selects the WHATWG (`Url`) or RFC 3986 (`Uri`) acceptance rules. - * @param isSpecial whether the scheme is special; gates the `Url` domain/IPv4 pipeline ([HOST-3]). - * @param isFile whether the scheme is `file`; an empty `file` host is permitted ([HOST-39]). - * @param allowIpv6ZoneId whether an RFC 6874 `%25` IPv6 zone id is accepted (default off, [HOST-18]); - * honored by the `Uri` profile only. The `Url` profile ignores it and always rejects a zone id, - * since the WHATWG URL parser has no zone-id production ([HOST-17]). + * @param allowIpv6ZoneId whether an RFC 6874 `%25` IPv6 zone id is accepted (default off, [HOST-18]). * @return the parsed [Host], or a [UriParseError]-tagged failure. */ internal fun parse( input: String, - profile: ParseProfile, - isSpecial: Boolean, - isFile: Boolean = false, allowIpv6ZoneId: Boolean = false, - ): ParseResult = - if (profile.isWhatwg) { - parseUrlHost(input, isSpecial, isFile) - } else { - parseUriHost(input, allowIpv6ZoneId) - } - - // --- Url profile (WHATWG host parser, §7.2/§7.4/§7.5/§7.7) ------------------------ - - /** Ordered `Url`-profile dispatch ([HOST-3] rows 1–4): literal, opaque, empty, then domain. */ - private fun parseUrlHost( - input: String, - isSpecial: Boolean, - isFile: Boolean, - ): ParseResult = - when { - input.firstOrNull() == BRACKET_OPEN -> parseBracketedUrl(input) - !isSpecial -> OpaqueHost.parse(input) - input.isEmpty() -> if (isFile) ParseResult.Ok(Host.Empty) else ParseResult.Err(UriParseError.EmptyHost) - else -> parseSpecialDomain(input) - } - - /** Parses a `Url` `[`…`]` literal strictly as IPv6, rejecting any zone id; a missing `]` is fatal ([HOST-4]). */ - private fun parseBracketedUrl(input: String): ParseResult = - if (input.endsWith(BRACKET_CLOSE)) { - Ipv6.parse(bracketInterior(input)) - } else { - bracketError(input) - } - - /** - * The special-scheme domain pipeline (§7.4): UTF-8 percent-decode, run the WHATWG "domain to - * ASCII" wrapper ([Idna.domainToAsciiForUrl] — UTS-46 with the ASCII fast-path and empty-result - * rule), then classify the ASCII domain. A domain-to-ASCII failure propagates unchanged ([HOST-26]). - */ - private fun parseSpecialDomain(input: String): ParseResult { - require(input.isNotEmpty()) { "empty domain reached the IDNA pipeline" } - val decoded = PercentCodec.decode(input) - return when (val ascii = Idna.domainToAsciiForUrl(decoded)) { - is ParseResult.Err -> ascii - is ParseResult.Ok -> classifyAsciiDomain(ascii.value) - } - } - - /** - * Classifies the produced ASCII domain ([HOST-29]/[HOST-30]): a forbidden-domain code point is - * fatal, an ends-in-a-number host parses as IPv4, otherwise it is a lowercase-ASCII [Host.RegName]. - */ - private fun classifyAsciiDomain(asciiDomain: String): ParseResult { - val forbiddenAt = firstForbiddenDomainIndex(asciiDomain) - check(forbiddenAt == NOT_FOUND || forbiddenAt in asciiDomain.indices) { "bad index: $forbiddenAt" } - return when { - forbiddenAt != NOT_FOUND -> - ParseResult.Err(UriParseError.ForbiddenHostCodePoint(asciiDomain[forbiddenAt].code, forbiddenAt)) - Ipv4.endsInANumber(asciiDomain) -> Ipv4.parse(asciiDomain, ParseProfile.URL) - else -> ParseResult.Ok(Host.RegName(asciiDomain)) - } - } - - /** Index of the first forbidden-domain code point in [domain] (§7.6 [HOST-37]), or [NOT_FOUND] (`-1`). */ - private fun firstForbiddenDomainIndex(domain: String): Int = domain.indexOfFirst { isForbiddenDomainCodePoint(it) } - - // --- Uri profile (RFC 3986 §3.2.2 host grammar, §7.2/§7.5/§7.8) ------------------- - - /** Ordered `Uri`-profile dispatch ([HOST-3] row 5): IP-literal, empty host, then IPv4/reg-name. */ - private fun parseUriHost( - input: String, - allowIpv6ZoneId: Boolean, ): ParseResult = when { input.firstOrNull() == BRACKET_OPEN -> parseBracketedUri(input, allowIpv6ZoneId) @@ -152,7 +63,7 @@ internal object HostParser { /** A `Uri` non-bracket host is an `IPv4address` when it parses as one, else an RFC reg-name. */ private fun parseUriHostNonBracket(input: String): ParseResult { require(input.isNotEmpty()) { "empty host reached the IPv4/reg-name split" } - val asIpv4 = Ipv4.parse(input, ParseProfile.URI) + val asIpv4 = Ipv4Rfc3986.parse(input) return if (asIpv4.isOk()) asIpv4 else validateRegNameRfc(input) } @@ -251,18 +162,6 @@ internal object HostParser { } } - // --- shared helpers -------------------------------------------------------------- - - /** Strips the surrounding `[`/`]`, returning the bracket contents ([HOST-4]/[HOST-5]). */ - private fun bracketInterior(input: String): String { - require(input.length >= 2) { "bracketed literal too short: $input" } - return input.substring(1, input.length - 1) - } - - /** The malformed-literal failure for a bracketed host that does not close ([HOST-4]/[HOST-5]). */ - private fun bracketError(input: String): ParseResult = - ParseResult.Err(UriParseError.InvalidHost(input, HostError.Ipv6Malformed)) - /** True when [c] is an RFC 3986 `sub-delims` code point. */ private fun isSubDelim(c: Char): Boolean = c in SUB_DELIMS diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/UrlHostParser.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/UrlHostParser.kt new file mode 100644 index 0000000..9560797 --- /dev/null +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/UrlHostParser.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.host + +import org.dexpace.kuri.error.ParseResult +import org.dexpace.kuri.error.UriParseError +import org.dexpace.kuri.idna.Idna +import org.dexpace.kuri.percent.PercentCodec + +/** Sentinel for "no offending index found" returned by the host scanners. */ +private const val NOT_FOUND: Int = -1 + +/** + * The WHATWG host parser of SPEC §7.2/§7.4/§7.5/§7.7 ([HOST-3] rows 1–4). + * + * Composes the already-implemented host pieces — [Ipv6], [Ipv4], [OpaqueHost], [Idna], the + * forbidden-code-point tables, and [PercentCodec] — into the ordered `Url` branch selection: a + * `[`-prefixed IPv6 literal, then an opaque host for non-special schemes, an empty (`file`) host, + * or the special domain/IPv4 pipeline. No piece is reimplemented here; this layer only selects and + * validates. + */ +internal object UrlHostParser { + /** + * Parses [input] (the host substring, brackets retained) into a [Host] under the WHATWG rules. + * + * The branch is selected by [HOST-3]: a `[`-prefixed literal first, then opaque/empty/domain by + * specialness. Failures are returned as [ParseResult.Err] rather than thrown ([ERR-1]). + * + * @param input the host text isolated by the authority splitter (§8), `[`/`]` still present. + * @param isSpecial whether the scheme is special; gates the domain/IPv4 pipeline ([HOST-3]). + * @param isFile whether the scheme is `file`; an empty `file` host is permitted ([HOST-39]). + * @return the parsed [Host], or a [UriParseError]-tagged failure. + */ + internal fun parse( + input: String, + isSpecial: Boolean, + isFile: Boolean = false, + ): ParseResult = + when { + input.firstOrNull() == BRACKET_OPEN -> parseBracketedUrl(input) + !isSpecial -> OpaqueHost.parse(input) + input.isEmpty() -> if (isFile) ParseResult.Ok(Host.Empty) else ParseResult.Err(UriParseError.EmptyHost) + else -> parseSpecialDomain(input) + } + + /** Parses a `Url` `[`…`]` literal strictly as IPv6, rejecting any zone id; a missing `]` is fatal ([HOST-4]). */ + private fun parseBracketedUrl(input: String): ParseResult = + if (input.endsWith(BRACKET_CLOSE)) { + Ipv6.parse(bracketInterior(input)) + } else { + bracketError(input) + } + + /** + * The special-scheme domain pipeline (§7.4): UTF-8 percent-decode, run the WHATWG "domain to + * ASCII" wrapper ([Idna.domainToAsciiForUrl] — UTS-46 with the ASCII fast-path and empty-result + * rule), then classify the ASCII domain. A domain-to-ASCII failure propagates unchanged ([HOST-26]). + */ + private fun parseSpecialDomain(input: String): ParseResult { + require(input.isNotEmpty()) { "empty domain reached the IDNA pipeline" } + val decoded = PercentCodec.decode(input) + return when (val ascii = Idna.domainToAsciiForUrl(decoded)) { + is ParseResult.Err -> ascii + is ParseResult.Ok -> classifyAsciiDomain(ascii.value) + } + } + + /** + * Classifies the produced ASCII domain ([HOST-29]/[HOST-30]): a forbidden-domain code point is + * fatal, an ends-in-a-number host parses as IPv4, otherwise it is a lowercase-ASCII [Host.RegName]. + */ + private fun classifyAsciiDomain(asciiDomain: String): ParseResult { + val forbiddenAt = firstForbiddenDomainIndex(asciiDomain) + check(forbiddenAt == NOT_FOUND || forbiddenAt in asciiDomain.indices) { "bad index: $forbiddenAt" } + return when { + forbiddenAt != NOT_FOUND -> + ParseResult.Err(UriParseError.ForbiddenHostCodePoint(asciiDomain[forbiddenAt].code, forbiddenAt)) + Ipv4.endsInANumber(asciiDomain) -> Ipv4Whatwg.parse(asciiDomain) + else -> ParseResult.Ok(Host.RegName(asciiDomain)) + } + } + + /** Index of the first forbidden-domain code point in [domain] (§7.6 [HOST-37]), or [NOT_FOUND] (`-1`). */ + private fun firstForbiddenDomainIndex(domain: String): Int = domain.indexOfFirst { isForbiddenDomainCodePoint(it) } +} diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlPath.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ComponentPath.kt similarity index 90% rename from kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlPath.kt rename to kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ComponentPath.kt index cb2ad22..d3af85d 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlPath.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ComponentPath.kt @@ -49,10 +49,10 @@ package org.dexpace.kuri.parser * would leak into the future public surface); the parser/serializer modules that * construct them are responsible for upholding the table above. */ -internal sealed interface UrlPath { +internal sealed interface ComponentPath { /** * A hierarchical path as an ordered list of decoded segments (SPEC §3.7, - * [MODEL-26]/[MODEL-27]; WHATWG URL "path" list). See [UrlPath] for the + * [MODEL-26]/[MODEL-27]; WHATWG URL "path" list). See [ComponentPath] for the * empty-path (`emptyList()`) vs. root-only (`listOf("")`) convention. * * @property segments the decoded path segments in order; `emptyList()` is the @@ -65,7 +65,7 @@ internal sealed interface UrlPath { data class Segments( val segments: List, val rooted: Boolean = true, - ) : UrlPath + ) : ComponentPath /** * A "cannot-be-a-base" / opaque path stored verbatim as one encoded string with @@ -78,15 +78,15 @@ internal sealed interface UrlPath { */ data class Opaque( val path: String, - ) : UrlPath + ) : ComponentPath } /** The RFC 3986 §3.3 path-segment separator shared by the `Uri`-profile path helpers below. */ private const val URI_PATH_SEPARATOR: String = "/" /** - * Encodes these [UrlPath.Segments] back to their RFC 3986 §5.3 path string: `""` for the empty - * path, an absolute `/`-prefixed join when [rooted][UrlPath.Segments.rooted], or a bare rootless + * Encodes these [ComponentPath.Segments] back to their RFC 3986 §5.3 path string: `""` for the empty + * path, an absolute `/`-prefixed join when [rooted][ComponentPath.Segments.rooted], or a bare rootless * join otherwise. * * The empty check comes FIRST so a root-only path (`Segments(listOf(""))`, encoded `"/"`) stays @@ -94,7 +94,7 @@ private const val URI_PATH_SEPARATOR: String = "/" * selects the absolute (`/a/b`) versus rootless (`a/b`) form, so a relative reference (`a/b`) and a * scheme-rootless path (`mailto:a@b`) each round-trip unchanged. */ -internal fun UrlPath.Segments.toUriPathString(): String = +internal fun ComponentPath.Segments.toUriPathString(): String = when { segments.isEmpty() -> "" rooted -> URI_PATH_SEPARATOR + segments.joinToString(URI_PATH_SEPARATOR) @@ -102,20 +102,20 @@ internal fun UrlPath.Segments.toUriPathString(): String = } /** - * Encodes any [UrlPath] back to its RFC 3986 §5.3 string form: an [Opaque][UrlPath.Opaque] path - * verbatim (no `/` guard, never dot-collapsed), else the [Segments][UrlPath.Segments] join. + * Encodes any [ComponentPath] back to its RFC 3986 §5.3 string form: an [Opaque][ComponentPath.Opaque] path + * verbatim (no `/` guard, never dot-collapsed), else the [Segments][ComponentPath.Segments] join. * * The smart-cast `Segments` receiver dispatches to the more specific [toUriPathString] overload, so * this is a dispatch, not a recursion. */ -internal fun UrlPath.toUriPathString(): String = +internal fun ComponentPath.toUriPathString(): String = when (this) { - is UrlPath.Opaque -> path - is UrlPath.Segments -> toUriPathString() + is ComponentPath.Opaque -> path + is ComponentPath.Segments -> toUriPathString() } /** - * Splits a raw RFC 3986 path string into [UrlPath.Segments], mirroring the parser's empty-path vs. + * Splits a raw RFC 3986 path string into [ComponentPath.Segments], mirroring the parser's empty-path vs. * single-empty-segment conventions (SPEC §3.7, [MODEL-26]/[MODEL-27]). * * An empty path is `emptyList()`; an absolute path drops the leading empty element so root-only `/` @@ -124,11 +124,11 @@ internal fun UrlPath.toUriPathString(): String = * needed; the `rooted` flag records the absolute (leading `/`) versus rootless distinction so the * serializer can round-trip it faithfully. */ -internal fun splitUriPath(path: String): UrlPath.Segments = +internal fun splitUriPath(path: String): ComponentPath.Segments = when { - path.isEmpty() -> UrlPath.Segments(emptyList()) - path.startsWith(URI_PATH_SEPARATOR) -> UrlPath.Segments(path.substring(1).split('/'), rooted = true) - else -> UrlPath.Segments(path.split('/'), rooted = false) + path.isEmpty() -> ComponentPath.Segments(emptyList()) + path.startsWith(URI_PATH_SEPARATOR) -> ComponentPath.Segments(path.substring(1).split('/'), rooted = true) + else -> ComponentPath.Segments(path.split('/'), rooted = false) } /** @@ -158,19 +158,19 @@ internal fun fileExtensionOf(name: String): String { /** * The decoded path segments of [path], each percent-decoded through [decode]; an - * [Opaque][UrlPath.Opaque] path has no segment structure and yields its single decoded value as a + * [Opaque][ComponentPath.Opaque] path has no segment structure and yields its single decoded value as a * one-element list. Shared by the `Uri`/`Url` `pathSegments` projections. * * Takes the decoder as a lambda so this helper stays free of the percent-codec dependency, the same * convention [appendPathSegments] follows with its `encode` lambda. */ internal fun decodedSegments( - path: UrlPath, + path: ComponentPath, decode: (String) -> String, ): List = when (path) { - is UrlPath.Opaque -> listOf(decode(path.path)) - is UrlPath.Segments -> path.segments.map(decode) + is ComponentPath.Opaque -> listOf(decode(path.path)) + is ComponentPath.Segments -> path.segments.map(decode) } /** @@ -239,7 +239,7 @@ internal enum class PathRooting { * mutating a segment list while separately threading the rooting back out. Path lengths are small, so * copying the segment list on each transform is cheap. * - * See [UrlPath] for the empty-path (`emptyList()`) versus root-only (`listOf("")`) segment convention, and + * See [ComponentPath] for the empty-path (`emptyList()`) versus root-only (`listOf("")`) segment convention, and * [PathRooting] for how [rooting] resolves to a leading `/` at build time. * * @property segments the decoded path segments in order; `emptyList()` is the empty path and every empty @@ -352,10 +352,10 @@ internal data class BuilderPath( */ fun effectivePath(hasHost: Boolean): String = when (rooting) { - PathRooting.ROOTED -> UrlPath.Segments(segments, rooted = true).toUriPathString() - PathRooting.ROOTLESS -> UrlPath.Segments(segments, rooted = false).toUriPathString() + PathRooting.ROOTED -> ComponentPath.Segments(segments, rooted = true).toUriPathString() + PathRooting.ROOTLESS -> ComponentPath.Segments(segments, rooted = false).toUriPathString() PathRooting.DEFERRED -> { - val rootless = UrlPath.Segments(segments, rooted = false).toUriPathString() + val rootless = ComponentPath.Segments(segments, rooted = false).toUriPathString() if (hasHost) URI_PATH_SEPARATOR + rootless else rootless } } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ParsedComponents.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ParsedComponents.kt index 4438039..66479e4 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ParsedComponents.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ParsedComponents.kt @@ -23,8 +23,8 @@ import org.dexpace.kuri.host.Host * present but the host is the empty string ([MODEL-15]). * - [port] `null` = unspecified or (`Url` profile) elided default ([MODEL-23], * [MODEL-25]); never a `-1`/`0` sentinel. - * - [path] never `null` ([MODEL-26]); the empty path is `UrlPath.Segments(emptyList())` - * (see [UrlPath] for the empty-vs-root convention). + * - [path] never `null` ([MODEL-26]); the empty path is `ComponentPath.Segments(emptyList())` + * (see [ComponentPath] for the empty-vs-root convention). * - [query] `null` = no `?`; `""` = `?` present with empty content ([MODEL-30]). * - [fragment] `null` = no `#`; `""` = `#` present with empty content ([PARSE-8]). * @@ -34,7 +34,7 @@ import org.dexpace.kuri.host.Host * the userinfo percent-encode set on serialization (§5). * * As a value record this type performs no inline validation — the §7/§8 modules that - * populate it own the component invariants (mirroring [Host] and [UrlPath]). + * populate it own the component invariants (mirroring [Host] and [ComponentPath]). * * @property scheme the parsed scheme (lowercased for storage in the `Url` profile), * or `null` for a `Uri` relative reference. @@ -57,7 +57,7 @@ internal data class ParsedComponents( val password: String = "", val host: Host? = null, val port: Int? = null, - val path: UrlPath = UrlPath.Segments(emptyList()), + val path: ComponentPath = ComponentPath.Segments(emptyList()), val query: String? = null, val fragment: String? = null, val validationErrors: List = emptyList(), diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt index 18cdcb3..eaaf716 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt @@ -58,7 +58,7 @@ private const val SEGMENT_DOTDOT: String = ".." * * The string entry point splits each input into its raw five parts directly (Appendix B) and resolves * over those raw path strings, where the absolute (`/g`) versus rootless (`g`) distinction that is - * load-bearing for §5.2.2 is plainly visible. [UrlPath.Segments] now records that same distinction via + * load-bearing for §5.2.2 is plainly visible. [ComponentPath.Segments] now records that same distinction via * its `rooted` flag, so the structured component form preserves it too; the raw-string path is kept * because it operates directly on the input as Appendix B specifies and remains correct. [UriParser] * is still run on both inputs first so the resolution shares the parser's validation and absolute-base @@ -121,7 +121,7 @@ internal object Resolver { * Resolves [reference] against the absolute [base] on the structured component model (§5.2), * the form the future public `Uri.resolve` builds on. * - * [UrlPath.Segments] records the absolute-vs-rootless distinction of a path via its `rooted` flag + * [ComponentPath.Segments] records the absolute-vs-rootless distinction of a path via its `rooted` flag * (`/g` is rooted, `g` is not), so [toUriPathString] reproduces each form faithfully and this structured * resolution preserves the distinction through to the recomposed target. * diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt index 158d8b8..e3f7513 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt @@ -5,11 +5,10 @@ package org.dexpace.kuri.parser import org.dexpace.kuri.ParseOptions -import org.dexpace.kuri.ParseProfile import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.error.UriParseError import org.dexpace.kuri.host.Host -import org.dexpace.kuri.host.HostParser +import org.dexpace.kuri.host.UriHostParser import org.dexpace.kuri.scheme.Scheme import org.dexpace.kuri.scheme.isSchemeContinuationChar import org.dexpace.kuri.scheme.schemeColonIndex @@ -265,7 +264,7 @@ internal object UriParser { val (host, portText) = splitHostPort(hostPort) return when ( val parsed = - HostParser.parse(host, ParseProfile.URI, isSpecial = false, allowIpv6ZoneId = options.allowIpv6ZoneId) + UriHostParser.parse(host, allowIpv6ZoneId = options.allowIpv6ZoneId) ) { is ParseResult.Err -> parsed is ParseResult.Ok -> attachPort(username, password, parsed.value, portText) 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 1520bdd..9a1d3c6 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParser.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParser.kt @@ -214,7 +214,8 @@ internal object UrlParser { /** Snapshots the mutable [state] into the immutable [ParsedComponents] result (§3, §8). */ private fun finalize(state: UrlParserState): ParsedComponents { - val path = if (state.isOpaque) UrlPath.Opaque(state.opaque) else UrlPath.Segments(state.path.toList()) + val path = + if (state.isOpaque) ComponentPath.Opaque(state.opaque) else ComponentPath.Segments(state.path.toList()) val fragment = state.fragmentRaw?.let { PercentCodec.encode(it, PercentEncodeSets.FRAGMENT) } return ParsedComponents( scheme = state.scheme, 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 17bb2f7..28fe6d6 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt @@ -4,12 +4,11 @@ */ package org.dexpace.kuri.parser -import org.dexpace.kuri.ParseProfile import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.error.UriParseError import org.dexpace.kuri.error.ValidationError import org.dexpace.kuri.host.Host -import org.dexpace.kuri.host.HostParser +import org.dexpace.kuri.host.UrlHostParser import org.dexpace.kuri.percent.PercentCodec import org.dexpace.kuri.percent.PercentEncodeSets import org.dexpace.kuri.scheme.Scheme @@ -130,7 +129,7 @@ internal object UrlParserAuthority { * HOST (§8.3 [PARSE-29]–[PARSE-31]; WHATWG host/hostname state), forward-scanning. * * Scans to the first `:` outside brackets (→ PORT) or the first path terminator (→ PATH_START), - * isolates the host slice, and delegates classification to [HostParser]. An empty special-scheme + * isolates the host slice, and delegates classification to [UrlHostParser]. An empty special-scheme * host is fatal ([PARSE-31]). */ internal fun hostState(state: UrlParserState): UrlTransition { @@ -197,7 +196,7 @@ internal object UrlParserAuthority { 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. */ + /** Parses [hostSlice] via [UrlHostParser] and advances to PORT (on `:`) or PATH_START. */ private fun parseAndStoreHost( state: UrlParserState, hostSlice: String, @@ -232,9 +231,8 @@ internal object UrlParserAuthority { if (hostSlice.isEmpty()) { ParseResult.Ok(Host.Empty) } else { - HostParser.parse( + UrlHostParser.parse( hostSlice, - ParseProfile.URL, isSpecial = state.special, isFile = false, ) @@ -430,7 +428,7 @@ internal object UrlParserAuthority { state: UrlParserState, base: ParsedComponents, ) { - val baseSegments = (base.path as? UrlPath.Segments)?.segments ?: emptyList() + val baseSegments = (base.path as? ComponentPath.Segments)?.segments ?: emptyList() val firstIsDrive = baseSegments.isNotEmpty() && isNormalizedWindowsDrive(baseSegments[0]) if (!startsWithWindowsDrive(state.fromCurrent()) && firstIsDrive) { state.path.add(baseSegments[0]) @@ -491,9 +489,8 @@ internal object UrlParserAuthority { ): UrlTransition = when ( val host = - HostParser.parse( + UrlHostParser.parse( buffer, - ParseProfile.URL, isSpecial = true, isFile = true, ) 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 e0c9636..fef2804 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserHelpers.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserHelpers.kt @@ -116,7 +116,7 @@ internal fun appendPathSegment( * fragment-only branch, [PARSE-18]); a `null` base yields an empty list. */ internal fun cloneBasePath(base: ParsedComponents?): MutableList { - val segments = (base?.path as? UrlPath.Segments)?.segments ?: emptyList() + val segments = (base?.path as? ComponentPath.Segments)?.segments ?: emptyList() return segments.toMutableList() } 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 e74b535..8282ec9 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserState.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserState.kt @@ -95,11 +95,11 @@ internal class UrlParserState( host = seed.host port = seed.port when (val seedPath = seed.path) { - is UrlPath.Opaque -> { + is ComponentPath.Opaque -> { isOpaque = true opaque = seedPath.path } - is UrlPath.Segments -> path.addAll(seedPath.segments) + is ComponentPath.Segments -> path.addAll(seedPath.segments) } query = seed.query stateOverride = override 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 017f842..df09d5f 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt @@ -168,7 +168,7 @@ internal object UrlParserStates { val c = state.currentChar() return when { base == null -> UrlTransition.Fail(UriParseError.MissingScheme) - base.path is UrlPath.Opaque -> noSchemeOpaqueBase(state, base, c) + base.path is ComponentPath.Opaque -> noSchemeOpaqueBase(state, base, c) base.scheme != FILE_SCHEME -> UrlTransition.Reconsume(UrlState.RELATIVE) else -> UrlTransition.Reconsume(UrlState.FILE) } @@ -194,7 +194,7 @@ internal object UrlParserStates { ) { state.scheme = base.scheme state.isOpaque = true - state.opaque = (base.path as UrlPath.Opaque).path + state.opaque = (base.path as ComponentPath.Opaque).path state.query = base.query } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/SerializeShared.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/SerializeShared.kt new file mode 100644 index 0000000..84a2e8b --- /dev/null +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/SerializeShared.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.serialize + +import org.dexpace.kuri.host.serializeHost +import org.dexpace.kuri.parser.ParsedComponents + +/** The path-segment separator and the per-segment prefix used by both path serializers. */ +internal const val SLASH: String = "/" + +/** The authority-introducing prefix emitted only when a value has an authority ([NORM-17]). */ +internal const val DOUBLE_SLASH: String = "//" + +/** + * §11.2 [NORM-18] leading-`/.` guard prepended to a hierarchical no-authority path that opens `//`. + * + * Shared by [UriSerializer]'s `guardRecomposedUriPath` and [UrlSerializer]'s `serializeUrlSegments`: + * both profiles must guard the same re-parse hazard, so the literal has one home. + */ +internal const val LEADING_DOT_GUARD: String = "/." + +/** + * §11.2 [NORM-16] authority serializer `[userinfo "@"] host [":" port]`; the single home shared by + * both profiles' authority rendering ([UriSerializer], [UrlSerializer], and `Url.authority`). + * + * @param components the stored components whose authority is rendered; MUST carry a non-null host. + * @return the authority text `[userinfo@]host[:port]`. + */ +internal fun serializeAuthority(components: ParsedComponents): String { + val host = requireNotNull(components.host) { "authority serialization requires a host" } + val port = if (components.port != null) ":${components.port}" else "" + return credentialsPrefix(components) + serializeHost(host) + port +} + +/** + * The `userinfo@` prefix ([NORM-16] / [NORM-30]); empty for a value with no credentials. + * + * The WHATWG "includes credentials" rule and the RFC null/empty rule coincide here because + * [ParsedComponents] holds the credentials as (possibly empty) strings, never null: the prefix + * appears iff `username` or `password` is non-empty, and `:password` iff `password` is non-empty. + */ +private fun credentialsPrefix(components: ParsedComponents): String { + if (components.username.isEmpty() && components.password.isEmpty()) return "" + val password = if (components.password.isNotEmpty()) ":${components.password}" else "" + return "${components.username}$password@" +} + +/** + * Appends `?query` then (unless excluded) `#fragment`, each only when its component is present. + * + * Shared by [UriSerializer.serialize] and [UrlSerializer.serialize]: the query/fragment tail is + * identical across both profiles ([NORM-15]). + */ +internal fun appendQueryFragment( + sb: StringBuilder, + c: ParsedComponents, + excludeFragment: Boolean, +) { + if (c.query != null) sb.append('?').append(c.query) + if (!excludeFragment && c.fragment != null) sb.append('#').append(c.fragment) +} diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/Serializer.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/Serializer.kt deleted file mode 100644 index cd4e0d8..0000000 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/Serializer.kt +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright (c) 2026 dexpace and Omar Aljarrah - * SPDX-License-Identifier: MIT - */ -package org.dexpace.kuri.serialize - -import org.dexpace.kuri.ParseProfile -import org.dexpace.kuri.host.serializeHost -import org.dexpace.kuri.parser.ParsedComponents -import org.dexpace.kuri.parser.UrlPath -import org.dexpace.kuri.parser.toUriPathString -import org.dexpace.kuri.scheme.schemeColonIndex - -/** The path-segment separator and the per-segment prefix used by both path serializers. */ -private const val SLASH: String = "/" - -/** The authority-introducing prefix emitted only when a value has an authority ([NORM-17]). */ -private const val DOUBLE_SLASH: String = "//" - -/** §11.2 [NORM-18] leading-`/.` guard prepended to a hierarchical no-authority path that opens `//`. */ -private const val LEADING_DOT_GUARD: String = "/." - -/** - * The RFC 3986 §4.2 dot-segment guard prepended to a scheme-less, authority-less path whose first - * segment contains a colon, so it does not re-parse as a scheme ([NORM-18]). - */ -private const val COLON_SEGMENT_GUARD: String = "./" - -/** - * Guards a recomposed `Uri` path so the serialized string re-parses to the same structure (RFC 3986 - * §4.2; [NORM-18]): with no authority, a `//`-leading path gets the `/.` guard and a scheme-less - * path whose first segment contains `:` gets the `./` guard. Both guards fire only on component - * states the parser never produces, so a parsed value round-trips unchanged. - */ -internal fun guardRecomposedUriPath( - scheme: String?, - hasAuthority: Boolean, - path: String, -): String = - when { - hasAuthority -> path - path.startsWith(DOUBLE_SLASH) -> LEADING_DOT_GUARD + path - scheme == null && schemeColonIndex(path) >= 0 -> COLON_SEGMENT_GUARD + path - else -> path - } - -/** - * The §11.2 recomposition of stored components back into the canonical string (SPEC [NORM-15].. - * [NORM-18], [NORM-30], [NORM-31]; RFC 3986 §5.3; WHATWG URL serializer). - * - * The recomposition order — `scheme:`, authority, path, `?query`, `#fragment` — is shared by both - * profiles ([NORM-15]); the profiles differ only in the path rule and the (here identical) userinfo - * rule. The `Url` branch follows the WHATWG URL serializer, including the `/.` guard for a - * no-authority hierarchical path that begins `//`; the `Uri` branch follows RFC 3986 §5.3 over the - * preserved (or explicitly normalized) components and applies the same leading-`/.` guard plus the - * §4.2 `./` dot-segment guard for a scheme-less, authority-less colon-first path, so every serialized - * `Uri` (including `normalized()`/`resolve()` output) re-parses to the same structure. - */ -internal object Serializer { - /** - * Recomposes [c] into its canonical serialization under [profile] ([NORM-15]). - * - * @param c the stored components to serialize; already canonical for a `Url`, preserved or - * explicitly normalized for a `Uri`. - * @param profile selects the WHATWG URL serializer ([ParseProfile.URL]) versus the RFC 3986 - * §5.3 recomposition ([ParseProfile.URI]). - * @param excludeFragment when `true`, the trailing `#fragment` is omitted ([NORM-31]); the - * basis for fragment-insensitive equality. - * @return the canonical string form of [c]. - */ - internal fun serialize( - c: ParsedComponents, - profile: ParseProfile, - excludeFragment: Boolean = false, - ): String = if (profile.isWhatwg) serializeUrl(c, excludeFragment) else serializeUri(c, excludeFragment) - - /** The WHATWG URL serializer ([NORM-15], [NORM-30]); a `Url` always carries a non-null scheme. */ - private fun serializeUrl( - c: ParsedComponents, - excludeFragment: Boolean, - ): String { - val scheme = requireNotNull(c.scheme) { "a Url value always carries a scheme" } - val sb = StringBuilder() - sb.append(scheme).append(':') - if (c.host != null) sb.append(DOUBLE_SLASH).append(serializeAuthority(c)) - sb.append(serializeUrlPath(c)) - appendQueryFragment(sb, c, excludeFragment) - check(sb.startsWith(scheme)) { "serialization must open with the scheme" } - return sb.toString() - } - - /** RFC 3986 §5.3 recomposition for the `Uri` profile; the scheme may be absent (relative ref). */ - private fun serializeUri( - c: ParsedComponents, - excludeFragment: Boolean, - ): String { - val sb = StringBuilder() - if (c.scheme != null) sb.append(c.scheme).append(':') - if (c.host != null) sb.append(DOUBLE_SLASH).append(serializeAuthority(c)) - sb.append(guardRecomposedUriPath(c.scheme, c.host != null, c.path.toUriPathString())) - appendQueryFragment(sb, c, excludeFragment) - check(c.host == null || sb.contains(DOUBLE_SLASH)) { "an authority must emit //" } - return sb.toString() - } - - /** Appends `?query` then (unless excluded) `#fragment`, each only when its component is present. */ - private fun appendQueryFragment( - sb: StringBuilder, - c: ParsedComponents, - excludeFragment: Boolean, - ) { - if (c.query != null) sb.append('?').append(c.query) - if (!excludeFragment && c.fragment != null) sb.append('#').append(c.fragment) - } -} - -/** - * §11.2 [NORM-16] authority serializer `[userinfo "@"] host [":" port]`; the single home shared by both - * profiles' URL-side authority rendering (the `Serializer` object and `Url.authority`). - * - * @param components the stored components whose authority is rendered; MUST carry a non-null host. - * @return the authority text `[userinfo@]host[:port]`. - */ -internal fun serializeAuthority(components: ParsedComponents): String { - val host = requireNotNull(components.host) { "authority serialization requires a host" } - val port = if (components.port != null) ":${components.port}" else "" - return credentialsPrefix(components) + serializeHost(host) + port -} - -/** - * The `userinfo@` prefix ([NORM-16] / [NORM-30]); empty for a value with no credentials. - * - * The WHATWG "includes credentials" rule and the RFC null/empty rule coincide here because - * [ParsedComponents] holds the credentials as (possibly empty) strings, never null: the prefix - * appears iff `username` or `password` is non-empty, and `:password` iff `password` is non-empty. - */ -private fun credentialsPrefix(components: ParsedComponents): String { - if (components.username.isEmpty() && components.password.isEmpty()) return "" - val password = if (components.password.isNotEmpty()) ":${components.password}" else "" - return "${components.username}$password@" -} - -/** - * WHATWG URL path serialization: an opaque path verbatim, else the segment list ([NORM-15] step 3). - * - * The single home shared by the `Serializer` object and `Url.encodedPath`. - * - * @param components the stored components whose path is rendered. - * @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, - guardAgainstAuthority: Boolean = true, -): String = - when (val path = components.path) { - is UrlPath.Opaque -> path.path - 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 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 = 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/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt index d9a493f..0d15f65 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt @@ -5,9 +5,9 @@ package org.dexpace.kuri.serialize import org.dexpace.kuri.host.Host +import org.dexpace.kuri.parser.ComponentPath import org.dexpace.kuri.parser.ParsedComponents import org.dexpace.kuri.parser.Resolver -import org.dexpace.kuri.parser.UrlPath import org.dexpace.kuri.parser.splitUriPath import org.dexpace.kuri.parser.toUriPathString import org.dexpace.kuri.scheme.Scheme @@ -19,9 +19,6 @@ import org.dexpace.kuri.text.percentByteAt /** Length of a percent-encoded triplet `%XY`; hoisted so the triplet scanners carry no bare `3`. */ private const val TRIPLET_LENGTH: Int = 3 -/** Path-segment separator used when re-stringifying and re-splitting a hierarchical path. */ -private const val SLASH: String = "/" - /** * The RFC 3986 §6.2 syntax-based and scheme-based normalizations for the `Uri` profile (SPEC §11.1 * [NORM-3]..[NORM-10]; RFC 3986 §6.2.2, §6.2.3). @@ -164,19 +161,19 @@ internal object UriNormalizer { /** §6.2.2.3 / [NORM-9]: normalizes a hierarchical path; an opaque path is never dot-collapsed. */ private fun normalizePath( - path: UrlPath, + path: ComponentPath, hasAuthority: Boolean, - ): UrlPath = + ): ComponentPath = when (path) { - is UrlPath.Opaque -> path - is UrlPath.Segments -> normalizeSegments(path, hasAuthority) + is ComponentPath.Opaque -> path + is ComponentPath.Segments -> normalizeSegments(path, hasAuthority) } /** Normalizes the path string (triplets, then [Resolver.removeDotSegments]) and re-splits it. */ private fun normalizeSegments( - path: UrlPath.Segments, + path: ComponentPath.Segments, hasAuthority: Boolean, - ): UrlPath { + ): ComponentPath { val cleaned = Resolver.removeDotSegments(normalizeText(path.toUriPathString())) val rendered = if (cleaned.isEmpty() && hasAuthority) SLASH else cleaned check(!(hasAuthority && rendered.isEmpty())) { "an authority path must render as at least /" } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriSerializer.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriSerializer.kt new file mode 100644 index 0000000..d8ae647 --- /dev/null +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriSerializer.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.serialize + +import org.dexpace.kuri.parser.ParsedComponents +import org.dexpace.kuri.parser.toUriPathString +import org.dexpace.kuri.scheme.schemeColonIndex + +/** + * The RFC 3986 §5.3 recomposition of stored components back into the canonical string (SPEC + * [NORM-15]..[NORM-18], [NORM-30], [NORM-31]; RFC 3986 §5.3). + * + * The recomposition order — `scheme:`, authority, path, `?query`, `#fragment` — is shared with + * [UrlSerializer] ([NORM-15]); the profiles differ only in the path rule and the (here identical) + * userinfo rule. This profile follows RFC 3986 §5.3 over the preserved (or explicitly normalized) + * components and applies the leading-`/.` guard plus the §4.2 `./` dot-segment guard for a + * scheme-less, authority-less colon-first path, so every serialized `Uri` (including + * `normalized()`/`resolve()` output) re-parses to the same structure. + */ +internal object UriSerializer { + /** + * Recomposes [c] into its canonical RFC 3986 §5.3 serialization ([NORM-15]). + * + * @param c the stored components to serialize; preserved or explicitly normalized for a `Uri`. + * @param excludeFragment when `true`, the trailing `#fragment` is omitted ([NORM-31]); the + * basis for fragment-insensitive equality. + * @return the canonical string form of [c]. + */ + internal fun serialize( + c: ParsedComponents, + excludeFragment: Boolean = false, + ): String { + val sb = StringBuilder() + if (c.scheme != null) sb.append(c.scheme).append(':') + if (c.host != null) sb.append(DOUBLE_SLASH).append(serializeAuthority(c)) + sb.append(guardRecomposedUriPath(c.scheme, c.host != null, c.path.toUriPathString())) + appendQueryFragment(sb, c, excludeFragment) + check(c.host == null || sb.contains(DOUBLE_SLASH)) { "an authority must emit //" } + return sb.toString() + } +} + +/** + * The RFC 3986 §4.2 dot-segment guard prepended to a scheme-less, authority-less path whose first + * segment contains a colon, so it does not re-parse as a scheme ([NORM-18]). + */ +private const val COLON_SEGMENT_GUARD: String = "./" + +/** + * Guards a recomposed `Uri` path so the serialized string re-parses to the same structure (RFC 3986 + * §4.2; [NORM-18]): with no authority, a `//`-leading path gets the `/.` guard and a scheme-less + * path whose first segment contains `:` gets the `./` guard. Both guards fire only on component + * states the parser never produces, so a parsed value round-trips unchanged. + */ +internal fun guardRecomposedUriPath( + scheme: String?, + hasAuthority: Boolean, + path: String, +): String = + when { + hasAuthority -> path + path.startsWith(DOUBLE_SLASH) -> LEADING_DOT_GUARD + path + scheme == null && schemeColonIndex(path) >= 0 -> COLON_SEGMENT_GUARD + path + else -> path + } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UrlSerializer.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UrlSerializer.kt new file mode 100644 index 0000000..5700f13 --- /dev/null +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UrlSerializer.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.serialize + +import org.dexpace.kuri.parser.ComponentPath +import org.dexpace.kuri.parser.ParsedComponents + +/** + * The WHATWG URL serializer recomposition of stored components back into the canonical string (SPEC + * [NORM-15]..[NORM-18], [NORM-30], [NORM-31]; WHATWG URL serializer). + * + * The recomposition order — `scheme:`, authority, path, `?query`, `#fragment` — is shared with + * [UriSerializer] ([NORM-15]); the profiles differ only in the path rule and the (here identical) + * userinfo rule. This profile follows the WHATWG URL serializer, including the `/.` guard for a + * no-authority hierarchical path that begins `//`. + */ +internal object UrlSerializer { + /** + * Recomposes [c] into its canonical WHATWG URL serialization ([NORM-15]); a `Url` always + * carries a non-null scheme. + * + * @param c the stored components to serialize; already canonical for a `Url`. + * @param excludeFragment when `true`, the trailing `#fragment` is omitted ([NORM-31]); the + * basis for fragment-insensitive equality. + * @return the canonical string form of [c]. + */ + internal fun serialize( + c: ParsedComponents, + excludeFragment: Boolean = false, + ): String { + val scheme = requireNotNull(c.scheme) { "a Url value always carries a scheme" } + val sb = StringBuilder() + sb.append(scheme).append(':') + if (c.host != null) sb.append(DOUBLE_SLASH).append(serializeAuthority(c)) + sb.append(serializeUrlPath(c)) + appendQueryFragment(sb, c, excludeFragment) + check(sb.startsWith(scheme)) { "serialization must open with the scheme" } + return sb.toString() + } +} + +/** + * WHATWG URL path serialization: an opaque path verbatim, else the segment list ([NORM-15] step 3). + * + * The single home shared by [UrlSerializer.serialize] and `Url.encodedPath`. + * + * @param components the stored components whose path is rendered. + * @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, + guardAgainstAuthority: Boolean = true, +): String = + when (val path = components.path) { + is ComponentPath.Opaque -> path.path + is ComponentPath.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 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 = 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/host/HostParserTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/HostParserTest.kt deleted file mode 100644 index e16c9b0..0000000 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/HostParserTest.kt +++ /dev/null @@ -1,311 +0,0 @@ -/* - * Copyright (c) 2026 dexpace and Omar Aljarrah - * SPDX-License-Identifier: MIT - */ -package org.dexpace.kuri.host - -import org.dexpace.kuri.ParseProfile -import org.dexpace.kuri.error.HostError -import org.dexpace.kuri.error.ParseResult -import org.dexpace.kuri.error.UriParseError -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -/** - * Tests for [HostParser.parse], the profile-aware host dispatcher of SPEC §7.1 ([HOST-3]). - * - * Covers the §13.3 category-G host cases plus the host-layer cross-checks: the `Url` - * special pipeline (IDNA domain, IPv4 shorthand, IPv6 literal, empty-host rules, - * forbidden-domain re-scan, [HOST-26]..[HOST-40]), the `Url` non-special opaque path - * ([HOST-40]), and the `Uri` RFC 3986 host grammar (IP-literal/IPvFuture, IPv4address, - * reg-name preserve, [HOST-5]/[HOST-24]/[HOST-32]/[HOST-42]). - */ -class HostParserTest { - // --- Url profile, special scheme ------------------------------------------------- - - @Test - fun `parse yields a lowercase RegName for a plain special domain`() { - val result = HostParser.parse("example.com", ParseProfile.URL, isSpecial = true) - - assertEquals(ParseResult.Ok(Host.RegName("example.com")), result) - } - - @Test - fun `parse lowercases a special domain via IDNA mapping`() { - val result = HostParser.parse("EXAMPLE.com", ParseProfile.URL, isSpecial = true) - - assertEquals(ParseResult.Ok(Host.RegName("example.com")), result) - } - - @Test - fun `parse round-trips an already-ASCII xn-- label`() { - val result = HostParser.parse("xn--fa-hia.de", ParseProfile.URL, isSpecial = true) - - assertEquals(ParseResult.Ok(Host.RegName("xn--fa-hia.de")), result) - } - - @Test - fun `parse ACE-encodes a Unicode special domain`() { - // bücher.de -> the U+00FC label becomes its Punycode ACE form ([HOST-26]). - val result = HostParser.parse("bücher.de", ParseProfile.URL, isSpecial = true) - - assertEquals(ParseResult.Ok(Host.RegName("xn--bcher-kva.de")), result) - } - - @Test - fun `parse classifies a dotted-decimal special host as Ipv4`() { - val result = HostParser.parse("192.168.0.1", ParseProfile.URL, isSpecial = true) - - assertEquals(ParseResult.Ok(Host.Ipv4(0xC0A80001.toInt())), result) - } - - @Test - fun `parse accepts the Url-profile IPv4 hex shorthand`() { - // 0x7f.1 -> 127.0.0.1, the last part packed into the low 24 bits ([HOST-22]). - val result = HostParser.parse("0x7f.1", ParseProfile.URL, isSpecial = true) - - assertEquals(ParseResult.Ok(Host.Ipv4(0x7F000001)), result) - } - - @Test - fun `parse reads a bracketed IPv6 literal in the Url profile`() { - val result = HostParser.parse("[::1]", ParseProfile.URL, isSpecial = true) - - assertEquals(ParseResult.Ok(Host.Ipv6(listOf(0, 0, 0, 0, 0, 0, 0, 1))), result) - } - - @Test - fun `parse fails an empty special non-file host with EmptyHost`() { - val result = HostParser.parse("", ParseProfile.URL, isSpecial = true, isFile = false) - - assertEquals(ParseResult.Err(UriParseError.EmptyHost), result) - } - - @Test - fun `parse permits an empty file host as Empty`() { - val result = HostParser.parse("", ParseProfile.URL, isSpecial = true, isFile = true) - - assertEquals(ParseResult.Ok(Host.Empty), result) - } - - @Test - fun `parse rejects a special domain bearing a space`() { - // U+0020 survives UTS-46 and is caught by the forbidden-domain re-scan ([HOST-30]). - val result = HostParser.parse("a b.com", ParseProfile.URL, isSpecial = true) - - val err = assertIs(result) - val cause = assertIs(err.error) - assertEquals(' '.code, cause.codePoint) - assertEquals(1, cause.at) - } - - @Test - fun `parse rejects a special domain bearing a number sign`() { - val result = HostParser.parse("a#b", ParseProfile.URL, isSpecial = true) - - val err = assertIs(result) - val cause = assertIs(err.error) - assertEquals('#'.code, cause.codePoint) - } - - @Test - fun `parse rejects an NBSP-mapped special domain`() { - // U+00A0 maps to U+0020 SPACE under UTS-46, which the re-scan then forbids ([HOST-30]). - val result = HostParser.parse("www .", ParseProfile.URL, isSpecial = true) - - assertIs(result) - } - - // --- Url profile, non-special opaque --------------------------------------------- - - @Test - fun `parse yields an Opaque host for a non-special scheme`() { - val result = HostParser.parse("foo", ParseProfile.URL, isSpecial = false) - - assertEquals(ParseResult.Ok(Host.Opaque("foo")), result) - } - - @Test - fun `parse preserves an existing percent triplet in an opaque host`() { - val result = HostParser.parse("%2f", ParseProfile.URL, isSpecial = false) - - assertEquals(ParseResult.Ok(Host.Opaque("%2f")), result) - } - - @Test - fun `parse reads a bracketed IPv6 literal for a non-special Url scheme`() { - // A bracketed literal is IPv6 regardless of scheme specialness ([HOST-4]). - val result = HostParser.parse("[::1]", ParseProfile.URL, isSpecial = false) - - assertEquals(ParseResult.Ok(Host.Ipv6(listOf(0, 0, 0, 0, 0, 0, 0, 1))), result) - } - - // --- Uri profile ----------------------------------------------------------------- - - @Test - fun `parse preserves reg-name case in the Uri profile`() { - // No IDNA and no lowercasing for a Uri reg-name ([HOST-32]). - val result = HostParser.parse("Example.COM", ParseProfile.URI, isSpecial = false) - - assertEquals(ParseResult.Ok(Host.RegName("Example.COM")), result) - } - - @Test - fun `parse reads a bracketed IPv6 literal in the Uri profile`() { - val result = HostParser.parse("[::1]", ParseProfile.URI, isSpecial = false) - - assertEquals(ParseResult.Ok(Host.Ipv6(listOf(0, 0, 0, 0, 0, 0, 0, 1))), result) - } - - @Test - fun `parse reads a bracketed IPvFuture literal in the Uri profile`() { - val result = HostParser.parse("[v1.fe]", ParseProfile.URI, isSpecial = false) - - assertEquals(ParseResult.Ok(Host.IpFuture("v1.fe")), result) - } - - @Test - fun `parse classifies a dotted-decimal Uri host as Ipv4`() { - val result = HostParser.parse("1.2.3.4", ParseProfile.URI, isSpecial = false) - - assertEquals(ParseResult.Ok(Host.Ipv4(0x01020304)), result) - } - - @Test - fun `parse accepts a sub-delim in a Uri reg-name`() { - val result = HostParser.parse("a!b", ParseProfile.URI, isSpecial = false) - - assertEquals(ParseResult.Ok(Host.RegName("a!b")), result) - } - - @Test - fun `parse rejects an invalid reg-name code point in the Uri profile`() { - // '/' is neither unreserved nor sub-delim nor part of a triplet ([HOST-33]). - val result = HostParser.parse("a/b", ParseProfile.URI, isSpecial = false) - - val err = assertIs(result) - val cause = assertIs(err.error) - assertEquals('/'.code, cause.codePoint) - assertEquals(1, cause.at) - } - - @Test - fun `parse permits an empty Uri host as Empty`() { - val result = HostParser.parse("", ParseProfile.URI, isSpecial = false) - - assertEquals(ParseResult.Ok(Host.Empty), result) - } - - @Test - fun `parse preserves a percent triplet in a Uri reg-name`() { - val result = HostParser.parse("a%2Fb", ParseProfile.URI, isSpecial = false) - - assertEquals(ParseResult.Ok(Host.RegName("a%2Fb")), result) - } - - @Test - fun `parse fails a Url bracketed literal with no closing bracket`() { - val result = HostParser.parse("[::1", ParseProfile.URL, isSpecial = true) - - assertIs(result) - } - - @Test - fun `parse fails a Uri bracketed literal with no closing bracket`() { - // An IP-literal MUST end with `]`; an unterminated one is fatal ([HOST-5]). - val result = HostParser.parse("[::1", ParseProfile.URI, isSpecial = false) - - assertIs(result) - } - - @Test - fun `parse fails a Uri bracketed IPvFuture that breaks the grammar`() { - // Begins with 'v' but carries no hex version digit, so the production fails ([HOST-42]). - val result = HostParser.parse("[v.fe]", ParseProfile.URI, isSpecial = false) - - assertIs(result) - } - - @Test - fun `parse accepts an uppercase V IPvFuture version marker`() { - // The version marker is 'v' or 'V'; the uppercase form reaches the same production ([HOST-42]). - val result = HostParser.parse("[V1.fe]", ParseProfile.URI, isSpecial = false) - - assertEquals(ParseResult.Ok(Host.IpFuture("V1.fe")), result) - } - - @Test - fun `parse accepts colon and sub-delim code points in an IPvFuture payload`() { - // The IPvFuture payload admits unreserved / sub-delims / ':' ([HOST-42]). - assertEquals( - ParseResult.Ok(Host.IpFuture("v1.a:b")), - HostParser.parse("[v1.a:b]", ParseProfile.URI, isSpecial = false), - ) - assertEquals( - ParseResult.Ok(Host.IpFuture("v1.a!b")), - HostParser.parse("[v1.a!b]", ParseProfile.URI, isSpecial = false), - ) - } - - @Test - fun `parse rejects IPvFuture literals that break the grammar`() { - // No '.' separator, a non-hex version digit, an empty payload, and an out-of-set payload - // code point each fail the IPvFuture production ([HOST-42]). - listOf("[v1eF]", "[vG1.fe]", "[v1.]", "[v1.a/b]").forEach { input -> - val result = HostParser.parse(input, ParseProfile.URI, isSpecial = false) - val err = assertIs(result, "expected Err for '$input'") - val cause = assertIs(err.error) - assertEquals(HostError.Ipv6Malformed, cause.reason, "input '$input'") - } - } - - @Test - fun `parse rejects a malformed percent triplet in a Uri reg-name`() { - // A '%' not followed by two hex digits is not a valid pct-encoded reg-name unit ([HOST-33]). - val result = HostParser.parse("a%2g", ParseProfile.URI, isSpecial = false) - - val err = assertIs(result) - val cause = assertIs(err.error) - assertEquals('%'.code, cause.codePoint) - assertEquals(1, cause.at) - } - - // --- RFC 6874 zone identifiers ([HOST-17]/[HOST-18]) ----------------------------- - - @Test - fun `the Url profile rejects a zone id even when the flag is set`() { - val result = - HostParser.parse("[fe80::1%25eth0]", ParseProfile.URL, isSpecial = true, allowIpv6ZoneId = true) - - val err = assertIs(result) - val cause = assertIs(err.error) - assertEquals(HostError.ZoneIdRejected, cause.reason) - } - - @Test - fun `parse accepts an opted-in zone id in the Uri profile`() { - val result = - HostParser.parse("[fe80::1%25eth0]", ParseProfile.URI, isSpecial = false, allowIpv6ZoneId = true) - - assertEquals(ParseResult.Ok(Host.Ipv6(listOf(0xFE80, 0, 0, 0, 0, 0, 0, 1), zoneId = "eth0")), result) - } - - @Test - fun `parse rejects a zone id with the opt-in off in the Url profile`() { - val result = HostParser.parse("[fe80::1%25eth0]", ParseProfile.URL, isSpecial = true) - - val err = assertIs(result) - val cause = assertIs(err.error) - assertEquals(HostError.ZoneIdRejected, cause.reason) - } - - @Test - fun `parse rejects a zone id with the opt-in off in the Uri profile`() { - val result = HostParser.parse("[fe80::1%25eth0]", ParseProfile.URI, isSpecial = false) - - val err = assertIs(result) - val cause = assertIs(err.error) - assertEquals(HostError.ZoneIdRejected, cause.reason) - } -} diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/Ipv4Test.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/Ipv4Test.kt index eafd7bd..66fea08 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/Ipv4Test.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/Ipv4Test.kt @@ -4,7 +4,6 @@ */ package org.dexpace.kuri.host -import org.dexpace.kuri.ParseProfile import org.dexpace.kuri.error.HostError import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.error.UriParseError @@ -20,9 +19,9 @@ import kotlin.test.assertTrue * ([CONF-37]..[CONF-41]). */ class Ipv4Test { - private fun parseUrl(input: String): ParseResult = Ipv4.parse(input, ParseProfile.URL) + private fun parseUrl(input: String): ParseResult = Ipv4Whatwg.parse(input) - private fun parseUri(input: String): ParseResult = Ipv4.parse(input, ParseProfile.URI) + private fun parseUri(input: String): ParseResult = Ipv4Rfc3986.parse(input) /** Parses under the `Url` profile and re-serializes, the canonical round trip. */ private fun canonicalUrl(input: String): String { diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/UriHostParserTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/UriHostParserTest.kt new file mode 100644 index 0000000..13ab58d --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/UriHostParserTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.host + +import org.dexpace.kuri.error.HostError +import org.dexpace.kuri.error.ParseResult +import org.dexpace.kuri.error.UriParseError +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * Tests for [UriHostParser.parse], the RFC 3986 §3.2.2 host parser. + * + * Covers the RFC 3986 host grammar (IP-literal/IPvFuture, IPv4address, reg-name + * preserve, [HOST-5]/[HOST-24]/[HOST-32]/[HOST-42]) and the opt-in RFC 6874 zone + * identifiers ([HOST-17]/[HOST-18]). + */ +class UriHostParserTest { + // --- Uri profile ----------------------------------------------------------------- + + @Test + fun `parse preserves reg-name case in the Uri profile`() { + // No IDNA and no lowercasing for a Uri reg-name ([HOST-32]). + val result = UriHostParser.parse("Example.COM") + + assertEquals(ParseResult.Ok(Host.RegName("Example.COM")), result) + } + + @Test + fun `parse reads a bracketed IPv6 literal in the Uri profile`() { + val result = UriHostParser.parse("[::1]") + + assertEquals(ParseResult.Ok(Host.Ipv6(listOf(0, 0, 0, 0, 0, 0, 0, 1))), result) + } + + @Test + fun `parse reads a bracketed IPvFuture literal in the Uri profile`() { + val result = UriHostParser.parse("[v1.fe]") + + assertEquals(ParseResult.Ok(Host.IpFuture("v1.fe")), result) + } + + @Test + fun `parse classifies a dotted-decimal Uri host as Ipv4`() { + val result = UriHostParser.parse("1.2.3.4") + + assertEquals(ParseResult.Ok(Host.Ipv4(0x01020304)), result) + } + + @Test + fun `parse accepts a sub-delim in a Uri reg-name`() { + val result = UriHostParser.parse("a!b") + + assertEquals(ParseResult.Ok(Host.RegName("a!b")), result) + } + + @Test + fun `parse rejects an invalid reg-name code point in the Uri profile`() { + // '/' is neither unreserved nor sub-delim nor part of a triplet ([HOST-33]). + val result = UriHostParser.parse("a/b") + + val err = assertIs(result) + val cause = assertIs(err.error) + assertEquals('/'.code, cause.codePoint) + assertEquals(1, cause.at) + } + + @Test + fun `parse permits an empty Uri host as Empty`() { + val result = UriHostParser.parse("") + + assertEquals(ParseResult.Ok(Host.Empty), result) + } + + @Test + fun `parse preserves a percent triplet in a Uri reg-name`() { + val result = UriHostParser.parse("a%2Fb") + + assertEquals(ParseResult.Ok(Host.RegName("a%2Fb")), result) + } + + @Test + fun `parse fails a Uri bracketed literal with no closing bracket`() { + // An IP-literal MUST end with `]`; an unterminated one is fatal ([HOST-5]). + val result = UriHostParser.parse("[::1") + + assertIs(result) + } + + @Test + fun `parse fails a Uri bracketed IPvFuture that breaks the grammar`() { + // Begins with 'v' but carries no hex version digit, so the production fails ([HOST-42]). + val result = UriHostParser.parse("[v.fe]") + + assertIs(result) + } + + @Test + fun `parse accepts an uppercase V IPvFuture version marker`() { + // The version marker is 'v' or 'V'; the uppercase form reaches the same production ([HOST-42]). + val result = UriHostParser.parse("[V1.fe]") + + assertEquals(ParseResult.Ok(Host.IpFuture("V1.fe")), result) + } + + @Test + fun `parse accepts colon and sub-delim code points in an IPvFuture payload`() { + // The IPvFuture payload admits unreserved / sub-delims / ':' ([HOST-42]). + assertEquals( + ParseResult.Ok(Host.IpFuture("v1.a:b")), + UriHostParser.parse("[v1.a:b]"), + ) + assertEquals( + ParseResult.Ok(Host.IpFuture("v1.a!b")), + UriHostParser.parse("[v1.a!b]"), + ) + } + + @Test + fun `parse rejects IPvFuture literals that break the grammar`() { + // No '.' separator, a non-hex version digit, an empty payload, and an out-of-set payload + // code point each fail the IPvFuture production ([HOST-42]). + listOf("[v1eF]", "[vG1.fe]", "[v1.]", "[v1.a/b]").forEach { input -> + val result = UriHostParser.parse(input) + val err = assertIs(result, "expected Err for '$input'") + val cause = assertIs(err.error) + assertEquals(HostError.Ipv6Malformed, cause.reason, "input '$input'") + } + } + + @Test + fun `parse rejects a malformed percent triplet in a Uri reg-name`() { + // A '%' not followed by two hex digits is not a valid pct-encoded reg-name unit ([HOST-33]). + val result = UriHostParser.parse("a%2g") + + val err = assertIs(result) + val cause = assertIs(err.error) + assertEquals('%'.code, cause.codePoint) + assertEquals(1, cause.at) + } + + // --- RFC 6874 zone identifiers ([HOST-17]/[HOST-18]) ----------------------------- + + @Test + fun `parse accepts an opted-in zone id in the Uri profile`() { + val result = + UriHostParser.parse("[fe80::1%25eth0]", allowIpv6ZoneId = true) + + assertEquals(ParseResult.Ok(Host.Ipv6(listOf(0xFE80, 0, 0, 0, 0, 0, 0, 1), zoneId = "eth0")), result) + } + + @Test + fun `parse rejects a zone id with the opt-in off in the Uri profile`() { + val result = UriHostParser.parse("[fe80::1%25eth0]") + + val err = assertIs(result) + val cause = assertIs(err.error) + assertEquals(HostError.ZoneIdRejected, cause.reason) + } +} diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/UrlHostParserTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/UrlHostParserTest.kt new file mode 100644 index 0000000..54740ff --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/UrlHostParserTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.host + +import org.dexpace.kuri.error.HostError +import org.dexpace.kuri.error.ParseResult +import org.dexpace.kuri.error.UriParseError +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * Tests for [UrlHostParser.parse], the WHATWG host parser of SPEC §7.1 ([HOST-3]). + * + * Covers the `Url` special pipeline (IDNA domain, IPv4 shorthand, IPv6 literal, + * empty-host rules, forbidden-domain re-scan, [HOST-26]..[HOST-40]) and the `Url` + * non-special opaque path ([HOST-40]). + */ +class UrlHostParserTest { + // --- Url profile, special scheme ------------------------------------------------- + + @Test + fun `parse yields a lowercase RegName for a plain special domain`() { + val result = UrlHostParser.parse("example.com", isSpecial = true) + + assertEquals(ParseResult.Ok(Host.RegName("example.com")), result) + } + + @Test + fun `parse lowercases a special domain via IDNA mapping`() { + val result = UrlHostParser.parse("EXAMPLE.com", isSpecial = true) + + assertEquals(ParseResult.Ok(Host.RegName("example.com")), result) + } + + @Test + fun `parse round-trips an already-ASCII xn-- label`() { + val result = UrlHostParser.parse("xn--fa-hia.de", isSpecial = true) + + assertEquals(ParseResult.Ok(Host.RegName("xn--fa-hia.de")), result) + } + + @Test + fun `parse ACE-encodes a Unicode special domain`() { + // bücher.de -> the U+00FC label becomes its Punycode ACE form ([HOST-26]). + val result = UrlHostParser.parse("bücher.de", isSpecial = true) + + assertEquals(ParseResult.Ok(Host.RegName("xn--bcher-kva.de")), result) + } + + @Test + fun `parse classifies a dotted-decimal special host as Ipv4`() { + val result = UrlHostParser.parse("192.168.0.1", isSpecial = true) + + assertEquals(ParseResult.Ok(Host.Ipv4(0xC0A80001.toInt())), result) + } + + @Test + fun `parse accepts the Url-profile IPv4 hex shorthand`() { + // 0x7f.1 -> 127.0.0.1, the last part packed into the low 24 bits ([HOST-22]). + val result = UrlHostParser.parse("0x7f.1", isSpecial = true) + + assertEquals(ParseResult.Ok(Host.Ipv4(0x7F000001)), result) + } + + @Test + fun `parse reads a bracketed IPv6 literal in the Url profile`() { + val result = UrlHostParser.parse("[::1]", isSpecial = true) + + assertEquals(ParseResult.Ok(Host.Ipv6(listOf(0, 0, 0, 0, 0, 0, 0, 1))), result) + } + + @Test + fun `parse fails an empty special non-file host with EmptyHost`() { + val result = UrlHostParser.parse("", isSpecial = true, isFile = false) + + assertEquals(ParseResult.Err(UriParseError.EmptyHost), result) + } + + @Test + fun `parse permits an empty file host as Empty`() { + val result = UrlHostParser.parse("", isSpecial = true, isFile = true) + + assertEquals(ParseResult.Ok(Host.Empty), result) + } + + @Test + fun `parse rejects a special domain bearing a space`() { + // U+0020 survives UTS-46 and is caught by the forbidden-domain re-scan ([HOST-30]). + val result = UrlHostParser.parse("a b.com", isSpecial = true) + + val err = assertIs(result) + val cause = assertIs(err.error) + assertEquals(' '.code, cause.codePoint) + assertEquals(1, cause.at) + } + + @Test + fun `parse rejects a special domain bearing a number sign`() { + val result = UrlHostParser.parse("a#b", isSpecial = true) + + val err = assertIs(result) + val cause = assertIs(err.error) + assertEquals('#'.code, cause.codePoint) + } + + @Test + fun `parse rejects an NBSP-mapped special domain`() { + // U+00A0 maps to U+0020 SPACE under UTS-46, which the re-scan then forbids ([HOST-30]). + val result = UrlHostParser.parse("www .", isSpecial = true) + + assertIs(result) + } + + // --- Url profile, non-special opaque --------------------------------------------- + + @Test + fun `parse yields an Opaque host for a non-special scheme`() { + val result = UrlHostParser.parse("foo", isSpecial = false) + + assertEquals(ParseResult.Ok(Host.Opaque("foo")), result) + } + + @Test + fun `parse preserves an existing percent triplet in an opaque host`() { + val result = UrlHostParser.parse("%2f", isSpecial = false) + + assertEquals(ParseResult.Ok(Host.Opaque("%2f")), result) + } + + @Test + fun `parse reads a bracketed IPv6 literal for a non-special Url scheme`() { + // A bracketed literal is IPv6 regardless of scheme specialness ([HOST-4]). + val result = UrlHostParser.parse("[::1]", isSpecial = false) + + assertEquals(ParseResult.Ok(Host.Ipv6(listOf(0, 0, 0, 0, 0, 0, 0, 1))), result) + } + + @Test + fun `parse fails a Url bracketed literal with no closing bracket`() { + val result = UrlHostParser.parse("[::1", isSpecial = true) + + assertIs(result) + } + + // --- RFC 6874 zone identifiers ([HOST-17]/[HOST-18]) ----------------------------- + + @Test + fun `the Url profile rejects a zone id`() { + // The WHATWG URL host parser has no zone-id production, so a `%25`-bearing literal is fatal + // ([HOST-17]); unlike the Uri parser there is no opt-in flag to relax this. + val result = UrlHostParser.parse("[fe80::1%25eth0]", isSpecial = true) + + val err = assertIs(result) + val cause = assertIs(err.error) + assertEquals(HostError.ZoneIdRejected, cause.reason) + } +} diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/idna/IdnaConformanceTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/idna/IdnaConformanceTest.kt index df2cf1a..63902e4 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/idna/IdnaConformanceTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/idna/IdnaConformanceTest.kt @@ -5,10 +5,9 @@ package org.dexpace.kuri.idna -import org.dexpace.kuri.ParseProfile import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.error.UriParseError -import org.dexpace.kuri.host.HostParser +import org.dexpace.kuri.host.UrlHostParser import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -30,7 +29,7 @@ import kotlin.test.assertTrue * * That scope is asserted directly, not merely documented: every residual input is shown to be accepted * by [Idna.domainToAscii] (so the corpus's required rejection is a host obligation, not a ToASCII one) - * yet rejected by [HostParser] under the `Url` profile. The suite also ratchets: it fails if any other + * yet rejected by [UrlHostParser] under the `Url` profile. The suite also ratchets: it fails if any other * case regresses, or if the residual drifts from the live failing set. */ class IdnaConformanceTest { @@ -95,7 +94,7 @@ class IdnaConformanceTest { // Host parsing owns these: each falls to the empty-host rule or the forbidden-host-code-point // check the WHATWG host parser applies on top of "domain to ASCII". residual.forEach { input -> - val result = HostParser.parse(input, ParseProfile.URL, isSpecial = true) + val result = UrlHostParser.parse(input, isSpecial = true) assertIs(result, "residual input should be rejected by the host layer: <$input>") assertTrue( result.error is UriParseError.EmptyHost || result.error is UriParseError.ForbiddenHostCodePoint, diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlPathTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ComponentPathTest.kt similarity index 90% rename from kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlPathTest.kt rename to kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ComponentPathTest.kt index 5afb06c..f0c11e8 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlPathTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ComponentPathTest.kt @@ -9,13 +9,13 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith /** - * Unit tests for the structured path values in `UrlPath.kt`. [BuilderPath], the builders' + * Unit tests for the structured path values in `ComponentPath.kt`. [BuilderPath], the builders' * structured-path value (RFC 3986 §3.3; SPEC §3.7), covers the segment-edit transforms * ([BuilderPath.pushSegment], [BuilderPath.addSegments], [BuilderPath.setSegment], * [BuilderPath.removeSegment]) and their index-bounds contracts; the remaining cases pin the - * opaque-path projections [UrlPath.toUriPathString] and [decodedSegments]. + * opaque-path projections [ComponentPath.toUriPathString] and [decodedSegments]. */ -internal class UrlPathTest { +internal class ComponentPathTest { @Test fun `setSegment throws when the index is past the end`() { val path = BuilderPath(listOf("a", "b")) @@ -111,14 +111,14 @@ internal class UrlPathTest { @Test fun `toUriPathString returns an opaque path verbatim`() { - val path: UrlPath = UrlPath.Opaque("a@b") + val path: ComponentPath = ComponentPath.Opaque("a@b") assertEquals("a@b", path.toUriPathString()) } @Test fun `decodedSegments yields the single decoded value for an opaque path`() { - val result = decodedSegments(UrlPath.Opaque("a%40b")) { it } + val result = decodedSegments(ComponentPath.Opaque("a%40b")) { it } assertEquals(listOf("a%40b"), result) } diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt index 3cb7b68..21449e4 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt @@ -5,11 +5,10 @@ package org.dexpace.kuri.parser import org.dexpace.kuri.ParseOptions -import org.dexpace.kuri.ParseProfile import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.error.UriParseError import org.dexpace.kuri.host.Host -import org.dexpace.kuri.serialize.Serializer +import org.dexpace.kuri.serialize.UriSerializer import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -170,7 +169,7 @@ internal class ResolverTest { val resolved = Resolver.resolve(base, reference).getOrThrow() - assertEquals("mailto:x@y", Serializer.serialize(resolved, ParseProfile.URI)) + assertEquals("mailto:x@y", UriSerializer.serialize(resolved)) } @Test @@ -181,7 +180,7 @@ internal class ResolverTest { val resolved = Resolver.resolve(base, reference).getOrThrow() - assertEquals("http://h/a/g/x", Serializer.serialize(resolved, ParseProfile.URI)) + assertEquals("http://h/a/g/x", UriSerializer.serialize(resolved)) } @Test @@ -195,7 +194,7 @@ internal class ResolverTest { assertEquals("user", resolved.username) assertEquals("", resolved.password) - assertEquals("http://user@host/a/x", Serializer.serialize(resolved, ParseProfile.URI)) + assertEquals("http://user@host/a/x", UriSerializer.serialize(resolved)) } @Test @@ -208,7 +207,7 @@ internal class ResolverTest { assertEquals("user", resolved.username) assertEquals("pass", resolved.password) - assertEquals("http://user:pass@host/a/x", Serializer.serialize(resolved, ParseProfile.URI)) + assertEquals("http://user:pass@host/a/x", UriSerializer.serialize(resolved)) } @Test @@ -220,7 +219,7 @@ internal class ResolverTest { val resolved = Resolver.resolve(base, reference).getOrThrow() assertEquals(Host.Ipv6(listOf(0xFE80, 0, 0, 0, 0, 0, 0, 1), zoneId = "eth0"), resolved.host) - assertEquals("foo://[fe80::1%25eth0]/a/x", Serializer.serialize(resolved, ParseProfile.URI)) + assertEquals("foo://[fe80::1%25eth0]/a/x", UriSerializer.serialize(resolved)) } @Test @@ -233,7 +232,7 @@ internal class ResolverTest { val resolved = Resolver.resolve(base, reference).getOrThrow() assertEquals(8080, resolved.port) - assertEquals("http://h:8080/a/x", Serializer.serialize(resolved, ParseProfile.URI)) + assertEquals("http://h:8080/a/x", UriSerializer.serialize(resolved)) } @Test @@ -248,7 +247,7 @@ internal class ResolverTest { assertEquals("", resolved.username) assertEquals("pass", resolved.password) - assertEquals("http://:pass@h/a/x", Serializer.serialize(resolved, ParseProfile.URI)) + assertEquals("http://:pass@h/a/x", UriSerializer.serialize(resolved)) } private fun assertResolves(cases: List>) { 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 5cd3a7f..454cc3f 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/StateOverrideParseTest.kt @@ -18,7 +18,7 @@ class StateOverrideParseTest { assertTrue(result is ParseResult.Ok) val c = result.value assertEquals("https", c.scheme) - assertEquals(listOf("new", "path"), (c.path as UrlPath.Segments).segments) + assertEquals(listOf("new", "path"), (c.path as ComponentPath.Segments).segments) assertEquals("q", c.query) assertEquals("f", c.fragment) } diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UriParserTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UriParserTest.kt index e4406e8..6ab85b5 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UriParserTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UriParserTest.kt @@ -30,7 +30,7 @@ class UriParserTest { private fun segments(components: ParsedComponents): List { val path = components.path - assertIs(path, "expected a segmented path") + assertIs(path, "expected a segmented path") return path.segments } @@ -55,7 +55,7 @@ class UriParserTest { assertEquals("urn", components.scheme) assertNull(components.host) assertEquals(listOf("example:animal:ferret:nose"), segments(components)) - assertEquals(false, (components.path as UrlPath.Segments).rooted) + assertEquals(false, (components.path as ComponentPath.Segments).rooted) assertNull(components.query) assertNull(components.fragment) } @@ -67,7 +67,7 @@ class UriParserTest { assertEquals("mailto", components.scheme) assertNull(components.host) assertEquals(listOf("John.Doe@example.com"), segments(components)) - assertEquals(false, (components.path as UrlPath.Segments).rooted) + assertEquals(false, (components.path as ComponentPath.Segments).rooted) } // --- authority presence and relative references ([PARSE-19]/[PARSE-50]) ---------------------- @@ -88,7 +88,7 @@ class UriParserTest { assertNull(components.scheme) assertNull(components.host) assertEquals(listOf("relative", "path"), segments(components)) - assertEquals(true, (components.path as UrlPath.Segments).rooted) + assertEquals(true, (components.path as ComponentPath.Segments).rooted) } @Test @@ -98,7 +98,7 @@ class UriParserTest { assertNull(components.scheme) assertNull(components.host) assertEquals(listOf("a", "b", "c"), segments(components)) - assertEquals(false, (components.path as UrlPath.Segments).rooted) + assertEquals(false, (components.path as ComponentPath.Segments).rooted) } @Test diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlConformanceTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlConformanceTest.kt index feacc4f..8d08861 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlConformanceTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlConformanceTest.kt @@ -60,10 +60,10 @@ class UrlConformanceTest { * `Segments([])` therefore renders as `""` (a non-special authority with no path), while the * special-URL root `Segments([""])` renders as `"/"` -- matching WPT exactly. */ - private fun serializePath(path: UrlPath): String = + private fun serializePath(path: ComponentPath): String = when (path) { - is UrlPath.Opaque -> path.path - is UrlPath.Segments -> path.segments.joinToString(separator = "") { "/$it" } + is ComponentPath.Opaque -> path.path + is ComponentPath.Segments -> path.segments.joinToString(separator = "") { "/$it" } } /** Maps a WPT `port` string to the parsed [ParsedComponents.port] (`""` = default/none = null). */ diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlParserTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlParserTest.kt index a7e98f0..c54f9cc 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlParserTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlParserTest.kt @@ -32,7 +32,7 @@ class UrlParserTest { private fun segments(components: ParsedComponents): List { val path = components.path - assertIs(path, "expected a segment path, was $path") + assertIs(path, "expected a segment path, was $path") return path.segments } @@ -113,7 +113,7 @@ class UrlParserTest { val url = parsed("mailto:a@b") assertEquals("mailto", url.scheme) assertNull(url.host) - assertEquals(UrlPath.Opaque("a@b"), url.path) + assertEquals(ComponentPath.Opaque("a@b"), url.path) } @Test @@ -170,7 +170,7 @@ class UrlParserTest { val base = parsed("mailto:x@y") val url = parsed("#frag", base) assertEquals("mailto", url.scheme) - assertEquals(UrlPath.Opaque("x@y"), url.path) + assertEquals(ComponentPath.Opaque("x@y"), url.path) assertEquals("frag", url.fragment) } diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlSetterConformanceTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlSetterConformanceTest.kt index 3346e85..85390ee 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlSetterConformanceTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlSetterConformanceTest.kt @@ -124,14 +124,14 @@ class UrlSetterConformanceTest { // one commented entry per line explaining why kuri's immutable model diverges from the // JS mutable-setter fixture. // - // Both entries are the same underlying gap, not a setter-plumbing bug: HostParser accepts + // Both entries are the same underlying gap, not a setter-plumbing bug: UrlHostParser accepts // the ACE label "xn--" (an `xn--` prefix with no encoded suffix, which Punycode decodes to // the empty string) as a plain RegName instead of rejecting it as an invalid domain label. // A *full* parse of "https://xn--/" reproduces the identical acceptance (verified directly - // against HostParser.parse and Url.parse, not just through the setter), so this is IDNA + // against UrlHostParser.parse and Url.parse, not just through the setter), so this is IDNA // domain-validity depth kuri's host pipeline doesn't yet cover -- not something introduced // or fixable by the setter/override plumbing this suite exercises. Fixing it means teaching - // HostParser/the domain-to-ASCII step to validate an `xn--` label's Punycode round-trip, + // UrlHostParser/the domain-to-ASCII step to validate an `xn--` label's Punycode round-trip, // which is IDNA runtime this task is explicitly scoped out of touching. private val KNOWN_FAILURES: Set = setOf( diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/IdempotencyTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/IdempotencyTest.kt index 59f9c55..74270d2 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/IdempotencyTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/IdempotencyTest.kt @@ -4,7 +4,6 @@ */ package org.dexpace.kuri.serialize -import org.dexpace.kuri.ParseProfile import org.dexpace.kuri.parser.UriParser import org.dexpace.kuri.parser.UrlParser import kotlin.test.Test @@ -37,11 +36,11 @@ internal class IdempotencyTest { private fun serializeUrl(input: String): String { val parsed = requireNotNull(UrlParser.parse(input).getOrNull()) { "expected a parseable Url: $input" } - return Serializer.serialize(parsed, ParseProfile.URL) + return UrlSerializer.serialize(parsed) } private fun serializeUri(input: String): String { val parsed = requireNotNull(UriParser.parse(input).getOrNull()) { "expected a parseable Uri: $input" } - return Serializer.serialize(parsed, ParseProfile.URI) + return UriSerializer.serialize(parsed) } } diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/SerializeSharedTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/SerializeSharedTest.kt new file mode 100644 index 0000000..18b3c8f --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/SerializeSharedTest.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.serialize + +import org.dexpace.kuri.parser.ParsedComponents +import kotlin.test.Test +import kotlin.test.assertFailsWith + +/** + * Tests for the shared authority serializer used by both the WHATWG URL and RFC 3986 URI + * recomposition paths. + */ +internal class SerializeSharedTest { + @Test + fun `serializeAuthority rejects components with no host`() { + // Authority serialization requires a non-null host; callers only reach it behind that guard. + assertFailsWith { serializeAuthority(ParsedComponents(host = null)) } + } +} diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/SerializerTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/SerializerTest.kt deleted file mode 100644 index 9f3a417..0000000 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/SerializerTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2026 dexpace and Omar Aljarrah - * SPDX-License-Identifier: MIT - */ -package org.dexpace.kuri.serialize - -import org.dexpace.kuri.ParseProfile -import org.dexpace.kuri.host.Host -import org.dexpace.kuri.parser.ParsedComponents -import org.dexpace.kuri.parser.UriParser -import org.dexpace.kuri.parser.UrlParser -import org.dexpace.kuri.parser.UrlPath -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -/** - * §11.2 serializer tests: WHATWG URL recomposition (round-trip, default-port elision, opaque and - * `file` paths, the [NORM-18] `/.` guard, `excludeFragment`, present-vs-absent empty query/fragment) - * and the RFC 3986 §5.3 `Uri` recomposition. - */ -internal class SerializerTest { - @Test - fun `serialize round-trips a parsed Url with userinfo port path query and fragment`() { - val input = "https://u:p@h:8443/a/b?q#f" - assertEquals(input, Serializer.serialize(parseUrl(input), ParseProfile.URL)) - } - - @Test - fun `serialize elides the default port for a special scheme`() { - assertEquals("https://h/", Serializer.serialize(parseUrl("https://h:443/"), ParseProfile.URL)) - } - - @Test - fun `serialize emits an opaque non-special path verbatim`() { - assertEquals("mailto:a@b", Serializer.serialize(parseUrl("mailto:a@b"), ParseProfile.URL)) - } - - @Test - fun `serialize renders an empty-host file authority`() { - assertEquals("file:///x", Serializer.serialize(parseUrl("file:///x"), ParseProfile.URL)) - } - - @Test - fun `serialize prepends the slash-dot guard for a no-authority path that opens with two slashes`() { - val components = ParsedComponents(scheme = "web+demo", path = UrlPath.Segments(listOf("", "x"))) - assertEquals("web+demo:/.//x", Serializer.serialize(components, ParseProfile.URL)) - } - - @Test - fun `serialize omits the fragment when excludeFragment is set`() { - assertEquals("https://h/p?q", Serializer.serialize(parseUrl("https://h/p?q#f"), ParseProfile.URL, true)) - } - - @Test - fun `serialize distinguishes a present-empty query and fragment from their absence`() { - val base = ParsedComponents(scheme = "https", host = Host.RegName("h"), path = UrlPath.Segments(listOf(""))) - assertEquals("https://h/", Serializer.serialize(base, ParseProfile.URL)) - assertEquals("https://h/?", Serializer.serialize(base.copy(query = ""), ParseProfile.URL)) - assertEquals("https://h/#", Serializer.serialize(base.copy(fragment = ""), ParseProfile.URL)) - } - - @Test - fun `serialize recomposes a generic URI per RFC 3986 section 5_3`() { - val input = "foo://example.com/over/there?name=ferret#nose" - assertEquals(input, Serializer.serialize(parseUri(input), ParseProfile.URI)) - } - - @Test - fun `serialize keeps a rootless relative reference path without a leading slash`() { - assertEquals("a/b/c", Serializer.serialize(parseUri("a/b/c"), ParseProfile.URI)) - } - - @Test - fun `serialize keeps a scheme-rootless mailto path without a leading slash`() { - assertEquals("mailto:a@b", Serializer.serialize(parseUri("mailto:a@b"), ParseProfile.URI)) - } - - @Test - fun `serialize keeps a scheme-rootless urn path without a leading slash`() { - assertEquals("urn:example:x", Serializer.serialize(parseUri("urn:example:x"), ParseProfile.URI)) - } - - @Test - fun `serialize keeps an absolute-path relative reference rooted`() { - assertEquals("/a/b", Serializer.serialize(parseUri("/a/b"), ParseProfile.URI)) - } - - @Test - fun `serialize keeps a trailing slash on a rootless path`() { - // The trailing "" segment and the missing leading slash must both survive the rootless join. - assertEquals("a/b/", Serializer.serialize(parseUri("a/b/"), ParseProfile.URI)) - } - - @Test - fun `serialize rejects a Url profile value that carries no scheme`() { - // The WHATWG serializer asserts the scheme is present; a Url always has one. - val components = ParsedComponents(host = Host.RegName("h"), path = UrlPath.Segments(listOf(""))) - assertFailsWith { Serializer.serialize(components, ParseProfile.URL) } - } - - @Test - fun `serializeAuthority rejects components with no host`() { - // Authority serialization requires a non-null host; callers only reach it behind that guard. - assertFailsWith { serializeAuthority(ParsedComponents(host = null)) } - } - - private fun parseUrl(input: String): ParsedComponents = - requireNotNull(UrlParser.parse(input).getOrNull()) { "expected a parseable Url: $input" } - - private fun parseUri(input: String): ParsedComponents = - requireNotNull(UriParser.parse(input).getOrNull()) { "expected a parseable Uri: $input" } -} diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UriNormalizerTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UriNormalizerTest.kt index 4a952b6..cc72fe8 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UriNormalizerTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UriNormalizerTest.kt @@ -4,11 +4,10 @@ */ package org.dexpace.kuri.serialize -import org.dexpace.kuri.ParseProfile import org.dexpace.kuri.host.Host +import org.dexpace.kuri.parser.ComponentPath import org.dexpace.kuri.parser.ParsedComponents import org.dexpace.kuri.parser.UriParser -import org.dexpace.kuri.parser.UrlPath import kotlin.test.Test import kotlin.test.assertEquals @@ -138,7 +137,7 @@ internal class UriNormalizerTest { @Test fun `normalize leaves an opaque path untouched`() { // §6.2.2.3 / [NORM-9]: an opaque path is returned verbatim and is never dot-collapsed. - val opaque = UrlPath.Opaque("a@b") + val opaque = ComponentPath.Opaque("a@b") val components = ParsedComponents(scheme = "mailto", path = opaque) assertEquals(opaque, UriNormalizer.normalize(components).path) } @@ -154,6 +153,6 @@ internal class UriNormalizerTest { private fun normalizedUri(input: String): String { val parsed = requireNotNull(UriParser.parse(input).getOrNull()) { "expected a parseable Uri: $input" } val normalized: ParsedComponents = UriNormalizer.normalize(parsed) - return Serializer.serialize(normalized, ParseProfile.URI) + return UriSerializer.serialize(normalized) } } diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UriSerializerTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UriSerializerTest.kt new file mode 100644 index 0000000..7a705f8 --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UriSerializerTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.serialize + +import org.dexpace.kuri.parser.ParsedComponents +import org.dexpace.kuri.parser.UriParser +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * §5.3 serializer tests: RFC 3986 `Uri` recomposition (generic round-trip, rootless vs rooted paths, + * scheme-rootless opaque paths, and trailing-slash preservation across the rootless join). + */ +internal class UriSerializerTest { + @Test + fun `serialize recomposes a generic URI per RFC 3986 section 5_3`() { + val input = "foo://example.com/over/there?name=ferret#nose" + assertEquals(input, UriSerializer.serialize(parseUri(input))) + } + + @Test + fun `serialize keeps a rootless relative reference path without a leading slash`() { + assertEquals("a/b/c", UriSerializer.serialize(parseUri("a/b/c"))) + } + + @Test + fun `serialize keeps a scheme-rootless mailto path without a leading slash`() { + assertEquals("mailto:a@b", UriSerializer.serialize(parseUri("mailto:a@b"))) + } + + @Test + fun `serialize keeps a scheme-rootless urn path without a leading slash`() { + assertEquals("urn:example:x", UriSerializer.serialize(parseUri("urn:example:x"))) + } + + @Test + fun `serialize keeps an absolute-path relative reference rooted`() { + assertEquals("/a/b", UriSerializer.serialize(parseUri("/a/b"))) + } + + @Test + fun `serialize keeps a trailing slash on a rootless path`() { + // The trailing "" segment and the missing leading slash must both survive the rootless join. + assertEquals("a/b/", UriSerializer.serialize(parseUri("a/b/"))) + } + + private fun parseUri(input: String): ParsedComponents = + requireNotNull(UriParser.parse(input).getOrNull()) { "expected a parseable Uri: $input" } +} diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UrlHrefConformanceTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UrlHrefConformanceTest.kt index 14ae260..60f699b 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UrlHrefConformanceTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UrlHrefConformanceTest.kt @@ -5,7 +5,6 @@ package org.dexpace.kuri.serialize -import org.dexpace.kuri.ParseProfile import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.parser.ParsedComponents import org.dexpace.kuri.parser.URL_TEST_CASES @@ -35,7 +34,7 @@ private const val MIN_PASS_PERCENT: Int = 99 * * Each case resolves [UrlCase.input] against the parsed [UrlCase.base] (the WPT bases always parse, * so a base failure is surfaced as a real bug, not skipped), then serializes the resulting - * [ParsedComponents] under [ParseProfile.URL] and compares against [UrlCase.href]. Only non-failure + * [ParsedComponents] via [UrlSerializer] and compares against [UrlCase.href]. Only non-failure * cases are in scope -- a required-failure input has no canonical serialization. * * This target is strictly tighter than the component-getter test in `UrlConformanceTest`: the @@ -55,7 +54,7 @@ class UrlHrefConformanceTest { } /** - * True when [case] round-trips: `parse(input, base)` succeeds and its [ParseProfile.URL] + * True when [case] round-trips: `parse(input, base)` succeeds and its [UrlSerializer] * serialization equals the WPT [UrlCase.href]. A non-failure input that fails to parse counts * as a residual (false), since every in-scope case must yield a canonical serialization. */ @@ -64,7 +63,7 @@ class UrlHrefConformanceTest { val base = case.base?.let { parseBase(it) } return when (val result = UrlParser.parse(case.input, base)) { is ParseResult.Err -> false - is ParseResult.Ok -> Serializer.serialize(result.value, ParseProfile.URL) == case.href + is ParseResult.Ok -> UrlSerializer.serialize(result.value) == case.href } } @@ -113,14 +112,14 @@ class UrlHrefConformanceTest { /** * The tracked baseline of non-failure case keys (`input + U+0000 + base`) whose live href * round-trip diverges from WPT. The analysis-derived residual is empty: `UrlConformanceTest` - * already matches every component getter for all non-failure cases, and the [Serializer] + * already matches every component getter for all non-failure cases, and the [UrlSerializer] * reproduces the only two stricter-than-getter distinctions (present-but-empty `?`/`#`, the * §11.2 [NORM-18] `/.` guard). The decoded-A-label re-validation corners are all * required-*failure* inputs and so lie outside this test's non-failure scope. * * The ratcheting `the known-failures set exactly equals the live failing set` test is the - * authority: any genuine serializer corner it surfaces is fixed in the [Serializer] (against - * the WHATWG URL serializer), never masked by appending to this baseline. + * authority: any genuine serializer corner it surfaces is fixed in the [UrlSerializer] + * (against the WHATWG URL serializer), never masked by appending to this baseline. */ private val KNOWN_FAILURES: Set = emptySet() } diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UrlSerializerTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UrlSerializerTest.kt new file mode 100644 index 0000000..0116677 --- /dev/null +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UrlSerializerTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.serialize + +import org.dexpace.kuri.host.Host +import org.dexpace.kuri.parser.ComponentPath +import org.dexpace.kuri.parser.ParsedComponents +import org.dexpace.kuri.parser.UrlParser +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * §11.2 serializer tests: WHATWG URL recomposition (round-trip, default-port elision, opaque and + * `file` paths, the [NORM-18] `/.` guard, `excludeFragment`, present-vs-absent empty query/fragment). + */ +internal class UrlSerializerTest { + @Test + fun `serialize round-trips a parsed Url with userinfo port path query and fragment`() { + val input = "https://u:p@h:8443/a/b?q#f" + assertEquals(input, UrlSerializer.serialize(parseUrl(input))) + } + + @Test + fun `serialize elides the default port for a special scheme`() { + assertEquals("https://h/", UrlSerializer.serialize(parseUrl("https://h:443/"))) + } + + @Test + fun `serialize emits an opaque non-special path verbatim`() { + assertEquals("mailto:a@b", UrlSerializer.serialize(parseUrl("mailto:a@b"))) + } + + @Test + fun `serialize renders an empty-host file authority`() { + assertEquals("file:///x", UrlSerializer.serialize(parseUrl("file:///x"))) + } + + @Test + fun `serialize prepends the slash-dot guard for a no-authority path that opens with two slashes`() { + val components = ParsedComponents(scheme = "web+demo", path = ComponentPath.Segments(listOf("", "x"))) + assertEquals("web+demo:/.//x", UrlSerializer.serialize(components)) + } + + @Test + fun `serialize omits the fragment when excludeFragment is set`() { + assertEquals("https://h/p?q", UrlSerializer.serialize(parseUrl("https://h/p?q#f"), true)) + } + + @Test + fun `serialize distinguishes a present-empty query and fragment from their absence`() { + val base = + ParsedComponents( + scheme = "https", + host = Host.RegName("h"), + path = ComponentPath.Segments(listOf("")), + ) + assertEquals("https://h/", UrlSerializer.serialize(base)) + assertEquals("https://h/?", UrlSerializer.serialize(base.copy(query = ""))) + assertEquals("https://h/#", UrlSerializer.serialize(base.copy(fragment = ""))) + } + + @Test + fun `serialize rejects a Url profile value that carries no scheme`() { + // The WHATWG serializer asserts the scheme is present; a Url always has one. + val components = ParsedComponents(host = Host.RegName("h"), path = ComponentPath.Segments(listOf(""))) + assertFailsWith { UrlSerializer.serialize(components) } + } + + private fun parseUrl(input: String): ParsedComponents = + requireNotNull(UrlParser.parse(input).getOrNull()) { "expected a parseable Url: $input" } +}