From ba374bb45ce2a8b309fcd5fe6e0cb02a011e8441 Mon Sep 17 00:00:00 2001 From: Abduqodiri Qurbonzoda Date: Fri, 8 Sep 2023 22:40:56 +0000 Subject: [PATCH] Optimize hex formatting and parsing implementation #KT-58218 Merge-request: KT-MR-11702 Merged-by: Abduqodiri Qurbonzoda --- .../stdlib/src/kotlin/text/HexExtensions.kt | 689 +++++++++++++++--- libraries/stdlib/src/kotlin/text/HexFormat.kt | 30 + .../stdlib/test/text/BytesHexFormatTest.kt | 99 ++- .../stdlib/test/text/NumberHexFormatTest.kt | 62 +- 4 files changed, 714 insertions(+), 166 deletions(-) diff --git a/libraries/stdlib/src/kotlin/text/HexExtensions.kt b/libraries/stdlib/src/kotlin/text/HexExtensions.kt index b4545840478..ca0d917d41a 100644 --- a/libraries/stdlib/src/kotlin/text/HexExtensions.kt +++ b/libraries/stdlib/src/kotlin/text/HexExtensions.kt @@ -5,15 +5,56 @@ package kotlin.text +// Benchmarks repository: https://github.com/qurbonzoda/KotlinHexFormatBenchmarks + private const val LOWER_CASE_HEX_DIGITS = "0123456789abcdef" private const val UPPER_CASE_HEX_DIGITS = "0123456789ABCDEF" -// case-insensitive parsing -private val HEX_DIGITS_TO_DECIMAL = IntArray(128) { -1 }.apply { +/** + * The table for converting Byte values to their two-digit hex representation. + * + * It's used for formatting ByteArray. Storing the hex representation + * of each Byte value makes it possible to access the table only once per Byte. + * This noticeably improves performance, especially for large ByteArray's. + */ +private val BYTE_TO_LOWER_CASE_HEX_DIGITS = IntArray(256) { + (LOWER_CASE_HEX_DIGITS[(it shr 4)].code shl 8) or LOWER_CASE_HEX_DIGITS[(it and 0xF)].code +} + +/** + * The table for converting Byte values to their two-digit hex representation. + * + * @see BYTE_TO_LOWER_CASE_HEX_DIGITS + */ +private val BYTE_TO_UPPER_CASE_HEX_DIGITS = IntArray(256) { + (UPPER_CASE_HEX_DIGITS[(it shr 4)].code shl 8) or UPPER_CASE_HEX_DIGITS[(it and 0xF)].code +} + +/** + * The table for converting hex digits (both lowercase and uppercase) to their `Int` decimal value. + * + * Although `Char.code` of every hex digit is less than 128, the table size is 256 for the following reason: + * If the string whose chars are being converted is ASCII-encoded, the JIT optimizes `charAt` to a byte-sized load. + * When the table is 256 entries wide then both the `code ushr 8 == 0` and array bounds check are eliminated. + * This noticeably improves performance for ASCII strings. + */ +private val HEX_DIGITS_TO_DECIMAL = IntArray(256) { -1 }.apply { LOWER_CASE_HEX_DIGITS.forEachIndexed { index, char -> this[char.code] = index } UPPER_CASE_HEX_DIGITS.forEachIndexed { index, char -> this[char.code] = index } } +/** + * The table for converting hex digits (both lowercase and uppercase) to their `Long` decimal value. + * + * Because `Int.toLong()` noticeably impacted performance, this separate table was introduced. + * + * @see HEX_DIGITS_TO_DECIMAL + */ +private val HEX_DIGITS_TO_LONG_DECIMAL = LongArray(256) { -1 }.apply { + LOWER_CASE_HEX_DIGITS.forEachIndexed { index, char -> this[char.code] = index.toLong() } + UPPER_CASE_HEX_DIGITS.forEachIndexed { index, char -> this[char.code] = index.toLong() } +} + // -------------------------- format and parse ByteArray -------------------------- /** @@ -55,9 +96,101 @@ public fun ByteArray.toHexString( return "" } - val digits = if (format.upperCase) UPPER_CASE_HEX_DIGITS else LOWER_CASE_HEX_DIGITS - + val byteToDigits = if (format.upperCase) BYTE_TO_UPPER_CASE_HEX_DIGITS else BYTE_TO_LOWER_CASE_HEX_DIGITS val bytesFormat = format.bytes + + // Optimize for formats with unspecified bytesPerLine and bytesPerGroup + if (bytesFormat.noLineAndGroupSeparator) { + return toHexStringNoLineAndGroupSeparator(startIndex, endIndex, bytesFormat, byteToDigits) + } + + return toHexStringSlowPath(startIndex, endIndex, bytesFormat, byteToDigits) +} + +@ExperimentalStdlibApi +private fun ByteArray.toHexStringNoLineAndGroupSeparator( + startIndex: Int, + endIndex: Int, + bytesFormat: HexFormat.BytesHexFormat, + byteToDigits: IntArray +): String { + // Optimize for formats with a short byteSeparator and no bytePrefix/Suffix + if (bytesFormat.shortByteSeparatorNoPrefixAndSuffix) { + return toHexStringShortByteSeparatorNoPrefixAndSuffix(startIndex, endIndex, bytesFormat, byteToDigits) + } + + return toHexStringNoLineAndGroupSeparatorSlowPath(startIndex, endIndex, bytesFormat, byteToDigits) +} + +@ExperimentalStdlibApi +private fun ByteArray.toHexStringShortByteSeparatorNoPrefixAndSuffix( + startIndex: Int, + endIndex: Int, + bytesFormat: HexFormat.BytesHexFormat, + byteToDigits: IntArray +): String { + val byteSeparatorLength = bytesFormat.byteSeparator.length + require(byteSeparatorLength <= 1) + + val numberOfBytes = endIndex - startIndex + var charIndex = 0 + + if (byteSeparatorLength == 0) { + val charArray = CharArray(checkFormatLength(2L * numberOfBytes)) + for (byteIndex in startIndex until endIndex) { + charIndex = formatByteAt(byteIndex, byteToDigits, charArray, charIndex) + } + return charArray.concatToString() + } else { + val charArray = CharArray(checkFormatLength(3L * numberOfBytes - 1)) + val byteSeparatorChar = bytesFormat.byteSeparator[0] + + charIndex = formatByteAt(startIndex, byteToDigits, charArray, charIndex) + for (byteIndex in startIndex + 1 until endIndex) { + charArray[charIndex++] = byteSeparatorChar + charIndex = formatByteAt(byteIndex, byteToDigits, charArray, charIndex) + } + + return charArray.concatToString() + } +} + +@ExperimentalStdlibApi +private fun ByteArray.toHexStringNoLineAndGroupSeparatorSlowPath( + startIndex: Int, + endIndex: Int, + bytesFormat: HexFormat.BytesHexFormat, + byteToDigits: IntArray +): String { + val bytePrefix = bytesFormat.bytePrefix + val byteSuffix = bytesFormat.byteSuffix + val byteSeparator = bytesFormat.byteSeparator + + val formatLength = formattedStringLength( + numberOfBytes = endIndex - startIndex, + byteSeparator.length, + bytePrefix.length, + byteSuffix.length + ) + val charArray = CharArray(formatLength) + var charIndex = 0 + + charIndex = formatByteAt(startIndex, bytePrefix, byteSuffix, byteToDigits, charArray, charIndex) + for (byteIndex in startIndex + 1 until endIndex) { + charIndex = byteSeparator.toCharArrayIfNotEmpty(charArray, charIndex) + charIndex = formatByteAt(byteIndex, bytePrefix, byteSuffix, byteToDigits, charArray, charIndex) + } + + return charArray.concatToString() +} + +@ExperimentalStdlibApi +private fun ByteArray.toHexStringSlowPath( + startIndex: Int, + endIndex: Int, + bytesFormat: HexFormat.BytesHexFormat, + byteToDigits: IntArray +): String { val bytesPerLine = bytesFormat.bytesPerLine val bytesPerGroup = bytesFormat.bytesPerGroup val bytePrefix = bytesFormat.bytePrefix @@ -66,7 +199,7 @@ public fun ByteArray.toHexString( val groupSeparator = bytesFormat.groupSeparator val formatLength = formattedStringLength( - totalBytes = endIndex - startIndex, + numberOfBytes = endIndex - startIndex, bytesPerLine, bytesPerGroup, groupSeparator.length, @@ -74,42 +207,77 @@ public fun ByteArray.toHexString( bytePrefix.length, byteSuffix.length ) + val charArray = CharArray(formatLength) + var charIndex = 0 var indexInLine = 0 var indexInGroup = 0 - return buildString(formatLength) { - for (i in startIndex until endIndex) { - val byte = this@toHexString[i].toInt() and 0xFF - - if (indexInLine == bytesPerLine) { - append('\n') - indexInLine = 0 - indexInGroup = 0 - } else if (indexInGroup == bytesPerGroup) { - append(groupSeparator) - indexInGroup = 0 - } - if (indexInGroup != 0) { - append(byteSeparator) - } - - append(bytePrefix) - append(digits[byte shr 4]) - append(digits[byte and 0xF]) - append(byteSuffix) - - indexInGroup += 1 - indexInLine += 1 + for (byteIndex in startIndex until endIndex) { + if (indexInLine == bytesPerLine) { + charArray[charIndex++] = '\n' + indexInLine = 0 + indexInGroup = 0 + } else if (indexInGroup == bytesPerGroup) { + charIndex = groupSeparator.toCharArrayIfNotEmpty(charArray, charIndex) + indexInGroup = 0 + } + if (indexInGroup != 0) { + charIndex = byteSeparator.toCharArrayIfNotEmpty(charArray, charIndex) } - check(formatLength == length) + charIndex = formatByteAt(byteIndex, bytePrefix, byteSuffix, byteToDigits, charArray, charIndex) + + indexInGroup += 1 + indexInLine += 1 } + + check(charIndex == formatLength) + return charArray.concatToString() +} + +private fun ByteArray.formatByteAt( + index: Int, + bytePrefix: String, + byteSuffix: String, + byteToDigits: IntArray, + destination: CharArray, + destinationOffset: Int +): Int { + var offset = bytePrefix.toCharArrayIfNotEmpty(destination, destinationOffset) + offset = formatByteAt(index, byteToDigits, destination, offset) + return byteSuffix.toCharArrayIfNotEmpty(destination, offset) +} + +private fun ByteArray.formatByteAt( + index: Int, + byteToDigits: IntArray, + destination: CharArray, + destinationOffset: Int +): Int { + val byte = this[index].toInt() and 0xFF + val byteDigits = byteToDigits[byte] + destination[destinationOffset] = (byteDigits shr 8).toChar() + destination[destinationOffset + 1] = (byteDigits and 0xFF).toChar() + return destinationOffset + 2 +} + +private fun formattedStringLength( + numberOfBytes: Int, + byteSeparatorLength: Int, + bytePrefixLength: Int, + byteSuffixLength: Int +): Int { + require(numberOfBytes > 0) + + val charsPerByte = 2L + bytePrefixLength + byteSuffixLength + byteSeparatorLength + val formatLength = numberOfBytes * charsPerByte - byteSeparatorLength + return checkFormatLength(formatLength) } // Declared internal for testing internal fun formattedStringLength( - totalBytes: Int, + numberOfBytes: Int, bytesPerLine: Int, bytesPerGroup: Int, groupSeparatorLength: Int, @@ -117,34 +285,37 @@ internal fun formattedStringLength( bytePrefixLength: Int, byteSuffixLength: Int ): Int { - require(totalBytes > 0) + require(numberOfBytes > 0) // By contract bytesPerLine and bytesPerGroup are > 0 - val lineSeparators = (totalBytes - 1) / bytesPerLine + val lineSeparators = (numberOfBytes - 1) / bytesPerLine val groupSeparators = run { val groupSeparatorsPerLine = (bytesPerLine - 1) / bytesPerGroup - val bytesInLastLine = (totalBytes % bytesPerLine).let { if (it == 0) bytesPerLine else it } + val bytesInLastLine = (numberOfBytes % bytesPerLine).let { if (it == 0) bytesPerLine else it } val groupSeparatorsInLastLine = (bytesInLastLine - 1) / bytesPerGroup lineSeparators * groupSeparatorsPerLine + groupSeparatorsInLastLine } - val byteSeparators = totalBytes - 1 - lineSeparators - groupSeparators + val byteSeparators = numberOfBytes - 1 - lineSeparators - groupSeparators - // The max totalLength is achieved when - // totalBytes, bytePrefix/Suffix/Separator.length = Int.MAX_VALUE. + // The max formatLength is achieved when + // numberOfBytes, bytePrefix/Suffix/Separator.length = Int.MAX_VALUE. // The result is 3 * Int.MAX_VALUE * Int.MAX_VALUE + Int.MAX_VALUE, // which is > Long.MAX_VALUE, but < ULong.MAX_VALUE. - val totalLength: Long = lineSeparators.toLong() /* * lineSeparator.length = 1 */ + + val formatLength: Long = lineSeparators.toLong() /* * lineSeparator.length = 1 */ + groupSeparators.toLong() * groupSeparatorLength.toLong() + byteSeparators.toLong() * byteSeparatorLength.toLong() + - totalBytes.toLong() * (bytePrefixLength.toLong() + 2L + byteSuffixLength.toLong()) + numberOfBytes.toLong() * (bytePrefixLength.toLong() + 2L + byteSuffixLength.toLong()) - if (totalLength !in 0..Int.MAX_VALUE) { + return checkFormatLength(formatLength) +} + +private fun checkFormatLength(formatLength: Long): Int { + if (formatLength !in 0..Int.MAX_VALUE) { // TODO: Common OutOfMemoryError? - throw IllegalArgumentException("The resulting string length is too big: ${totalLength.toULong()}") + throw IllegalArgumentException("The resulting string length is too big: ${formatLength.toULong()}") } - - return totalLength.toInt() + return formatLength.toInt() } /** @@ -191,14 +362,119 @@ private fun String.hexToByteArray( } val bytesFormat = format.bytes + + // Optimize for formats with unspecified bytesPerLine and bytesPerGroup + if (bytesFormat.noLineAndGroupSeparator) { + hexToByteArrayNoLineAndGroupSeparator(startIndex, endIndex, bytesFormat)?.let { return it } + } + + return hexToByteArraySlowPath(startIndex, endIndex, bytesFormat) +} + +@ExperimentalStdlibApi +private fun String.hexToByteArrayNoLineAndGroupSeparator( + startIndex: Int, + endIndex: Int, + bytesFormat: HexFormat.BytesHexFormat +): ByteArray? { + // Optimize for formats with a short byteSeparator and no bytePrefix/Suffix + if (bytesFormat.shortByteSeparatorNoPrefixAndSuffix) { + return hexToByteArrayShortByteSeparatorNoPrefixAndSuffix(startIndex, endIndex, bytesFormat) + } + + return hexToByteArrayNoLineAndGroupSeparatorSlowPath(startIndex, endIndex, bytesFormat) +} + +@ExperimentalStdlibApi +private fun String.hexToByteArrayShortByteSeparatorNoPrefixAndSuffix( + startIndex: Int, + endIndex: Int, + bytesFormat: HexFormat.BytesHexFormat +): ByteArray? { + val byteSeparatorLength = bytesFormat.byteSeparator.length + require(byteSeparatorLength <= 1) + + val numberOfChars = endIndex - startIndex + var charIndex = 0 + + if (byteSeparatorLength == 0) { + if (numberOfChars and 1 != 0) return null + val numberOfBytes = numberOfChars shr 1 + val byteArray = ByteArray(numberOfBytes) + for (byteIndex in 0 until numberOfBytes) { + byteArray[byteIndex] = parseByteAt(charIndex) + charIndex += 2 + } + return byteArray + } else { + if (numberOfChars % 3 != 2) return null + val numberOfBytes = numberOfChars / 3 + 1 + val byteArray = ByteArray(numberOfBytes) + val byteSeparatorChar = bytesFormat.byteSeparator[0] + byteArray[0] = parseByteAt(charIndex) + charIndex += 2 + for (byteIndex in 1 until numberOfBytes) { + if (this[charIndex] != byteSeparatorChar) { + checkContainsAt(charIndex, endIndex, bytesFormat.byteSeparator, bytesFormat.ignoreCase, "byte separator") + } + byteArray[byteIndex] = parseByteAt(charIndex + 1) + charIndex += 3 + } + return byteArray + } +} + +@ExperimentalStdlibApi +private fun String.hexToByteArrayNoLineAndGroupSeparatorSlowPath( + startIndex: Int, + endIndex: Int, + bytesFormat: HexFormat.BytesHexFormat +): ByteArray? { + val bytePrefix = bytesFormat.bytePrefix + val byteSuffix = bytesFormat.byteSuffix + val byteSeparator = bytesFormat.byteSeparator + val byteSeparatorLength = byteSeparator.length + val charsPerByte = 2L + bytePrefix.length + byteSuffix.length + byteSeparatorLength + val numberOfChars = (endIndex - startIndex).toLong() + val numberOfBytes = ((numberOfChars + byteSeparatorLength) / charsPerByte).toInt() + + // Go to the default implementation when the string length doesn't match + if (numberOfBytes * charsPerByte - byteSeparatorLength != numberOfChars) { + return null + } + + val ignoreCase = bytesFormat.ignoreCase + + val byteArray = ByteArray(numberOfBytes) + var charIndex = startIndex + + charIndex = checkContainsAt(charIndex, endIndex, bytePrefix, ignoreCase, "byte prefix") + val between = byteSuffix + byteSeparator + bytePrefix + for (byteIndex in 0 until numberOfBytes - 1) { + byteArray[byteIndex] = parseByteAt(charIndex) + charIndex = checkContainsAt(charIndex + 2, endIndex, between, ignoreCase, "byte suffix + byte separator + byte prefix") + } + byteArray[numberOfBytes - 1] = parseByteAt(charIndex) + checkContainsAt(charIndex + 2, endIndex, byteSuffix, ignoreCase, "byte suffix") + + return byteArray +} + +@ExperimentalStdlibApi +private fun String.hexToByteArraySlowPath( + startIndex: Int, + endIndex: Int, + bytesFormat: HexFormat.BytesHexFormat +): ByteArray { val bytesPerLine = bytesFormat.bytesPerLine val bytesPerGroup = bytesFormat.bytesPerGroup val bytePrefix = bytesFormat.bytePrefix val byteSuffix = bytesFormat.byteSuffix val byteSeparator = bytesFormat.byteSeparator val groupSeparator = bytesFormat.groupSeparator + val ignoreCase = bytesFormat.ignoreCase - val resultCapacity = parsedByteArrayMaxSize( + val parseMaxSize = parsedByteArrayMaxSize( stringLength = endIndex - startIndex, bytesPerLine, bytesPerGroup, @@ -207,37 +483,42 @@ private fun String.hexToByteArray( bytePrefix.length, byteSuffix.length ) - val result = ByteArray(resultCapacity) + val byteArray = ByteArray(parseMaxSize) - var i = startIndex + var charIndex = startIndex var byteIndex = 0 var indexInLine = 0 var indexInGroup = 0 - while (i < endIndex) { + while (charIndex < endIndex) { if (indexInLine == bytesPerLine) { - i = checkNewLineAt(i, endIndex) + charIndex = checkNewLineAt(charIndex, endIndex) indexInLine = 0 indexInGroup = 0 } else if (indexInGroup == bytesPerGroup) { - i = checkContainsAt(groupSeparator, i, endIndex, "group separator") + charIndex = checkContainsAt(charIndex, endIndex, groupSeparator, ignoreCase, "group separator") indexInGroup = 0 } else if (indexInGroup != 0) { - i = checkContainsAt(byteSeparator, i, endIndex, "byte separator") + charIndex = checkContainsAt(charIndex, endIndex, byteSeparator, ignoreCase, "byte separator") } indexInLine += 1 indexInGroup += 1 - i = checkContainsAt(bytePrefix, i, endIndex, "byte prefix") - - checkHexLength(i, (i + 2).coerceAtMost(endIndex), maxDigits = 2, requireMaxLength = true) - - result[byteIndex++] = ((decimalFromHexDigitAt(i++) shl 4) or decimalFromHexDigitAt(i++)).toByte() - - i = checkContainsAt(byteSuffix, i, endIndex, "byte suffix") + charIndex = checkContainsAt(charIndex, endIndex, bytePrefix, ignoreCase, "byte prefix") + if (endIndex - 2 < charIndex) { + throwInvalidNumberOfDigits(charIndex, endIndex, maxDigits = 2, requireMaxLength = true) + } + byteArray[byteIndex++] = parseByteAt(charIndex) + charIndex = checkContainsAt(charIndex + 2, endIndex, byteSuffix, ignoreCase, "byte suffix") } - return if (byteIndex == result.size) result else result.copyOf(byteIndex) + return if (byteIndex == byteArray.size) byteArray else byteArray.copyOf(byteIndex) +} + +private fun String.parseByteAt(index: Int): Byte { + val high = decimalFromHexDigitAt(index) + val low = decimalFromHexDigitAt(index + 1) + return ((high shl 4) or low).toByte() } // Declared internal for testing @@ -329,7 +610,24 @@ private fun String.checkNewLineAt(index: Int, endIndex: Int): Int { */ @ExperimentalStdlibApi @SinceKotlin("1.9") -public fun Byte.toHexString(format: HexFormat = HexFormat.Default): String = toLong().toHexStringImpl(format, bits = 8) +public fun Byte.toHexString(format: HexFormat = HexFormat.Default): String { + val digits = if (format.upperCase) UPPER_CASE_HEX_DIGITS else LOWER_CASE_HEX_DIGITS + val numberFormat = format.number + + // Optimize for digits-only formats + if (numberFormat.isDigitsOnly) { + val charArray = CharArray(2) + val value = this.toInt() + charArray[0] = digits[(value shr 4) and 0xF] + charArray[1] = digits[value and 0xF] + return if (numberFormat.removeLeadingZeros) + charArray.concatToString(startIndex = (countLeadingZeroBits() shr 2).coerceAtMost(1)) + else + charArray.concatToString() + } + + return toLong().toHexStringImpl(numberFormat, digits, bits = 8) +} /** * Parses a `Byte` value from this string using the specified [format]. @@ -362,7 +660,7 @@ public fun String.hexToByte(format: HexFormat = HexFormat.Default): Byte = hexTo @ExperimentalStdlibApi //@SinceKotlin("1.9") private fun String.hexToByte(startIndex: Int = 0, endIndex: Int = length, format: HexFormat = HexFormat.Default): Byte = - hexToLongImpl(startIndex, endIndex, format, maxDigits = 2).toByte() + hexToIntImpl(startIndex, endIndex, format, maxDigits = 2).toByte() // -------------------------- format and parse Short -------------------------- @@ -375,7 +673,26 @@ private fun String.hexToByte(startIndex: Int = 0, endIndex: Int = length, format */ @ExperimentalStdlibApi @SinceKotlin("1.9") -public fun Short.toHexString(format: HexFormat = HexFormat.Default): String = toLong().toHexStringImpl(format, bits = 16) +public fun Short.toHexString(format: HexFormat = HexFormat.Default): String { + val digits = if (format.upperCase) UPPER_CASE_HEX_DIGITS else LOWER_CASE_HEX_DIGITS + val numberFormat = format.number + + // Optimize for digits-only formats + if (numberFormat.isDigitsOnly) { + val charArray = CharArray(4) + val value = this.toInt() + charArray[0] = digits[(value shr 12) and 0xF] + charArray[1] = digits[(value shr 8) and 0xF] + charArray[2] = digits[(value shr 4) and 0xF] + charArray[3] = digits[value and 0xF] + return if (numberFormat.removeLeadingZeros) + charArray.concatToString(startIndex = (countLeadingZeroBits() shr 2).coerceAtMost(3)) + else + charArray.concatToString() + } + + return toLong().toHexStringImpl(numberFormat, digits, bits = 16) +} /** * Parses a `Short` value from this string using the specified [format]. @@ -408,7 +725,7 @@ public fun String.hexToShort(format: HexFormat = HexFormat.Default): Short = hex @ExperimentalStdlibApi //@SinceKotlin("1.9") private fun String.hexToShort(startIndex: Int = 0, endIndex: Int = length, format: HexFormat = HexFormat.Default): Short = - hexToLongImpl(startIndex, endIndex, format, maxDigits = 4).toShort() + hexToIntImpl(startIndex, endIndex, format, maxDigits = 4).toShort() // -------------------------- format and parse Int -------------------------- @@ -421,7 +738,30 @@ private fun String.hexToShort(startIndex: Int = 0, endIndex: Int = length, forma */ @ExperimentalStdlibApi @SinceKotlin("1.9") -public fun Int.toHexString(format: HexFormat = HexFormat.Default): String = toLong().toHexStringImpl(format, bits = 32) +public fun Int.toHexString(format: HexFormat = HexFormat.Default): String { + val digits = if (format.upperCase) UPPER_CASE_HEX_DIGITS else LOWER_CASE_HEX_DIGITS + val numberFormat = format.number + + // Optimize for digits-only formats + if (numberFormat.isDigitsOnly) { + val charArray = CharArray(8) + val value = this + charArray[0] = digits[(value shr 28) and 0xF] + charArray[1] = digits[(value shr 24) and 0xF] + charArray[2] = digits[(value shr 20) and 0xF] + charArray[3] = digits[(value shr 16) and 0xF] + charArray[4] = digits[(value shr 12) and 0xF] + charArray[5] = digits[(value shr 8) and 0xF] + charArray[6] = digits[(value shr 4) and 0xF] + charArray[7] = digits[value and 0xF] + return if (numberFormat.removeLeadingZeros) + charArray.concatToString(startIndex = (countLeadingZeroBits() shr 2).coerceAtMost(7)) + else + charArray.concatToString() + } + + return toLong().toHexStringImpl(numberFormat, digits, bits = 32) +} /** * Parses an `Int` value from this string using the specified [format]. @@ -454,7 +794,7 @@ public fun String.hexToInt(format: HexFormat = HexFormat.Default): Int = hexToIn @ExperimentalStdlibApi //@SinceKotlin("1.9") private fun String.hexToInt(startIndex: Int = 0, endIndex: Int = length, format: HexFormat = HexFormat.Default): Int = - hexToLongImpl(startIndex, endIndex, format, maxDigits = 8).toInt() + hexToIntImpl(startIndex, endIndex, format, maxDigits = 8) // -------------------------- format and parse Long -------------------------- @@ -467,7 +807,38 @@ private fun String.hexToInt(startIndex: Int = 0, endIndex: Int = length, format: */ @ExperimentalStdlibApi @SinceKotlin("1.9") -public fun Long.toHexString(format: HexFormat = HexFormat.Default): String = toHexStringImpl(format, bits = 64) +public fun Long.toHexString(format: HexFormat = HexFormat.Default): String { + val digits = if (format.upperCase) UPPER_CASE_HEX_DIGITS else LOWER_CASE_HEX_DIGITS + val numberFormat = format.number + + // Optimize for digits-only formats + if (numberFormat.isDigitsOnly) { + val charArray = CharArray(16) + val value = this + charArray[0] = digits[((value shr 60) and 0xF).toInt()] + charArray[1] = digits[((value shr 56) and 0xF).toInt()] + charArray[2] = digits[((value shr 52) and 0xF).toInt()] + charArray[3] = digits[((value shr 48) and 0xF).toInt()] + charArray[4] = digits[((value shr 44) and 0xF).toInt()] + charArray[5] = digits[((value shr 40) and 0xF).toInt()] + charArray[6] = digits[((value shr 36) and 0xF).toInt()] + charArray[7] = digits[((value shr 32) and 0xF).toInt()] + charArray[8] = digits[((value shr 28) and 0xF).toInt()] + charArray[9] = digits[((value shr 24) and 0xF).toInt()] + charArray[10] = digits[((value shr 20) and 0xF).toInt()] + charArray[11] = digits[((value shr 16) and 0xF).toInt()] + charArray[12] = digits[((value shr 12) and 0xF).toInt()] + charArray[13] = digits[((value shr 8) and 0xF).toInt()] + charArray[14] = digits[((value shr 4) and 0xF).toInt()] + charArray[15] = digits[(value and 0xF).toInt()] + return if (numberFormat.removeLeadingZeros) + charArray.concatToString(startIndex = (countLeadingZeroBits() shr 2).coerceAtMost(15)) + else + charArray.concatToString() + } + + return toHexStringImpl(numberFormat, digits, bits = 64) +} /** * Parses a `Long` value from this string using the specified [format]. @@ -505,86 +876,174 @@ private fun String.hexToLong(startIndex: Int = 0, endIndex: Int = length, format // -------------------------- private format and parse functions -------------------------- @ExperimentalStdlibApi -private fun Long.toHexStringImpl(format: HexFormat, bits: Int): String { +private fun Long.toHexStringImpl(numberFormat: HexFormat.NumberHexFormat, digits: String, bits: Int): String { require(bits and 0x3 == 0) - val digits = if (format.upperCase) UPPER_CASE_HEX_DIGITS else LOWER_CASE_HEX_DIGITS val value = this + val numberOfHexDigits = bits shr 2 - val prefix = format.number.prefix - val suffix = format.number.suffix - val formatLength = prefix.length + (bits shr 2) + suffix.length - var removeZeros = format.number.removeLeadingZeros + val prefix = numberFormat.prefix + val suffix = numberFormat.suffix + var removeZeros = numberFormat.removeLeadingZeros - return buildString(formatLength) { - append(prefix) + val formatLength = prefix.length.toLong() + numberOfHexDigits + suffix.length + val charArray = CharArray(checkFormatLength(formatLength)) - var shift = bits - while (shift > 0) { - shift -= 4 - val decimal = ((value shr shift) and 0xF).toInt() - removeZeros = removeZeros && decimal == 0 && shift > 0 - if (!removeZeros) { - append(digits[decimal]) - } + var charIndex = prefix.toCharArrayIfNotEmpty(charArray, 0) + + var shift = bits + repeat(numberOfHexDigits) { + shift -= 4 + val decimal = ((value shr shift) and 0xF).toInt() + removeZeros = removeZeros && decimal == 0 && shift > 0 + if (!removeZeros) { + charArray[charIndex++] = digits[decimal] } - - append(suffix) } + + charIndex = suffix.toCharArrayIfNotEmpty(charArray, charIndex) + + return if (charIndex == charArray.size) charArray.concatToString() else charArray.concatToString(endIndex = charIndex) +} + +@OptIn(ExperimentalStdlibApi::class) +private fun String.toCharArrayIfNotEmpty(destination: CharArray, destinationOffset: Int): Int { + when (length) { + 0 -> { /* do nothing */ } + 1 -> destination[destinationOffset] = this[0] + else -> toCharArray(destination, destinationOffset) + } + return destinationOffset + length } @ExperimentalStdlibApi -private fun String.hexToLongImpl(startIndex: Int = 0, endIndex: Int = length, format: HexFormat, maxDigits: Int): Long { +private fun String.hexToIntImpl(startIndex: Int, endIndex: Int, format: HexFormat, maxDigits: Int): Int { AbstractList.checkBoundsIndexes(startIndex, endIndex, length) - val prefix = format.number.prefix - val suffix = format.number.suffix + val numberFormat = format.number - if (prefix.length + suffix.length >= endIndex - startIndex) { - throw NumberFormatException( - "Expected a hexadecimal number with prefix \"$prefix\" and suffix \"$suffix\", but was ${substring(startIndex, endIndex)}" - ) + // Optimize for digits-only formats + if (numberFormat.isDigitsOnly) { + checkMaxDigits(startIndex, endIndex, maxDigits) + return parseInt(startIndex, endIndex) } - val digitsStartIndex = checkContainsAt(prefix, startIndex, endIndex, "prefix") + val prefix = numberFormat.prefix + val suffix = numberFormat.suffix + checkPrefixSuffixMaxDigits(startIndex, endIndex, prefix, suffix, numberFormat.ignoreCase, maxDigits) + return parseInt(startIndex + prefix.length, endIndex - suffix.length) +} + +@ExperimentalStdlibApi +private fun String.hexToLongImpl(startIndex: Int, endIndex: Int, format: HexFormat, maxDigits: Int): Long { + AbstractList.checkBoundsIndexes(startIndex, endIndex, length) + + val numberFormat = format.number + + // Optimize for digits-only formats + if (numberFormat.isDigitsOnly) { + checkMaxDigits(startIndex, endIndex, maxDigits) + return parseLong(startIndex, endIndex) + } + + val prefix = numberFormat.prefix + val suffix = numberFormat.suffix + checkPrefixSuffixMaxDigits(startIndex, endIndex, prefix, suffix, numberFormat.ignoreCase, maxDigits) + return parseLong(startIndex + prefix.length, endIndex - suffix.length) +} + +private fun String.checkPrefixSuffixMaxDigits( + startIndex: Int, + endIndex: Int, + prefix: String, + suffix: String, + ignoreCase: Boolean, + maxDigits: Int +) { + if (endIndex - startIndex - prefix.length <= suffix.length) { + throwInvalidPrefixSuffix(startIndex, endIndex, prefix, suffix) + } + + val digitsStartIndex = checkContainsAt(startIndex, endIndex, prefix, ignoreCase, "prefix") val digitsEndIndex = endIndex - suffix.length - checkContainsAt(suffix, digitsEndIndex, endIndex, "suffix") + checkContainsAt(digitsEndIndex, endIndex, suffix, ignoreCase, "suffix") - checkHexLength(digitsStartIndex, digitsEndIndex, maxDigits, requireMaxLength = false) + checkMaxDigits(digitsStartIndex, digitsEndIndex, maxDigits) +} - var result = 0L - for (i in digitsStartIndex until digitsEndIndex) { - result = (result shl 4) or decimalFromHexDigitAt(i).toLong() +private fun String.checkMaxDigits(startIndex: Int, endIndex: Int, maxDigits: Int) { + if (startIndex >= endIndex || endIndex - startIndex > maxDigits) { + throwInvalidNumberOfDigits(startIndex, endIndex, maxDigits, requireMaxLength = false) + } +} + +private fun String.parseInt(startIndex: Int, endIndex: Int): Int { + var result = 0 + for (i in startIndex until endIndex) { + result = (result shl 4) or decimalFromHexDigitAt(i) } return result } -private fun String.checkContainsAt(part: String, index: Int, endIndex: Int, partName: String): Int { - val end = index + part.length - if (end > endIndex || !regionMatches(index, part, 0, part.length, ignoreCase = true)) { - throw NumberFormatException( - "Expected $partName \"$part\" at index $index, but was ${this.substring(index, end.coerceAtMost(endIndex))}" - ) +private fun String.parseLong(startIndex: Int, endIndex: Int): Long { + var result = 0L + for (i in startIndex until endIndex) { + result = (result shl 4) or longDecimalFromHexDigitAt(i) } - return end + return result } -private fun String.checkHexLength(startIndex: Int, endIndex: Int, maxDigits: Int, requireMaxLength: Boolean) { - val digitsLength = endIndex - startIndex - val isCorrectLength = if (requireMaxLength) digitsLength == maxDigits else digitsLength <= maxDigits - if (!isCorrectLength) { - val specifier = if (requireMaxLength) "exactly" else "at most" - val substring = substring(startIndex, endIndex) - throw NumberFormatException( - "Expected $specifier $maxDigits hexadecimal digits at index $startIndex, but was $substring of length $digitsLength" - ) +@Suppress("NOTHING_TO_INLINE") +private inline fun String.checkContainsAt(index: Int, endIndex: Int, part: String, ignoreCase: Boolean, partName: String): Int { + if (part.isEmpty()) return index + for (i in part.indices) { + if (!part[i].equals(this[index + i], ignoreCase)) { + throwNotContainedAt(index, endIndex, part, partName) + } } + return index + part.length } -private fun String.decimalFromHexDigitAt(index: Int): Int { +@Suppress("NOTHING_TO_INLINE") +private inline fun String.decimalFromHexDigitAt(index: Int): Int { val code = this[index].code - if (code > 127 || HEX_DIGITS_TO_DECIMAL[code] < 0) { - throw NumberFormatException("Expected a hexadecimal digit at index $index, but was ${this[index]}") + if (code ushr 8 == 0 && HEX_DIGITS_TO_DECIMAL[code] >= 0) { + return HEX_DIGITS_TO_DECIMAL[code] } - return HEX_DIGITS_TO_DECIMAL[code] + throwInvalidDigitAt(index) +} + +@Suppress("NOTHING_TO_INLINE") +private inline fun String.longDecimalFromHexDigitAt(index: Int): Long { + val code = this[index].code + if (code ushr 8 == 0 && HEX_DIGITS_TO_LONG_DECIMAL[code] >= 0) { + return HEX_DIGITS_TO_LONG_DECIMAL[code] + } + throwInvalidDigitAt(index) +} + +private fun String.throwInvalidNumberOfDigits(startIndex: Int, endIndex: Int, maxDigits: Int, requireMaxLength: Boolean) { + val specifier = if (requireMaxLength) "exactly" else "at most" + val substring = substring(startIndex, endIndex) + throw NumberFormatException( + "Expected $specifier $maxDigits hexadecimal digits at index $startIndex, but was $substring of length ${endIndex - startIndex}" + ) +} + +private fun String.throwNotContainedAt(index: Int, endIndex: Int, part: String, partName: String) { + val substring = substring(index, (index + part.length).coerceAtMost(endIndex)) + throw NumberFormatException( + "Expected $partName \"$part\" at index $index, but was $substring" + ) +} + +private fun String.throwInvalidPrefixSuffix(startIndex: Int, endIndex: Int, prefix: String, suffix: String) { + val substring = substring(startIndex, endIndex) + throw NumberFormatException( + "Expected a hexadecimal number with prefix \"$prefix\" and suffix \"$suffix\", but was $substring" + ) +} + +private fun String.throwInvalidDigitAt(index: Int): Nothing { + throw NumberFormatException("Expected a hexadecimal digit at index $index, but was ${this[index]}") } diff --git a/libraries/stdlib/src/kotlin/text/HexFormat.kt b/libraries/stdlib/src/kotlin/text/HexFormat.kt index 62616a58f20..e12a0fdb6cc 100644 --- a/libraries/stdlib/src/kotlin/text/HexFormat.kt +++ b/libraries/stdlib/src/kotlin/text/HexFormat.kt @@ -79,6 +79,22 @@ public class HexFormat internal constructor( val byteSuffix: String ) { + internal val noLineAndGroupSeparator: Boolean = + bytesPerLine == Int.MAX_VALUE && bytesPerGroup == Int.MAX_VALUE + + internal val shortByteSeparatorNoPrefixAndSuffix: Boolean = + bytePrefix.isEmpty() && byteSuffix.isEmpty() && byteSeparator.length <= 1 + + /** + * Whether to ignore case when parsing format strings. + * If false, case-sensitive parsing is conducted, which is faster. + */ + internal val ignoreCase: Boolean = + groupSeparator.isCaseSensitive() || + byteSeparator.isCaseSensitive() || + bytePrefix.isCaseSensitive() || + byteSuffix.isCaseSensitive() + override fun toString(): String = buildString { append("BytesHexFormat(").appendLine() appendOptionsTo(this, indent = " ").appendLine() @@ -227,6 +243,14 @@ public class HexFormat internal constructor( val removeLeadingZeros: Boolean ) { + internal val isDigitsOnly: Boolean = prefix.isEmpty() && suffix.isEmpty() + + /** + * Whether to ignore case when parsing format strings. + * If false, case-sensitive parsing is conducted, which is faster. + */ + internal val ignoreCase: Boolean = prefix.isCaseSensitive() || suffix.isCaseSensitive() + override fun toString(): String = buildString { append("NumberHexFormat(").appendLine() appendOptionsTo(this, indent = " ").appendLine() @@ -409,3 +433,9 @@ public class HexFormat internal constructor( public inline fun HexFormat(builderAction: HexFormat.Builder.() -> Unit): HexFormat { return HexFormat.Builder().apply(builderAction).build() } + +// --- private functions --- + +private fun String.isCaseSensitive(): Boolean { + return this.any { it >= '\u0080' || it.isLetter() } +} \ No newline at end of file diff --git a/libraries/stdlib/test/text/BytesHexFormatTest.kt b/libraries/stdlib/test/text/BytesHexFormatTest.kt index 185df7204ff..95f9ea20964 100644 --- a/libraries/stdlib/test/text/BytesHexFormatTest.kt +++ b/libraries/stdlib/test/text/BytesHexFormatTest.kt @@ -21,6 +21,28 @@ class BytesHexFormatTest { assertContentEquals(bytes.asUByteArray(), expected.hexToUByteArray(format)) } + private fun testFormatAndParse(bytes: ByteArray, startIndex: Int, endIndex: Int, expected: String, format: HexFormat) { + val subArray = bytes.copyOfRange(startIndex, endIndex) + assertEquals(expected, bytes.toHexString(startIndex, endIndex, format)) + assertContentEquals(subArray, expected.hexToByteArray(format)) + + assertEquals(expected, bytes.asUByteArray().toHexString(startIndex, endIndex, format)) + assertContentEquals(subArray.asUByteArray(), expected.hexToUByteArray(format)) + } + + @Test + fun emptyByteArray() { + testFormatAndParse(byteArrayOf(), "", HexFormat.Default) + testFormatAndParse(fourBytes, 2, 2, "", HexFormat.Default) + } + + @Test + fun invalidRange() { + assertFailsWith { fourBytes.toHexString(startIndex = -1) } + assertFailsWith { fourBytes.toHexString(endIndex = 5) } + assertFailsWith { fourBytes.toHexString(startIndex = 2, endIndex = 1) } + } + @Test fun ignoreNumberFormat() { val format = HexFormat { @@ -38,34 +60,55 @@ class BytesHexFormatTest { fun upperCase() { testFormatAndParse(fourBytes, "0A0B0C0D", HexFormat { upperCase = true }) testFormatAndParse(fourBytes, "0A0B0C0D", HexFormat.UpperCase) + testFormatAndParse(fourBytes, 0, 3, "0A0B0C", HexFormat.UpperCase) } @Test fun lowerCase() { testFormatAndParse(fourBytes, "0a0b0c0d", HexFormat { upperCase = false }) + testFormatAndParse(fourBytes, 1, 4, "0b0c0d", HexFormat { upperCase = false }) } @Test fun defaultCase() { testFormatAndParse(fourBytes, "0a0b0c0d", HexFormat.Default) + testFormatAndParse(fourBytes, 1, 3, "0b0c", HexFormat.Default) } @Test fun byteSeparatorPrefixSuffix() { // byteSeparator - testFormatAndParse(fourBytes, "0a 0b 0c 0d", HexFormat { bytes.byteSeparator = " " }) + HexFormat { bytes.byteSeparator = " " }.let { format -> + testFormatAndParse(fourBytes, "0a 0b 0c 0d", format) + testFormatAndParse(fourBytes, 0, 1, "0a", format) + assertFailsWith { "0a-0b 0c 0d".hexToByteArray(format) } + assertFailsWith { "0a0b 0c 0d".hexToByteArray(format) } + } // bytePrefix - testFormatAndParse(fourBytes, "0x0a0x0b0x0c0x0d", HexFormat { bytes.bytePrefix = "0x" }) - // bytePrefix - testFormatAndParse(fourBytes, "0a;0b;0c;0d;", HexFormat { bytes.byteSuffix = ";" }) + HexFormat { bytes.bytePrefix = "0x" }.let { format -> + testFormatAndParse(fourBytes, "0x0a0x0b0x0c0x0d", format) + testFormatAndParse(fourBytes, 2, 4, "0x0c0x0d", HexFormat { bytes.bytePrefix = "0x" }) + assertFailsWith { "0x0a0x0b0x0c0#0d".hexToByteArray(format) } + assertFailsWith { "0x0a0x0b0x0c0d".hexToByteArray(format) } + } + // byteSuffix + HexFormat { bytes.byteSuffix = ";" }.let { format -> + testFormatAndParse(fourBytes, "0a;0b;0c;0d;", format) + testFormatAndParse(fourBytes, 0, 4, "0a;0b;0c;0d;", format) + assertFailsWith { "0a;0b:0c;0d;".hexToByteArray(format) } + assertFailsWith { "0a;0b0c;0d;".hexToByteArray(format) } + } // all together - testFormatAndParse(fourBytes, "0x0a; 0x0b; 0x0c; 0x0d;", HexFormat { + HexFormat { bytes { byteSeparator = " " bytePrefix = "0x" byteSuffix = ";" } - }) + }.let { format -> + testFormatAndParse(fourBytes, "0x0a; 0x0b; 0x0c; 0x0d;", format) + testFormatAndParse(fourBytes, 1, 3, "0x0b; 0x0c;", format) + } } @Test @@ -75,6 +118,8 @@ class BytesHexFormatTest { val format = HexFormat { bytes.bytesPerLine = 8 } val expected = "0001020304050607\n08090a0b0c0d0e0f\n10111213" testFormatAndParse(twentyBytes, expected, format) + val missingNewLine = "0001020304050607\n08090a0b0c0d0e0f10111213" + assertFailsWith { missingNewLine.hexToByteArray(format) } } // The last line is not ended by the line separator @@ -116,7 +161,9 @@ class BytesHexFormatTest { @Test fun bytesPerLineAndBytesPerGroup() { - val format = HexFormat { + val byteArray = ByteArray(31) { it.toByte() } + + HexFormat { upperCase = true bytes { bytesPerLine = 10 @@ -126,18 +173,22 @@ class BytesHexFormatTest { bytePrefix = "#" byteSuffix = ";" } + }.let { format -> + val expected = """ + #00; #01; #02; #03;---#04; #05; #06; #07;---#08; #09; + #0A; #0B; #0C; #0D;---#0E; #0F; #10; #11;---#12; #13; + #14; #15; #16; #17;---#18; #19; #1A; #1B;---#1C; #1D; + #1E; + """.trimIndent() + testFormatAndParse(byteArray, expected, format) + + val expectedRange = """ + #05; #06; #07; #08;---#09; #0A; #0B; #0C;---#0D; #0E; + #0F; #10; #11; #12;---#13; #14; #15; + """.trimIndent() + + testFormatAndParse(byteArray, 5, 22, expectedRange, format) } - - val byteArray = ByteArray(31) { it.toByte() } - - val expected = """ - #00; #01; #02; #03;---#04; #05; #06; #07;---#08; #09; - #0A; #0B; #0C; #0D;---#0E; #0F; #10; #11;---#12; #13; - #14; #15; #16; #17;---#18; #19; #1A; #1B;---#1C; #1D; - #1E; - """.trimIndent() - - testFormatAndParse(byteArray, expected, format) } @Test @@ -396,7 +447,7 @@ class BytesHexFormatTest { run { // 00010203\n04050607\n08090a0b\n0c0d0e0f\n10111213 val length = formattedStringLength( - totalBytes = 20, + numberOfBytes = 20, bytesPerLine = 4, bytesPerGroup = Int.MAX_VALUE, groupSeparatorLength = 2, @@ -409,7 +460,7 @@ class BytesHexFormatTest { run { // 0001020304050607---08090a0b0c0d0e0f---10111213 val length = formattedStringLength( - totalBytes = 20, + numberOfBytes = 20, bytesPerLine = Int.MAX_VALUE, bytesPerGroup = 8, groupSeparatorLength = 3, @@ -425,7 +476,7 @@ class BytesHexFormatTest { // #14; #15; #16; #17;---#18; #19; #1A; #1B;---#1C; #1D; // #1E; val length = formattedStringLength( - totalBytes = 31, + numberOfBytes = 31, bytesPerLine = 10, bytesPerGroup = 4, groupSeparatorLength = 3, @@ -437,7 +488,7 @@ class BytesHexFormatTest { } run { val length = formattedStringLength( - totalBytes = Int.MAX_VALUE / 2, + numberOfBytes = Int.MAX_VALUE / 2, bytesPerLine = Int.MAX_VALUE, bytesPerGroup = Int.MAX_VALUE, groupSeparatorLength = 0, @@ -449,7 +500,7 @@ class BytesHexFormatTest { } assertFailsWith { formattedStringLength( - totalBytes = Int.MAX_VALUE, + numberOfBytes = Int.MAX_VALUE, bytesPerLine = Int.MAX_VALUE, bytesPerGroup = Int.MAX_VALUE, groupSeparatorLength = Int.MAX_VALUE, @@ -460,7 +511,7 @@ class BytesHexFormatTest { } assertFailsWith { formattedStringLength( - totalBytes = Int.MAX_VALUE / 2 + 1, + numberOfBytes = Int.MAX_VALUE / 2 + 1, bytesPerLine = Int.MAX_VALUE, bytesPerGroup = Int.MAX_VALUE, groupSeparatorLength = 0, diff --git a/libraries/stdlib/test/text/NumberHexFormatTest.kt b/libraries/stdlib/test/text/NumberHexFormatTest.kt index a3445732014..b34da580321 100644 --- a/libraries/stdlib/test/text/NumberHexFormatTest.kt +++ b/libraries/stdlib/test/text/NumberHexFormatTest.kt @@ -16,35 +16,35 @@ class NumberHexFormatTest { private const val longHex3A: String = "000000000000003A" // 16 hex digits } - private fun testFormatAndParse(number: Long, string: String, format: HexFormat) { - testFormat(number, string, format) - testParse(string, number, format) + private fun testFormatAndParse(number: Long, digits: String, format: HexFormat) { + testFormat(number, digits, format) + testParse(digits, number, format) } - private fun testFormat(number: Long, string: String, format: HexFormat) { - require(format.number.prefix.isEmpty() && format.number.suffix.isEmpty()) + private fun testFormat(number: Long, digits: String, format: HexFormat) { + fun String.withPrefixAndSuffix(): String = format.number.prefix + this + format.number.suffix - assertEquals(string.takeLast(2), number.toByte().toHexString(format)) - assertEquals(string.takeLast(2), number.toUByte().toHexString(format)) - assertEquals(string.takeLast(4), number.toShort().toHexString(format)) - assertEquals(string.takeLast(4), number.toUShort().toHexString(format)) - assertEquals(string.takeLast(8), number.toInt().toHexString(format)) - assertEquals(string.takeLast(8), number.toUInt().toHexString(format)) - assertEquals(string, number.toHexString(format)) - assertEquals(string, number.toULong().toHexString(format)) + assertEquals(digits.takeLast(2).withPrefixAndSuffix(), number.toByte().toHexString(format)) + assertEquals(digits.takeLast(2).withPrefixAndSuffix(), number.toUByte().toHexString(format)) + assertEquals(digits.takeLast(4).withPrefixAndSuffix(), number.toShort().toHexString(format)) + assertEquals(digits.takeLast(4).withPrefixAndSuffix(), number.toUShort().toHexString(format)) + assertEquals(digits.takeLast(8).withPrefixAndSuffix(), number.toInt().toHexString(format)) + assertEquals(digits.takeLast(8).withPrefixAndSuffix(), number.toUInt().toHexString(format)) + assertEquals(digits.withPrefixAndSuffix(), number.toHexString(format)) + assertEquals(digits.withPrefixAndSuffix(), number.toULong().toHexString(format)) } - private fun testParse(string: String, number: Long, format: HexFormat) { - require(format.number.prefix.isEmpty() && format.number.suffix.isEmpty()) + private fun testParse(digits: String, number: Long, format: HexFormat) { + fun String.withPrefixAndSuffix(): String = format.number.prefix + this + format.number.suffix - assertEquals(number.toByte(), string.takeLast(2).hexToByte(format)) - assertEquals(number.toUByte(), string.takeLast(2).hexToUByte(format)) - assertEquals(number.toShort(), string.takeLast(4).hexToShort(format)) - assertEquals(number.toUShort(), string.takeLast(4).hexToUShort(format)) - assertEquals(number.toInt(), string.takeLast(8).hexToInt(format)) - assertEquals(number.toUInt(), string.takeLast(8).hexToUInt(format)) - assertEquals(number, string.hexToLong(format)) - assertEquals(number.toULong(), string.hexToULong(format)) + assertEquals(number.toByte(), digits.takeLast(2).withPrefixAndSuffix().hexToByte(format)) + assertEquals(number.toUByte(), digits.takeLast(2).withPrefixAndSuffix().hexToUByte(format)) + assertEquals(number.toShort(), digits.takeLast(4).withPrefixAndSuffix().hexToShort(format)) + assertEquals(number.toUShort(), digits.takeLast(4).withPrefixAndSuffix().hexToUShort(format)) + assertEquals(number.toInt(), digits.takeLast(8).withPrefixAndSuffix().hexToInt(format)) + assertEquals(number.toUInt(), digits.takeLast(8).withPrefixAndSuffix().hexToUInt(format)) + assertEquals(number, digits.withPrefixAndSuffix().hexToLong(format)) + assertEquals(number.toULong(), digits.withPrefixAndSuffix().hexToULong(format)) } @Test @@ -92,10 +92,10 @@ class NumberHexFormatTest { } @Test - fun formatPrefixSuffix() { - assertEquals("0x0000003a", 58.toHexString(HexFormat { number.prefix = "0x" })) - assertEquals("0000003ah", 58.toHexString(HexFormat { number.suffix = "h" })) - assertEquals("0x0000003ah", 58.toHexString(HexFormat { number.prefix = "0x"; number.suffix = "h" })) + fun formatAndParsePrefixSuffix() { + testFormatAndParse(58, "000000000000003a", HexFormat { number.prefix = "0x" }) + testFormatAndParse(58, "000000000000003a", HexFormat { number.suffix = "h" }) + testFormatAndParse(58, "000000000000003a", HexFormat { number.prefix = "0x"; number.suffix = "h" }) } @Test @@ -130,6 +130,14 @@ class NumberHexFormatTest { } } + @Test + fun parseInvalidDigit() { + assertFailsWith { "3g".hexToByte() } + assertFailsWith { "3g".hexToShort() } + assertFailsWith { "3g".hexToInt() } + assertFailsWith { "3g".hexToLong() } + } + @Test fun parseRequiresAtLeastOneHexDigit() { assertFailsWith {