diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/HostParser.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/HostParser.kt index a0b2937..b8d0b88 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/HostParser.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/HostParser.kt @@ -10,8 +10,9 @@ 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.isAsciiAlphanumeric +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 = '[' @@ -31,9 +32,6 @@ private const val MIN_IP_FUTURE_DOT_INDEX: Int = 2 /** The `.` separating the IPvFuture version digits from their payload. */ private const val IP_FUTURE_DOT: Char = '.' -/** RFC 3986 `unreserved` symbol characters beyond ALPHA/DIGIT (`ALPHA / DIGIT / "-._~"`). */ -private const val UNRESERVED_SYMBOLS: String = "-._~" - /** RFC 3986 `sub-delims` set (`"!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="`). */ private const val SUB_DELIMS: String = "!\$&'()*+,;=" @@ -256,8 +254,8 @@ internal object HostParser { require(index in input.indices) { "index out of range: $index" } val c = input[index] return when { - c == '%' && isPctEncodedAt(input, index) -> PCT_TRIPLET_LENGTH - isUnreserved(c) || isSubDelim(c) -> 1 + c == '%' && hasPercentHexPairAt(input, index) -> PCT_TRIPLET_LENGTH + c.isUnreserved() || isSubDelim(c) -> 1 else -> 0 } } @@ -274,18 +272,9 @@ internal object HostParser { private fun bracketError(input: String): ParseResult = ParseResult.Err(UriParseError.InvalidHost(input, HostError.Ipv6Malformed)) - /** True when a `%` at [index] introduces a valid `pct-encoded` triplet (`%` then two HEXDIG). */ - private fun isPctEncodedAt( - input: String, - index: Int, - ): Boolean = index + 2 < input.length && input[index + 1].isAsciiHexDigit() && input[index + 2].isAsciiHexDigit() - - /** True when [c] is an RFC 3986 `unreserved` code point. */ - private fun isUnreserved(c: Char): Boolean = c.isAsciiAlphanumeric() || c in UNRESERVED_SYMBOLS - /** True when [c] is an RFC 3986 `sub-delims` code point. */ private fun isSubDelim(c: Char): Boolean = c in SUB_DELIMS /** True when [c] is admissible in an IPvFuture payload (`unreserved / sub-delims / ":"`). */ - private fun isIpFuturePayloadChar(c: Char): Boolean = isUnreserved(c) || isSubDelim(c) || c == ':' + private fun isIpFuturePayloadChar(c: Char): Boolean = c.isUnreserved() || isSubDelim(c) || c == ':' } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv6.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv6.kt index 3b3e64e..bb966bd 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv6.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv6.kt @@ -7,10 +7,11 @@ package org.dexpace.kuri.host import org.dexpace.kuri.error.HostError import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.error.UriParseError +import org.dexpace.kuri.text.hasPercentHexPairAt import org.dexpace.kuri.text.hexDigitToInt -import org.dexpace.kuri.text.isAsciiAlphanumeric import org.dexpace.kuri.text.isAsciiDigit import org.dexpace.kuri.text.isAsciiHexDigit +import org.dexpace.kuri.text.isUnreserved /** Number of 16-bit pieces in an IPv6 address (§7.2). */ private const val PIECE_COUNT: Int = 8 @@ -54,9 +55,6 @@ private const val ZONE_INTRODUCER: String = "%25" /** Length of a `pct-encoded` triplet, `%HH`, consumed whole by the `ZoneID` scanner ([HOST-18]). */ private const val PCT_TRIPLET_LENGTH: Int = 3 -/** RFC 3986 `unreserved` symbol characters beyond ALPHA/DIGIT (`ALPHA / DIGIT / "-._~"`). */ -private const val ZONE_UNRESERVED_SYMBOLS: String = "-._~" - /** * The IPv6 literal parser and RFC 5952 serializer (SPEC §7.2). * @@ -161,27 +159,12 @@ internal object Ipv6 { require(index in rawZone.indices) { "zone index out of range: $index" } val c = rawZone[index] return when { - c == '%' && isPctTriplet(rawZone, index) -> PCT_TRIPLET_LENGTH - isZoneUnreserved(c) -> 1 + c == '%' && hasPercentHexPairAt(rawZone, index) -> PCT_TRIPLET_LENGTH + c.isUnreserved() -> 1 else -> 0 } } - /** True when [c] is an RFC 3986 `unreserved` code point (`ALPHA / DIGIT / "-._~"`). */ - private fun isZoneUnreserved(c: Char): Boolean = c.isAsciiAlphanumeric() || c in ZONE_UNRESERVED_SYMBOLS - - /** True when a `%` at [index] introduces a valid `pct-encoded` triplet (`%` then two HEXDIG). */ - private fun isPctTriplet( - rawZone: String, - index: Int, - ): Boolean { - require(index in rawZone.indices) { "pct index out of range: $index" } - require(rawZone[index] == '%') { "pct triplet must start with '%'" } - return index + 2 < rawZone.length && - rawZone[index + 1].isAsciiHexDigit() && - rawZone[index + 2].isAsciiHexDigit() - } - /** * Serializes eight 16-bit pieces to their RFC 5952 canonical form ([HOST-15], * [HOST-16]): lowercase hex, suppressed leading zeros, and the single longest diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt index bbfbbac..ae1174b 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt @@ -9,8 +9,11 @@ import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.error.UriParseError import org.dexpace.kuri.error.map import org.dexpace.kuri.text.MAX_CODE_POINT +import org.dexpace.kuri.text.NON_ASCII_MIN import org.dexpace.kuri.text.appendCodePoint +import org.dexpace.kuri.text.asciiLowercased import org.dexpace.kuri.text.codePointsOf +import org.dexpace.kuri.text.isAllAscii /** ACE prefix marking a Punycode-encoded (`xn--`) label (RFC 5890 §2.3.2.1). */ private const val ACE_PREFIX: String = "xn--" @@ -21,12 +24,6 @@ private const val ACE_PREFIX_LENGTH: Int = 4 /** Label separator inside a domain (U+002E FULL STOP); also the join separator. */ private const val LABEL_SEPARATOR: String = "." -/** First non-ASCII code point; a label with any point `>=` this needs ACE encoding. */ -private const val NON_ASCII_MIN: Int = 0x80 - -/** Distance from an uppercase ASCII letter to its lowercase counterpart (`'A'` -> `'a'`). */ -private const val ASCII_CASE_OFFSET: Int = 0x20 - /** * UTS-46 ToASCII / ToUnicode for IDNA domains (SPEC §7.4, [HOST-26]) under the `Url`-profile * parameter set ([HOST-28]): `CheckHyphens = false`, `CheckBidi = true`, `CheckJoiners = true`, @@ -88,7 +85,7 @@ internal object Idna { */ internal fun domainToAsciiForUrl(domain: String): ParseResult { val result = - if (isAsciiString(domain)) { + if (domain.isAllAscii()) { asciiLowercase(domain) } else { when (val ascii = domainToAscii(domain)) { @@ -240,7 +237,7 @@ internal object Idna { label: String, domain: String, ): ParseResult { - if (label.all { it.code < NON_ASCII_MIN }) { + if (label.isAllAscii()) { return ParseResult.Ok(label) } return when (val encoded = Punycode.encode(label)) { @@ -264,12 +261,9 @@ internal object Idna { private fun idnaError(domain: String): ParseResult.Err = ParseResult.Err(UriParseError.InvalidHost(domain, HostError.IdnaFailed)) - /** True when every UTF-16 unit of [s] is ASCII (`< 0x80`); an ASCII domain skips UTS-46 ToASCII. */ - private fun isAsciiString(s: String): Boolean = s.all { it.code < NON_ASCII_MIN } - /** ASCII-lowercases [s] (`A`–`Z` -> `a`–`z`), leaving every other code unit unchanged. */ private fun asciiLowercase(s: String): String = buildString(s.length) { - for (c in s) append(if (c in 'A'..'Z') c + ASCII_CASE_OFFSET else c) + for (c in s) append(c.asciiLowercased()) } } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/IriMapping.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/IriMapping.kt index 77e0201..a85614a 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/IriMapping.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/IriMapping.kt @@ -12,9 +12,7 @@ import org.dexpace.kuri.percent.PercentCodec import org.dexpace.kuri.percent.PercentEncodeSets import org.dexpace.kuri.scheme.Scheme import org.dexpace.kuri.scheme.schemeColonIndex - -/** First non-ASCII code point; a host carrying any point `>=` this is routed through IDNA. */ -private const val NON_ASCII_MIN: Int = 0x80 +import org.dexpace.kuri.text.isAllAscii /** The authority-introducing `//` and the number of code units it spans (RFC 3986 §3.2). */ private const val DOUBLE_SLASH: String = "//" @@ -67,7 +65,7 @@ internal object IriMapping { * UTS-46 [Idna.domainToAscii], whose failure becomes the conversion failure. */ private fun mapHost(host: String): ParseResult { - val verbatim = host.startsWith('[') || isAllAscii(host) + val verbatim = host.startsWith('[') || host.isAllAscii() return when { verbatim -> ParseResult.Ok(host) else -> Idna.domainToAscii(host) @@ -190,9 +188,6 @@ internal object IriMapping { return Authority(userInfo, host, port) } - /** True when every UTF-16 unit of [value] is ASCII (`< 0x80`); an ASCII host skips IDNA ToASCII. */ - private fun isAllAscii(value: String): Boolean = value.all { it.code < NON_ASCII_MIN } - /** The located structural components of an IRI, each carried raw (pre-transform). */ private data class IriParts( val scheme: String?, 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 6b00d78..158d8b8 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt @@ -11,11 +11,11 @@ import org.dexpace.kuri.error.UriParseError import org.dexpace.kuri.host.Host import org.dexpace.kuri.host.HostParser import org.dexpace.kuri.scheme.Scheme +import org.dexpace.kuri.scheme.isSchemeContinuationChar import org.dexpace.kuri.scheme.schemeColonIndex +import org.dexpace.kuri.text.hasPercentHexPairAt import org.dexpace.kuri.text.isAsciiAlpha -import org.dexpace.kuri.text.isAsciiAlphanumeric import org.dexpace.kuri.text.isAsciiDigit -import org.dexpace.kuri.text.isAsciiHexDigit import org.dexpace.kuri.text.isC0Control /** Sentinel returned by the index scanners when no offending position exists. */ @@ -168,12 +168,9 @@ internal object UriParser { when { candidate.isEmpty() -> 0 !candidate[0].isAsciiAlpha() -> 0 - else -> (1 until candidate.length).firstOrNull { !isSchemeTailChar(candidate[it]) } ?: 0 + else -> (1 until candidate.length).firstOrNull { !isSchemeContinuationChar(candidate[it]) } ?: 0 } - /** True when [c] may follow the first code point of a scheme (§6.2; ALPHA / DIGIT / `+` / `-` / `.`). */ - private fun isSchemeTailChar(c: Char): Boolean = c.isAsciiAlphanumeric() || c == '+' || c == '-' || c == '.' - /** Assembles the [Sections] record, splitting the post-scheme remainder into authority and path. */ private fun buildSections( frag: FragmentSplit, @@ -410,19 +407,13 @@ internal object UriParser { var index = 0 var bad = NOT_FOUND while (index < text.length && bad == NOT_FOUND) { - if (text[index] == '%' && !isHexPairAt(text, index)) bad = index + if (text[index] == '%' && !hasPercentHexPairAt(text, index)) bad = index index++ } check(bad == NOT_FOUND || bad in text.indices) { "bad percent index out of range: $bad" } return bad } - /** True when a `%` at [index] is followed by two in-bounds ASCII hex digits. */ - private fun isHexPairAt( - text: String, - index: Int, - ): Boolean = index + 2 < text.length && text[index + 1].isAsciiHexDigit() && text[index + 2].isAsciiHexDigit() - /** The smaller non-negative index of [a] and [b], or the present one, or [NOT_FOUND] when both are absent. */ private fun earliest( a: Int, 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 fb95a19..8145017 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt @@ -9,8 +9,9 @@ import org.dexpace.kuri.error.ValidationError import org.dexpace.kuri.percent.PercentCodec import org.dexpace.kuri.percent.PercentEncodeSets import org.dexpace.kuri.scheme.Scheme +import org.dexpace.kuri.scheme.isSchemeContinuationChar +import org.dexpace.kuri.text.asciiLowercased import org.dexpace.kuri.text.isAsciiAlpha -import org.dexpace.kuri.text.isAsciiAlphanumeric /** The canonical `file` scheme, special-cased throughout the §8 state machine. */ private const val FILE_SCHEME: String = "file" @@ -38,7 +39,7 @@ internal object UrlParserStates { internal fun schemeStartState(state: UrlParserState): UrlTransition { val c = state.currentChar() if (c != null && c.isAsciiAlpha()) { - state.buffer.append(c.lowercaseChar()) + state.buffer.append(c.asciiLowercased()) return UrlTransition.Advance(UrlState.SCHEME) } return UrlTransition.Reconsume(UrlState.NO_SCHEME) @@ -54,8 +55,8 @@ internal object UrlParserStates { internal fun schemeState(state: UrlParserState): UrlTransition { val c = state.currentChar() return when { - c != null && isSchemeTailChar(c) -> { - state.buffer.append(c.lowercaseChar()) + c != null && isSchemeContinuationChar(c) -> { + state.buffer.append(c.asciiLowercased()) UrlTransition.Advance(UrlState.SCHEME) } c == ':' -> schemeColon(state) @@ -63,9 +64,6 @@ internal object UrlParserStates { } } - /** True when [c] may continue a scheme: ASCII alphanumeric, `+`, `-`, or `.` ([PARSE-14]). */ - private fun isSchemeTailChar(c: Char): Boolean = c.isAsciiAlphanumeric() || c == '+' || c == '-' || c == '.' - /** Resets the scan to NO_SCHEME from the first code point ([PARSE-16]; the only `pos` reset). */ private fun schemeRestart(state: UrlParserState): UrlTransition { state.buffer.setLength(0) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/percent/PercentCodec.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/percent/PercentCodec.kt index cc773f1..b9dfd37 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/percent/PercentCodec.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/percent/PercentCodec.kt @@ -4,26 +4,21 @@ */ package org.dexpace.kuri.percent -import org.dexpace.kuri.text.hexDigitToInt -import org.dexpace.kuri.text.isAsciiHexDigit +import org.dexpace.kuri.text.NON_ASCII_MIN +import org.dexpace.kuri.text.hasPercentHexPairAt import org.dexpace.kuri.text.isSurrogatePairAt +import org.dexpace.kuri.text.percentByteAt import org.dexpace.kuri.text.toPercentEncodedByte /** Mask isolating the low eight bits of an `Int`, turning a signed `Byte` into an octet `0..255`. */ private const val BYTE_MASK: Int = 0xFF -/** Bit width of one hex nibble; shifting the high hex digit left by this composes a triplet octet. */ -private const val HEX_SHIFT: Int = 4 - /** Length of a percent-encoded triplet, `%XX` (SPEC §5). */ private const val TRIPLET_LENGTH: Int = 3 /** U+FFFD, substituted for a lone surrogate that cannot be UTF-8 encoded ([PCT-21]). */ private const val REPLACEMENT_CHARACTER: String = "\uFFFD" -/** First non-ASCII octet value; a triplet run decodes to display text only when every octet is `>=` this. */ -private const val NON_ASCII_MIN: Int = 0x80 - /** * Percent-encoding ([encode], SPEC §5.2) and percent-decoding ([decode], SPEC §5.3). * @@ -99,7 +94,7 @@ internal object PercentCodec { val c = input[i] i = when { - c == '%' && hasHexPairAt(input, i) -> appendTripletRun(out, input, i) + c == '%' && hasPercentHexPairAt(input, i) -> appendTripletRun(out, input, i) plusAsSpace && c == '+' -> appendLiteral(out, ' ', i) else -> appendLiteral(out, c, i) } @@ -129,7 +124,7 @@ internal object PercentCodec { val c = input[i] i = when { - c == '%' && hasHexPairAt(input, i) -> appendNonAsciiRun(out, input, i) + c == '%' && hasPercentHexPairAt(input, i) -> appendNonAsciiRun(out, input, i) else -> appendLiteral(out, c, i) } } @@ -165,7 +160,7 @@ internal object PercentCodec { var source = start var target = 0 while (target < count) { - bytes[target] = tripletByte(input, source).toByte() + bytes[target] = percentByteAt(input, source).toByte() source += TRIPLET_LENGTH target++ } @@ -185,7 +180,7 @@ internal object PercentCodec { private fun isNonAsciiTripletAt( input: String, i: Int, - ): Boolean = input[i] == '%' && hasHexPairAt(input, i) && tripletByte(input, i) >= NON_ASCII_MIN + ): Boolean = input[i] == '%' && hasPercentHexPairAt(input, i) && percentByteAt(input, i) >= NON_ASCII_MIN /** * Returns the index of the first code unit that requires encoding under [set], or `-1` when @@ -256,7 +251,7 @@ internal object PercentCodec { ): Int { require(input[start] == '%') { "triplet run must start at a percent sign" } var end = start - while (end < input.length && input[end] == '%' && hasHexPairAt(input, end)) { + while (end < input.length && input[end] == '%' && hasPercentHexPairAt(input, end)) { end += TRIPLET_LENGTH } val count = (end - start) / TRIPLET_LENGTH @@ -265,32 +260,11 @@ internal object PercentCodec { var source = start var target = 0 while (target < count) { - bytes[target] = tripletByte(input, source).toByte() + bytes[target] = percentByteAt(input, source).toByte() source += TRIPLET_LENGTH target++ } out.append(bytes.decodeToString()) return end } - - /** True when [i] holds a `%` followed by two ASCII hex digits ([PCT-22], [PCT-24]). */ - private fun hasHexPairAt( - input: String, - i: Int, - ): Boolean { - val high = i + 1 - val low = i + 2 - return low < input.length && input[high].isAsciiHexDigit() && input[low].isAsciiHexDigit() - } - - /** Decodes the two hex digits following the `%` at [i] into one octet `0..255`. */ - private fun tripletByte( - input: String, - i: Int, - ): Int { - val high = hexDigitToInt(input[i + 1]) - val low = hexDigitToInt(input[i + 2]) - check(high >= 0 && low >= 0) { "triplet bytes must follow a verified hex pair" } - return (high shl HEX_SHIFT) or low - } } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/scheme/Scheme.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/scheme/Scheme.kt index f35aa4c..adced82 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/scheme/Scheme.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/scheme/Scheme.kt @@ -4,27 +4,15 @@ */ package org.dexpace.kuri.scheme +import org.dexpace.kuri.text.asciiLowercased import org.dexpace.kuri.text.isAsciiAlpha -import org.dexpace.kuri.text.isAsciiDigit - -/** ASCII distance from an upper-case letter to its lower-case counterpart (`'a' - 'A'`, 32). */ -private const val ASCII_CASE_OFFSET: Int = 'a' - 'A' - -/** - * Lower-cases a single ASCII letter, leaving every other code point untouched (SPEC §6.3). - * - * Mapping is by exact numeric range so it is locale-invariant: no Unicode case folding and - * no Turkish dotless-`i` mapping can occur ([SCH-16]). A valid scheme is ASCII-only, so this - * is total over every code point a scheme can contain. - */ -private fun Char.asciiLowercasedChar(): Char = if (this in 'A'..'Z') this + ASCII_CASE_OFFSET else this +import org.dexpace.kuri.text.isAsciiAlphanumeric /** - * True when [c] may appear after the first code point of a scheme: ASCII alpha, ASCII digit, + * True when [c] may appear after the first code point of a scheme: ASCII alphanumeric, * `+`, `-`, or `.` (SPEC §6.2, Table 6-2; [SCH-8]). */ -private fun isSchemeTailChar(c: Char): Boolean = - c.isAsciiAlpha() || c.isAsciiDigit() || c == '+' || c == '-' || c == '.' +internal fun isSchemeContinuationChar(c: Char): Boolean = c.isAsciiAlphanumeric() || c == '+' || c == '-' || c == '.' /** * The index of the `:` in [text] that would introduce a `scheme:` prefix — the first `:` when it @@ -64,7 +52,7 @@ internal object Scheme { return false } val firstIsAlpha = value[0].isAsciiAlpha() - val tailIsValid = (1 until value.length).all { isSchemeTailChar(value[it]) } + val tailIsValid = (1 until value.length).all { isSchemeContinuationChar(value[it]) } return firstIsAlpha && tailIsValid } @@ -79,7 +67,7 @@ internal object Scheme { * @return [value] with every ASCII `A`–`Z` mapped to `a`–`z`. */ fun normalize(value: String): String { - val normalized = buildString(value.length) { value.forEach { append(it.asciiLowercasedChar()) } } + val normalized = buildString(value.length) { value.forEach { append(it.asciiLowercased()) } } check(normalized.length == value.length) { "scheme normalization changed length: $value" } check(normalized.none { it in 'A'..'Z' }) { "scheme normalization left upper-case: $normalized" } return normalized 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 9a3006d..d9a493f 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt @@ -11,25 +11,17 @@ import org.dexpace.kuri.parser.UrlPath import org.dexpace.kuri.parser.splitUriPath import org.dexpace.kuri.parser.toUriPathString import org.dexpace.kuri.scheme.Scheme -import org.dexpace.kuri.text.hexDigitToInt -import org.dexpace.kuri.text.isAsciiAlphanumeric -import org.dexpace.kuri.text.isAsciiHexDigit +import org.dexpace.kuri.text.asciiLowercased +import org.dexpace.kuri.text.hasPercentHexPairAt +import org.dexpace.kuri.text.isUnreserved +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 -/** Bit width of one hex nibble; the high triplet digit is shifted left by this to form the octet. */ -private const val HEX_SHIFT: Int = 4 - /** Path-segment separator used when re-stringifying and re-splitting a hierarchical path. */ private const val SLASH: String = "/" -/** The non-alphanumeric members of RFC 3986 `unreserved` (`ALPHA`/`DIGIT` are tested separately). */ -private const val UNRESERVED_SYMBOLS: String = "-._~" - -/** ASCII distance from an upper-case letter to its lower-case counterpart (`'a' - 'A'`, 32). */ -private const val ASCII_CASE_OFFSET: Int = 'a' - 'A' - /** * 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). @@ -103,11 +95,11 @@ internal object UriNormalizer { text: String, i: Int, ): Int { - if (text[i] == '%' && isHexPairAt(text, i)) { + if (text[i] == '%' && hasPercentHexPairAt(text, i)) { out.append(text.substring(i, i + TRIPLET_LENGTH)) return i + TRIPLET_LENGTH } - out.append(asciiLowercased(text[i])) + out.append(text[i].asciiLowercased()) return i + 1 } @@ -133,7 +125,7 @@ internal object UriNormalizer { text: String, i: Int, ): Int { - if (text[i] != '%' || !isHexPairAt(text, i)) { + if (text[i] != '%' || !hasPercentHexPairAt(text, i)) { out.append(text[i]) return i + 1 } @@ -158,13 +150,13 @@ internal object UriNormalizer { text: String, i: Int, ): Int { - if (text[i] != '%' || !isHexPairAt(text, i)) { + if (text[i] != '%' || !hasPercentHexPairAt(text, i)) { out.append(text[i]) return i + 1 } - val octet = (hexDigitToInt(text[i + 1]) shl HEX_SHIFT) or hexDigitToInt(text[i + 2]) + val octet = percentByteAt(text, i) val decoded = octet.toChar() - if (isUnreserved(decoded)) out.append(decoded) else out.append(text.substring(i, i + TRIPLET_LENGTH)) + if (decoded.isUnreserved()) out.append(decoded) else out.append(text.substring(i, i + TRIPLET_LENGTH)) return i + TRIPLET_LENGTH } @@ -203,18 +195,4 @@ internal object UriNormalizer { port == Scheme.defaultPort(scheme) -> null else -> port } - - // --- shared predicates ------------------------------------------------------------------------- - - /** True when a `%` at [i] is followed by two in-bounds ASCII hex digits ([GRAM-6]). */ - private fun isHexPairAt( - text: String, - i: Int, - ): Boolean = i + 2 < text.length && text[i + 1].isAsciiHexDigit() && text[i + 2].isAsciiHexDigit() - - /** True when [c] is RFC 3986 `unreserved`: an ASCII alphanumeric or one of `- . _ ~`. */ - private fun isUnreserved(c: Char): Boolean = c.isAsciiAlphanumeric() || c in UNRESERVED_SYMBOLS - - /** Lowercases a single ASCII letter, leaving every other code point untouched (locale-invariant). */ - private fun asciiLowercased(c: Char): Char = if (c in 'A'..'Z') c + ASCII_CASE_OFFSET else c } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/text/CharClasses.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/text/CharClasses.kt index ff13548..4b50c5b 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/text/CharClasses.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/text/CharClasses.kt @@ -2,6 +2,12 @@ * Copyright (c) 2026 dexpace and Omar Aljarrah * SPDX-License-Identifier: MIT */ + +// The shared ASCII grammar / percent-triplet predicates the parser, host, percent, scheme, and +// serialize engines all key off; grouping them here keeps a single definition per rule (hence the +// per-file function count legitimately exceeds detekt's heuristic). +@file:Suppress("TooManyFunctions") + package org.dexpace.kuri.text /** Hexadecimal alphabet used to emit percent-encoded triplets; uppercase per [PCT-19]. */ @@ -25,6 +31,15 @@ private const val LOW_NIBBLE_MASK: Int = 0xF /** Inclusive upper bound of the C0 control range, `U+001F` (SPEC §2.1). */ private const val C0_CONTROL_MAX: Char = '\u001F' +/** ASCII distance from an upper-case letter to its lower-case counterpart (`'a' - 'A'`, 32). */ +private const val ASCII_CASE_OFFSET: Int = 'a' - 'A' + +/** RFC 3986 `unreserved` symbol characters beyond ALPHA/DIGIT (`ALPHA / DIGIT / "-._~"`). */ +private const val UNRESERVED_SYMBOLS: String = "-._~" + +/** First non-ASCII code point (`U+0080`); a code unit below this is ASCII (SPEC §2.1). */ +internal const val NON_ASCII_MIN: Int = 0x80 + /** * True when this char is an ASCII alpha, `A`–`Z` or `a`–`z` (SPEC §2.1, [TERM-1]). * @@ -47,6 +62,21 @@ internal fun Char.isAsciiHexDigit(): Boolean = isAsciiDigit() || this in 'A'..'F /** True when this char is an ASCII alphanumeric: an alpha or a digit (SPEC §2.1). */ internal fun Char.isAsciiAlphanumeric(): Boolean = isAsciiAlpha() || isAsciiDigit() +/** True when this char is an RFC 3986 `unreserved` code point: an ASCII alphanumeric or `-` `.` `_` `~`. */ +internal fun Char.isUnreserved(): Boolean = isAsciiAlphanumeric() || this in UNRESERVED_SYMBOLS + +/** + * Lower-cases a single ASCII letter, leaving every other code point untouched (SPEC §6.3). + * + * The mapping is by exact numeric range, so it is locale-invariant: no Unicode case folding and no + * Turkish dotless-`i` mapping can occur. Callers relying on this to match a spec's ASCII-lowercase + * step must ensure the input is ASCII where the two would otherwise diverge. + */ +internal fun Char.asciiLowercased(): Char = if (this in 'A'..'Z') this + ASCII_CASE_OFFSET else this + +/** True when every UTF-16 code unit of this sequence is ASCII (`< U+0080`). */ +internal fun CharSequence.isAllAscii(): Boolean = all { it.code < NON_ASCII_MIN } + /** True when this char is a C0 control, `U+0000`–`U+001F` (SPEC §2.1 / §4.2.1). */ internal fun Char.isC0Control(): Boolean = this <= C0_CONTROL_MAX @@ -80,6 +110,44 @@ internal fun hexDigitToInt(c: Char): Int { return value } +/** + * True when a `pct-encoded` triplet's two hex digits sit in bounds at [index]+1 and [index]+2 of + * [text] (SPEC §2.1; [GRAM-6]). + * + * Tests only the two trailing HEXDIG and their bounds; the `%` at [index] itself is the caller's + * precondition, so this composes with a `text[index] == '%'` guard at the call site. + * + * @param text the sequence being scanned. + * @param index the index of the `%` introducing the candidate triplet. + * @return `true` iff [index]+2 is in bounds and both following code units are ASCII hex digits. + */ +internal fun hasPercentHexPairAt( + text: CharSequence, + index: Int, +): Boolean = index + 2 < text.length && text[index + 1].isAsciiHexDigit() && text[index + 2].isAsciiHexDigit() + +/** + * Decodes the two hex digits following the `%` at [index] into one octet `0..255` (SPEC §5.3). + * + * The caller MUST have verified a valid triplet (e.g. via [hasPercentHexPairAt]). This re-checks + * that precondition on the two decoded nibbles — free, since [hexDigitToInt] runs regardless — so a + * misuse fails loudly instead of silently yielding a garbage octet from a `-1` nibble. + * + * @param text the sequence holding the triplet. + * @param index the index of the `%`; the hex digits are read at [index]+1 and [index]+2. + * @return the decoded octet in `0..255`. + * @throws IllegalArgumentException if the code unit at [index]+1 or [index]+2 is not an ASCII hex digit. + */ +internal fun percentByteAt( + text: CharSequence, + index: Int, +): Int { + val high = hexDigitToInt(text[index + 1]) + val low = hexDigitToInt(text[index + 2]) + require(high >= 0 && low >= 0) { "percentByteAt requires a verified hex pair at index $index" } + return (high shl HEX_SHIFT) or low +} + /** * Encodes a single octet `0..255` as an uppercase percent-encoded triplet `"%XX"` (SPEC * §5; uppercase output mandated by [PCT-19]).