diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e1e6976..c6eaf67 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -kapacity = "0.9.9-beta07" +kapacity = "0.9.9-beta08" agp = "8.11.2" android-compileSdk = "36" android-minSdk = "24" diff --git a/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/ByteBufferKapacityTest.kt b/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/ByteBufferKapacityTest.kt new file mode 100644 index 0000000..defae33 --- /dev/null +++ b/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/ByteBufferKapacityTest.kt @@ -0,0 +1,136 @@ +package io.github.developrofthings.kapacity.io + +import io.github.developrofthings.kapacity.byte +import java.nio.ByteBuffer +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class ByteBufferKapacityTest { + + //region ByteBuffer.get() Tests + @Test + fun testGet_exactFit() { + val buffer = ByteBuffer.wrap(byteArrayOf(1, 2, 3, 4, 5)) + val dest = ByteArray(size = 5) + + buffer.get(destination = dest, kapacity = 5.byte) + + assertContentEquals(byteArrayOf(1, 2, 3, 4, 5), dest) + assertEquals(0, buffer.remaining()) // Buffer should be fully read + } + + @Test + fun testGet_limitedByKapacity() { + val buffer = ByteBuffer.wrap(byteArrayOf(1, 2, 3, 4, 5)) + val dest = ByteArray(size = 5) + + // Buffer has 5, array holds 5, but we only ask for 3 + buffer.get(destination = dest, kapacity = 3.byte) + + // Only the first 3 slots should be filled + assertContentEquals(byteArrayOf(1, 2, 3, 0, 0), dest) + assertEquals(2, buffer.remaining()) // 2 bytes left in buffer + } + + @Test + fun testGet_limitedByArraySpace() { + val buffer = ByteBuffer.wrap(byteArrayOf(1, 2, 3, 4, 5)) + val dest = ByteArray(size = 2) + + // Ask for 5, but array only holds 2 + buffer.get(destination = dest, kapacity = 5.byte) + + assertContentEquals(byteArrayOf(1, 2), dest) + assertEquals(3, buffer.remaining()) // 3 bytes left in buffer + } + + @Test + fun testGet_limitedByBufferSpace() { + val buffer = ByteBuffer.wrap(byteArrayOf(1, 2)) + val dest = ByteArray(size = 5) + + // Ask for 5, array holds 5, but buffer only has 2 + buffer.get(destination = dest, kapacity = 5.byte) + + assertContentEquals(byteArrayOf(1, 2, 0, 0, 0), dest) + assertEquals(0, buffer.remaining()) + } + + @Test + fun testGet_withOffset() { + val buffer = ByteBuffer.wrap(byteArrayOf(9, 8, 7)) + val dest = ByteArray(size = 5) + + // Write starting at index 2 + buffer.get(destination = dest, destinationOffset = 2, kapacity = 10.byte) + + assertContentEquals(byteArrayOf(0, 0, 9, 8, 7), dest) + } + //endregion + + //region ByteBuffer.put() Tests + @Test + fun testPut_exactFit() { + val buffer = ByteBuffer.allocate(5) + val source = byteArrayOf(1, 2, 3, 4, 5) + + buffer.put(source = source, kapacity = 5.byte) + + buffer.flip() // Prepare buffer for reading to assert contents + val result = ByteArray(5) + buffer.get(result) + assertContentEquals(byteArrayOf(1, 2, 3, 4, 5), result) + } + + @Test + fun testPut_limitedByKapacity() { + val buffer = ByteBuffer.allocate(5) + val source = byteArrayOf(1, 2, 3, 4, 5) + + // We have 5, buffer holds 5, but we only want to write 2 + buffer.put(source = source, kapacity = 2.byte) + + assertEquals(2, buffer.position()) // Position should advance by 2 + } + + @Test + fun testPut_limitedByBufferSpace() { + val buffer = ByteBuffer.allocate(2) + val source = byteArrayOf(1, 2, 3, 4, 5) + + // Try to write 5, but buffer only holds 2 + buffer.put(source = source, kapacity = 5.byte) + + assertEquals(2, buffer.position()) // Should safely stop at buffer capacity + assertEquals(0, buffer.remaining()) + } + + @Test + fun testPut_limitedByArraySpace() { + val buffer = ByteBuffer.allocate(5) + val source = byteArrayOf(1, 2) + + // Try to write 5, but array only has 2 + buffer.put(source = source, kapacity = 5.byte) + + assertEquals(2, buffer.position()) // Should safely stop after reading the 2 available bytes + } + + @Test + fun testPut_withOffset() { + val buffer = ByteBuffer.allocate(5) + val source = byteArrayOf(0, 0, 9, 8, 7) // We want to skip the first two zeros + + // Read from source starting at index 2 + buffer.put(source = source, sourceOffset = 2, kapacity = 10.byte) + + assertEquals(3, buffer.position()) // Should write exactly 3 bytes + + buffer.flip() + val result = ByteArray(3) + buffer.get(result) + assertContentEquals(byteArrayOf(9, 8, 7), result) + } + //endregion +} \ No newline at end of file diff --git a/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/KapacityByteBufferAllocationTest.kt b/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/KapacityByteBufferAllocationTest.kt new file mode 100644 index 0000000..db2e6a4 --- /dev/null +++ b/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/KapacityByteBufferAllocationTest.kt @@ -0,0 +1,56 @@ +@file:OptIn(InternalKapacityApi::class) + +package io.github.developrofthings.kapacity.io + +import io.github.developrofthings.kapacity.InternalKapacityApi +import io.github.developrofthings.kapacity.Kapacity +import io.github.developrofthings.kapacity.gigabyte +import io.github.developrofthings.kapacity.megabyte +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class KapacityByteBufferAllocationTest { + + // --- 1. TEST THE MATH (Zero Allocation) --- + @Test + fun testCoercion_safelyClampsMassiveCapacities() { + val massiveKapacity = 3.gigabyte + + // Test the internal property directly to prove the math works + // without ever touching the JVM's memory allocator. + assertEquals((Int.MAX_VALUE - 8), massiveKapacity.rawBytesCoercedToIntRange) + } + + @Test + fun testCoercion_handlesNegativeOverflowGracefully() { + // If someone bypasses builders and forces a negative value + val negativeKapacity = Kapacity.fromBytes(-5000L) + + // Prove it clamps to 0 to prevent NegativeArraySizeException + assertEquals(0, negativeKapacity.rawBytesCoercedToIntRange) + } + + // --- 2. TEST THE ALLOCATOR (Safe Memory Bounds) --- + @Test + fun testAllocateByteBuffer_wiresCorrectly() { + // Use a safe, reasonable size (e.g., 10 Megabytes) that will + // comfortably allocate on any CI runner or Android emulator. + val safeKapacity = 10.megabyte + + val buffer = safeKapacity.allocateByteBuffer() + + assertEquals(safeKapacity.rawBytes, buffer.capacity().toLong()) + assertTrue(buffer.hasArray()) + } + + @Test + fun testDirectAllocateByteBuffer_wiresCorrectly() { + val safeKapacity = 10.megabyte + + val buffer = safeKapacity.directAllocateByteBuffer() + + assertEquals(safeKapacity.rawBytes, buffer.capacity().toLong()) + assertTrue(buffer.isDirect) + } +} \ No newline at end of file diff --git a/kapacity-io/src/androidMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.android.kt b/kapacity-io/src/androidMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.android.kt index 77d3959..7572246 100644 --- a/kapacity-io/src/androidMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.android.kt +++ b/kapacity-io/src/androidMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.android.kt @@ -1,5 +1,9 @@ +@file:OptIn(InternalKapacityApi::class) +@file:Suppress("unused") + package io.github.developrofthings.kapacity.io +import io.github.developrofthings.kapacity.InternalKapacityApi import io.github.developrofthings.kapacity.Kapacity import io.github.developrofthings.kapacity.byte import java.io.File @@ -25,4 +29,98 @@ val Path.kapacity: Kapacity get() = this.fileSize().byte /** * Returns the [Kapacity] of this [ByteBuffer], calculated directly from its length on the disk. */ -val ByteBuffer.kapacity: Kapacity get() = this.capacity().byte \ No newline at end of file +val ByteBuffer.kapacity: Kapacity get() = this.capacity().byte + +/** + * Allocates a new standard (heap-backed) [ByteBuffer] with a capacity equal to this [Kapacity]. + * + * This buffer is allocated on the JVM heap and will be managed by the standard garbage collector. + * It is backed by a standard `ByteArray`, which can be accessed via `buffer.array()`. + * + * **Warning on Bounds:** Because [ByteBuffer] capacities are strictly indexed by [Int], the maximum + * allowed size is [Int.MAX_VALUE]` - 8` (approximately 2.14 GB). If this capacity exceeds that limit, + * the resulting buffer size will be safely truncated to [Int.MAX_VALUE] to prevent memory allocation crashes. + * + * @return A new, empty [ByteBuffer] ready for writing. + */ +fun Kapacity.allocateByteBuffer(): ByteBuffer = + ByteBuffer.allocate(/*capacity = */this.rawBytesCoercedToIntRange) + +/** + * Allocates a new direct (off-heap) [ByteBuffer] with a capacity equal to this [Kapacity]. + * + * Direct buffers allocate memory outside of the standard JVM heap, bypassing standard garbage collection. + * They are highly optimized for native I/O operations (like socket or file channel transfers) and JNI + * interactions, as they avoid copying data between the JVM heap and native memory. + * + * **Note:** Allocating and deallocating direct buffers is generally more expensive than standard buffers, + * so they are best suited for large, long-lived buffers. + * + * **Warning on Bounds:** Like standard buffers, the maximum allowed size is safely truncated to + * [Int.MAX_VALUE]` - 8` (approximately 2.14 GB) to prevent memory allocation crashes. + * + * @return A new, empty direct [ByteBuffer] ready for writing. + */ +fun Kapacity.directAllocateByteBuffer(): ByteBuffer = + ByteBuffer.allocateDirect(/*capacity = */this.rawBytesCoercedToIntRange) + +/** + * Reads up to the specified [kapacity] of bytes from this buffer into the [destination] array. + * + * **Warning on Bounds:** This operation safely clamps the number of bytes read to prevent + * `BufferUnderflowException` and `IndexOutOfBoundsException`. The actual number of bytes transferred + * will be the minimum of: the requested [kapacity], the [ByteBuffer.remaining] bytes in this buffer, or the + * available space in the [destination] array accounting for the [destinationOffset]. + * + * @param destination The byte array to write the data into. + * @param destinationOffset The index within the destination array to begin writing. Defaults to 0. + * @param kapacity The maximum amount of data to read from this buffer. + * @return This buffer, to allow for fluent method chaining. + */ +fun ByteBuffer.get( + destination: ByteArray, + destinationOffset: Int = 0, + kapacity: Kapacity, +): ByteBuffer { + val length = (destination.size - destinationOffset) + val safeLength = minOf( + a = kapacity.rawBytesCoercedToIntRange, + b = minOf(a = remaining(), b = length) + ) + + return this.get( + /* dst = */ destination, + /* offset = */ destinationOffset, + /* length = */ safeLength, + ) +} + +/** + * Writes up to the specified [kapacity] of bytes from the [source] array into this buffer. + * + * **Warning on Bounds:** This operation safely clamps the number of bytes written to prevent + * `BufferOverflowException` and `IndexOutOfBoundsException`. The actual number of bytes transferred + * will be the minimum of: the requested [kapacity], the [ByteBuffer.remaining] space in this buffer, or the + * available data in the [source] array accounting for the [sourceOffset]. + * + * @param source The byte array containing the data to write. + * @param sourceOffset The index within the source array to begin reading from. Defaults to 0. + * @param kapacity The maximum amount of data to write into this buffer. + * @return This buffer, to allow for fluent method chaining. + */ +fun ByteBuffer.put( + source: ByteArray, + sourceOffset: Int = 0, + kapacity: Kapacity, +): ByteBuffer { + val length = (source.size - sourceOffset) + val safeLength = minOf( + a = kapacity.rawBytesCoercedToIntRange, + b = minOf(a = remaining(), b = length) + ) + return this.put( + /* src = */ source, + /* offset = */ sourceOffset, + /* length = */ safeLength, + ) +} \ No newline at end of file diff --git a/kapacity-io/src/commonMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.kt b/kapacity-io/src/commonMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.kt index e49846c..43fe997 100644 --- a/kapacity-io/src/commonMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.kt +++ b/kapacity-io/src/commonMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.kt @@ -1,8 +1,13 @@ +@file:Suppress("unused") + package io.github.developrofthings.kapacity.io +import io.github.developrofthings.kapacity.InternalKapacityApi import io.github.developrofthings.kapacity.Kapacity import io.github.developrofthings.kapacity.byte import kotlinx.io.Buffer +import kotlinx.io.RawSink +import kotlinx.io.RawSource import kotlinx.io.bytestring.ByteString import kotlinx.io.bytestring.ByteStringBuilder @@ -25,3 +30,131 @@ val ByteString.kapacity: Kapacity get() = this.size.byte * calculated directly from its current size. */ val ByteStringBuilder.kapacity: Kapacity get() = this.size.byte + +/** + * Removes and discards the exact number of bytes represented by the given [kapacity] from this buffer. + * + * @param kapacity The amount of data to skip. + * @throws kotlinx.io.EOFException if the buffer is exhausted before the requested capacity is skipped. + */ +fun Buffer.skip(kapacity: Kapacity) = skip(byteCount = kapacity.rawBytes) + +/** + * Removes up to the specified [kapacity] of bytes from this buffer and appends them to the [sink]. + * + * @param sink The destination buffer to write the bytes into. + * @param kapacity The maximum amount of data to read. + * @return A [Kapacity] representing the exact number of bytes that were successfully read and transferred. + */ +fun Buffer.readAtMostTo(sink: Buffer, kapacity: Kapacity): Kapacity = this.readAtMostTo( + sink = sink, + byteCount = kapacity.rawBytes, +).byte + +/** + * Removes up to the specified [kapacity] of bytes from this buffer and writes them into the provided [sink] array. + * + * **Warning on Bounds:** Because primitive arrays are strictly indexed by [Int], reading operations + * are safely bounded to [Int.MAX_VALUE]` - 8` (approximately 2.14 GB). The target end index is safely clamped + * to prevent `IndexOutOfBoundsException` if the calculated boundary exceeds the array's physical size. + * + * @param sink The destination primitive array. + * @param startIndex The index in the [sink] array to begin writing the data. Defaults to 0. + * @param kapacity The maximum amount of data to read from this buffer. + * @return A [Kapacity] representing the exact number of bytes that were successfully read into the array. + */ +@OptIn(InternalKapacityApi::class) +fun Buffer.readAtMostTo( + sink: ByteArray, + startIndex: Int = 0, + kapacity: Kapacity, +): Kapacity { + val targetEndIndex = startIndex + kapacity.rawBytesCoercedToIntRange + val safeEndIndex = minOf(a = targetEndIndex, b = sink.size) + + return this.readAtMostTo( + sink = sink, + startIndex = startIndex, + endIndex = safeEndIndex, + ).byte +} + +/** + * Removes exactly the specified [kapacity] of bytes from this buffer and appends them to the [sink]. + * + * @param sink The destination sink to write the bytes to. + * @param kapacity The exact amount of data to read. + * @throws kotlinx.io.EOFException if the buffer is exhausted before the requested capacity is read. + */ +fun Buffer.readTo(sink: RawSink, kapacity: Kapacity) = readTo( + sink = sink, + byteCount = kapacity.rawBytes, +) + +/** + * Appends the specified [kapacity] of bytes from the [source] array into this buffer. + * + * **Warning on Bounds:** Because primitive arrays are strictly indexed by [Int], writing operations + * are safely bounded to [Int.MAX_VALUE]` - 8` (approximately 2.14 GB). The target end index is safely clamped + * to prevent `IndexOutOfBoundsException` if the calculated boundary exceeds the array's physical size. + * + * @param source The primitive array containing the data to write. + * @param startIndex The index in the [source] array to begin reading from. Defaults to 0. + * @param kapacity The amount of data to write into this buffer. + */ +@OptIn(InternalKapacityApi::class) +fun Buffer.write( + source: ByteArray, + startIndex: Int = 0, + kapacity: Kapacity, +) { + val targetEndIndex = startIndex + kapacity.rawBytesCoercedToIntRange + val safeEndIndex = minOf(a = targetEndIndex, b = source.size) + + write( + source = source, + startIndex = startIndex, + endIndex = safeEndIndex, + ) +} + +/** + * Appends exactly the specified [kapacity] of bytes from the [source] into this buffer. + * + * @param source The source to read the bytes from. + * @param kapacity The exact amount of data to transfer into this buffer. + * @throws kotlinx.io.EOFException if the source is exhausted before the requested capacity is written. + */ +fun Buffer.write( + source: RawSource, + kapacity: Kapacity, +) = write( + source = source, + byteCount = kapacity.rawBytes, +) + +/** + * Returns a new [ByteString] containing a subset of bytes from this instance, starting at the + * [startIndex] and capturing up to the specified [kapacity]. + * + * **Warning on Bounds:** Because [ByteString] relies on `Int` indexing, extraction is safely + * bounded to [Int.MAX_VALUE]` - 8` (approximately 2.14 GB). The target end index is clamped to prevent + * `IndexOutOfBoundsException` if the capacity exceeds the available data. + * + * @param startIndex The index to begin the extraction. Defaults to 0. + * @param kapacity The total amount of data to capture in the new [ByteString]. + * @return A new [ByteString] containing the requested chunk of data. + */ +@OptIn(InternalKapacityApi::class) +fun ByteString.substring( + startIndex: Int = 0, + kapacity: Kapacity, +): ByteString { + val targetEndIndex = startIndex + kapacity.rawBytesCoercedToIntRange + val safeEndIndex = minOf(a = targetEndIndex, b = this.size) + + return this.substring( + startIndex = startIndex, + endIndex = safeEndIndex, + ) +} \ No newline at end of file diff --git a/kapacity-io/src/commonTest/kotlin/io/github/developrofthings/kapacity/io/KapacityTest.kt b/kapacity-io/src/commonTest/kotlin/io/github/developrofthings/kapacity/io/KapacityTest.kt index 26ddd23..fea591a 100644 --- a/kapacity-io/src/commonTest/kotlin/io/github/developrofthings/kapacity/io/KapacityTest.kt +++ b/kapacity-io/src/commonTest/kotlin/io/github/developrofthings/kapacity/io/KapacityTest.kt @@ -4,7 +4,9 @@ import io.github.developrofthings.kapacity.byte import kotlinx.io.Buffer import kotlinx.io.bytestring.ByteString import kotlinx.io.bytestring.ByteStringBuilder +import kotlinx.io.readByteArray import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals class KapacityTest { @@ -37,4 +39,143 @@ class KapacityTest { builder.append(byteArrayOf(1, 2, 3, 4)) assertEquals(4.byte, builder.kapacity) } + + //region Buffer.skip() Tests + @Test + fun testBufferSkip() { + val buffer = Buffer().apply { write(byteArrayOf(1, 2, 3, 4, 5)) } + + buffer.skip(kapacity = 2.byte) + + assertEquals(3L, buffer.size) + assertContentEquals(byteArrayOf(3, 4, 5), buffer.readByteArray()) + } + //endregion + + //region Buffer.readAtMostTo(Buffer) Tests + @Test + fun testBufferReadAtMostTo_bufferSink() { + val source = Buffer().apply { write(byteArrayOf(1, 2, 3, 4, 5)) } + val sink = Buffer() + + val bytesRead = source.readAtMostTo(sink = sink, kapacity = 3.byte) + + assertEquals(3L, bytesRead.rawBytes) + assertEquals(2L, source.size) // 2 bytes left in source + assertEquals(3L, sink.size) // 3 bytes transferred to sink + assertContentEquals(byteArrayOf(1, 2, 3), sink.readByteArray()) + } + + @Test + fun testBufferReadAtMostTo_bufferSink_exceedsAvailable() { + val source = Buffer().apply { write(byteArrayOf(1, 2)) } + val sink = Buffer() + + // Ask for 5 bytes, but only 2 are available + val bytesRead = source.readAtMostTo(sink = sink, kapacity = 5.byte) + + assertEquals(2L, bytesRead.rawBytes) // Should only read 2 + assertEquals(0L, source.size) + assertContentEquals(byteArrayOf(1, 2), sink.readByteArray()) + } + //endregion + + //region Buffer.readAtMostTo(ByteArray) Tests + @Test + fun testBufferReadAtMostTo_byteArray_exactFit() { + val source = Buffer().apply { write(byteArrayOf(1, 2, 3, 4, 5)) } + val sink = ByteArray(size = 5) + + val bytesRead = source.readAtMostTo(sink = sink, kapacity = 5.byte) + + assertEquals(5L, bytesRead.rawBytes) + assertContentEquals(byteArrayOf(1, 2, 3, 4, 5), sink) + } + + @Test + fun testBufferReadAtMostTo_byteArray_withOffsetAndClamping() { + val source = Buffer().apply { write(byteArrayOf(9, 8, 7, 6)) } + val sink = ByteArray(size = 5) + + // Start writing at index 2. + // We ask for 10 bytes, but the array only has 3 slots left (indices 2, 3, 4). + // The safe clamping should prevent an IndexOutOfBoundsException! + val bytesRead = source.readAtMostTo( + sink = sink, + startIndex = 2, + kapacity = 10.byte + ) + + assertEquals(3L, bytesRead.rawBytes) // Only 3 slots were safely available + assertContentEquals(byteArrayOf(0, 0, 9, 8, 7), sink) + } + //endregion + + //region Buffer.write(ByteArray) Tests + @Test + fun testBufferWrite_byteArray_exactFit() { + val source = byteArrayOf(1, 2, 3, 4, 5) + val buffer = Buffer() + + buffer.write(source = source, kapacity = 3.byte) + + assertEquals(3L, buffer.size) + assertContentEquals(byteArrayOf(1, 2, 3), buffer.readByteArray()) + } + + @Test + fun testBufferWrite_byteArray_withOffsetAndClamping() { + val source = byteArrayOf(0, 0, 9, 8, 7) + val buffer = Buffer() + + // Start reading from index 2. + // Ask to write 10 bytes, but the source array only has 3 bytes left after index 2. + buffer.write( + source = source, + startIndex = 2, + kapacity = 10.byte + ) + + assertEquals(3L, buffer.size) // Only safely wrote 3 bytes + assertContentEquals(byteArrayOf(9, 8, 7), buffer.readByteArray()) + } + //endregion + + //region Buffer.write(RawSource) Tests + @Test + fun testBufferWrite_rawSource() { + val source = Buffer().apply { write(byteArrayOf(1, 2, 3, 4, 5)) } + val sink = Buffer() + + sink.write(source = source, kapacity = 3.byte) + + assertEquals(3L, sink.size) + assertEquals(2L, source.size) + assertContentEquals(byteArrayOf(1, 2, 3), sink.readByteArray()) + } + //endregion + + //region ByteString.substring() Tests + @Test + fun testByteStringSubstring_exact() { + val byteString = ByteString(1, 2, 3, 4, 5) + + val chunk = byteString.substring(startIndex = 1, kapacity = 3.byte) + + assertEquals(3, chunk.size) + assertContentEquals(byteArrayOf(2, 3, 4), chunk.toByteArray()) + } + + @Test + fun testByteStringSubstring_clamping() { + val byteString = ByteString(1, 2, 3) + + // Start at index 1 (value 2). Ask for 50 bytes. + // Should safely clamp to the end of the ByteString without crashing. + val chunk = byteString.substring(startIndex = 1, kapacity = 50.byte) + + assertEquals(2, chunk.size) + assertContentEquals(byteArrayOf(2, 3), chunk.toByteArray()) + } + //endregion } diff --git a/kapacity-io/src/iosMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.ios.kt b/kapacity-io/src/iosMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.ios.kt index 446be5d..d9c79e9 100644 --- a/kapacity-io/src/iosMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.ios.kt +++ b/kapacity-io/src/iosMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.ios.kt @@ -1,13 +1,18 @@ +@file:Suppress("unused") + package io.github.developrofthings.kapacity.io import io.github.developrofthings.kapacity.Kapacity import io.github.developrofthings.kapacity.byte +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSData import platform.Foundation.NSFileManager import platform.Foundation.NSFileSize +import platform.Foundation.NSMutableData import platform.Foundation.NSNumber import platform.Foundation.NSURL +import platform.Foundation.create /** * Returns the [Kapacity] of this [NSData] buffer, calculated directly from its length in memory. @@ -36,4 +41,18 @@ val NSURL.kapacity: Kapacity // Convert to a Long and wrap it in your capacity extension return (fileSize?.longValue ?: 0L).byte - } \ No newline at end of file + } + +/** + * Allocates a new native Apple [NSMutableData] buffer initialized with zeros, + * sized exactly to this [Kapacity]. + */ +@OptIn(BetaInteropApi::class) +fun Kapacity.toNSMutableData(): NSMutableData = NSMutableData.create(length = this.rawBytes.toULong())!! + +/** + * Allocates a new native Apple [NSData] buffer initialized with zeros, + * sized exactly to this [Kapacity]. + */ +@OptIn(BetaInteropApi::class) +fun Kapacity.toNSData(): NSData = toNSMutableData() \ No newline at end of file diff --git a/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/InternalKapacityApi.kt b/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/InternalKapacityApi.kt new file mode 100644 index 0000000..fab168c --- /dev/null +++ b/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/InternalKapacityApi.kt @@ -0,0 +1,13 @@ +package io.github.developrofthings.kapacity + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This is an internal Kapacity API. It can change or be removed at any time. Do not use it in your application code." +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.PROPERTY, + AnnotationTarget.FUNCTION +) +annotation class InternalKapacityApi \ No newline at end of file diff --git a/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/Kapacity.kt b/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/Kapacity.kt index 86e6bbb..1c73957 100644 --- a/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/Kapacity.kt +++ b/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/Kapacity.kt @@ -1,5 +1,5 @@ @file:Suppress("unused") -@file:OptIn(ExperimentalUnsignedTypes::class) +@file:OptIn(ExperimentalUnsignedTypes::class, InternalKapacityApi::class) package io.github.developrofthings.kapacity @@ -12,6 +12,22 @@ internal expect fun formatSize(size: Double): String @JvmInline value class Kapacity private constructor(val rawBytes: Long) : Comparable { + /** + * Coerces this capacity's exact byte count into a standard 32-bit [Int] suitable for memory + * allocation. + * + * **VM Array Limit:** This safely clamps to `Int.MAX_VALUE - 8` rather than the absolute `Int.MAX_VALUE`. + * This accounts for internal JVM object header overheads and Dalvik memory alignment padding, + * completely preventing internal VM `NegativeArraySizeException` or `OutOfMemoryError` crashes. + */ + @InternalKapacityApi + val rawBytesCoercedToIntRange: Int + // Maximum size for an array is limited to the max value of `Int` (≈ 2.147 Gigabytes) + get() = this.rawBytes.coerceIn( + minimumValue = 0, + maximumValue = (Int.MAX_VALUE - 8).toLong() + ).toInt() + private fun determineKapacityUnit(useMetric: Boolean): KapacityUnit = KapacityUnit .entries .reversed() @@ -209,7 +225,11 @@ value class Kapacity private constructor(val rawBytes: Long) : Comparable.kapacity: Kapacity get() = this.size.byte //endregion -private val Kapacity.rawBytesCoercedToIntRange: Int - // Maximum size for an array is limited to the max value of `Int` (≈ 2.147 Gigabytes) - get() = this.rawBytes.coerceIn(minimumValue = 0, maximumValue = Int.MAX_VALUE.toLong()).toInt() - /** * Allocates a new boxed [Array] of [Byte] with a size equal to this capacity. * * **Warning on Truncation:** Because Kotlin arrays are strictly indexed by [Int], the maximum - * allowed size is [Int.MAX_VALUE] (approximately 2.14 GB). If this capacity exceeds + * allowed size is [Int.MAX_VALUE]` - 8` (approximately 2.14 GB). If this capacity exceeds * that limit, the resulting array size will be silently truncated to [Int.MAX_VALUE]. * * @param init A function used to compute the initial value of each array element based on its index. @@ -1104,7 +1120,7 @@ fun Kapacity.toArray(init: (Int) -> Byte = { 0 }): Array = * Allocates a new boxed [Array] of [UByte] with a size equal to this capacity. * * **Warning on Truncation:** Because Kotlin arrays are strictly indexed by [Int], the maximum - * allowed size is [Int.MAX_VALUE] (approximately 2.14 GB). If this capacity exceeds + * allowed size is [Int.MAX_VALUE]` - 8` (approximately 2.14 GB). If this capacity exceeds * that limit, the resulting array size will be silently truncated to [Int.MAX_VALUE]. * * @param init A function used to compute the initial value of each array element based on its index. @@ -1113,20 +1129,6 @@ fun Kapacity.toArray(init: (Int) -> Byte = { 0 }): Array = fun Kapacity.toArrayUnsigned(init: (Int) -> UByte = { 0U }): Array = Array(size = this.rawBytesCoercedToIntRange, init = init) -/** - * Allocates a new primitive [ByteArray] with a size equal to this capacity, using the - * provided [init] function to populate the elements. - * - * **Warning on Truncation:** Because Kotlin arrays are strictly indexed by [Int], the maximum - * allowed size is [Int.MAX_VALUE] (approximately 2.14 GB). If this capacity exceeds - * that limit, the resulting array size will be silently truncated to [Int.MAX_VALUE]. - * - * @param init A function used to compute the initial value of each array element based on its index. - * @return A new primitive [ByteArray]. - */ -fun Kapacity.toByteArray( - init: (Int) -> Byte = { 0 }, -): ByteArray = ByteArray(size = this.rawBytesCoercedToIntRange, init = init) /** * Allocates a new primitive [ByteArray] with a size equal to this capacity. @@ -1134,7 +1136,7 @@ fun Kapacity.toByteArray( * allocation where all elements are instantly initialized to `0`. * * **Warning on Truncation:** Because Kotlin arrays are strictly indexed by [Int], the maximum - * allowed size is [Int.MAX_VALUE] (approximately 2.14 GB). If this capacity exceeds + * allowed size is [Int.MAX_VALUE]` - 8` (approximately 2.14 GB). If this capacity exceeds * that limit, the resulting array size will be silently truncated to [Int.MAX_VALUE]. * * @return A new primitive [ByteArray] filled with zeros. @@ -1147,9 +1149,167 @@ fun Kapacity.toByteArray(): ByteArray = ByteArray(size = this.rawBytesCoercedToI * allocation where all elements are instantly initialized to `0`. * * **Warning on Truncation:** Because Kotlin arrays are strictly indexed by [Int], the maximum - * allowed size is [Int.MAX_VALUE] (approximately 2.14 GB). If this capacity exceeds + * allowed size is [Int.MAX_VALUE]` - 8` (approximately 2.14 GB). If this capacity exceeds * that limit, the resulting array size will be silently truncated to [Int.MAX_VALUE]. * * @return A new primitive [UByteArray] filled with zeros. */ -fun Kapacity.toUByteArray(): UByteArray = UByteArray(size = this.rawBytesCoercedToIntRange) \ No newline at end of file +fun Kapacity.toUByteArray(): UByteArray = UByteArray(size = this.rawBytesCoercedToIntRange) + +//region copyInto +/** + * Copies up to the specified [kapacity] of bytes from this array into the [destination] array. + * + * **Zero Allocation & Safe Bounds:** This operation mutates the provided [destination] array + * directly without allocating new memory. It safely clamps the number of bytes copied to prevent + * `IndexOutOfBoundsException`. The actual number of bytes transferred will be the minimum of: + * the requested [kapacity], the available data in this source array after [startIndex], or the + * available space in the [destination] array after [destinationOffset]. + * + * @param destination The array to write the copied data into. + * @param destinationOffset The index in the [destination] array to begin writing at. Defaults to 0. + * @param startIndex The index in this source array to begin reading from. Defaults to 0. + * @param kapacity The maximum amount of data to copy. + * @return The original [destination] array, allowing for fluent method chaining. + */ +fun ByteArray.copyInto( + destination: ByteArray, + destinationOffset: Int = 0, + startIndex: Int = 0, + kapacity: Kapacity, +): ByteArray { + val maxDestinationSize = (destination.size - destinationOffset) + val maxSourceSize = (this.size - startIndex) + val safeLength = minOf( + a = kapacity.rawBytesCoercedToIntRange, + b = minOf(a = maxSourceSize, b = maxDestinationSize) + ) + + if (safeLength <= 0) return destination + + val safeEndOffset = startIndex + safeLength + return this@copyInto.copyInto( + destination = destination, + destinationOffset = destinationOffset, + startIndex = startIndex, + endIndex = safeEndOffset, + ) +} + +/** + * Copies up to the specified [kapacity] of bytes from this array into the [destination] array. + * + * **Zero Allocation & Safe Bounds:** This operation mutates the provided [destination] array + * directly without allocating new memory. It safely clamps the number of bytes copied to prevent + * `IndexOutOfBoundsException`. The actual number of bytes transferred will be the minimum of: + * the requested [kapacity], the available data in this source array after [startIndex], or the + * available space in the [destination] array after [destinationOffset]. + * + * @param destination The array to write the copied data into. + * @param destinationOffset The index in the [destination] array to begin writing at. Defaults to 0. + * @param startIndex The index in this source array to begin reading from. Defaults to 0. + * @param kapacity The maximum amount of data to copy. + * @return The original [destination] array, allowing for fluent method chaining. + */ +fun UByteArray.copyInto( + destination: UByteArray, + destinationOffset: Int = 0, + startIndex: Int = 0, + kapacity: Kapacity, +): UByteArray { + val maxDestinationSize = (destination.size - destinationOffset) + val maxSourceSize = (this.size - startIndex) + val safeLength = minOf( + a = kapacity.rawBytesCoercedToIntRange, + b = minOf(a = maxSourceSize, b = maxDestinationSize) + ) + + if (safeLength <= 0) return destination + + val safeEndOffset = startIndex + safeLength + return this@copyInto.copyInto( + destination = destination, + destinationOffset = destinationOffset, + startIndex = startIndex, + endIndex = safeEndOffset, + ) +} + +/** + * Copies up to the specified [kapacity] of bytes from this array into the [destination] array. + * + * **Zero Allocation & Safe Bounds:** This operation mutates the provided [destination] array + * directly without allocating new memory. It safely clamps the number of bytes copied to prevent + * `IndexOutOfBoundsException`. The actual number of bytes transferred will be the minimum of: + * the requested [kapacity], the available data in this source array after [startIndex], or the + * available space in the [destination] array after [destinationOffset]. + * + * @param destination The array to write the copied data into. + * @param destinationOffset The index in the [destination] array to begin writing at. Defaults to 0. + * @param startIndex The index in this source array to begin reading from. Defaults to 0. + * @param kapacity The maximum amount of data to copy. + * @return The original [destination] array, allowing for fluent method chaining. + */ +fun Array.copyInto( + destination: Array, + destinationOffset: Int = 0, + startIndex: Int = 0, + kapacity: Kapacity, +): Array { + val maxDestinationSize = (destination.size - destinationOffset) + val maxSourceSize = (this.size - startIndex) + val safeLength = minOf( + a = kapacity.rawBytesCoercedToIntRange, + b = minOf(a = maxSourceSize, b = maxDestinationSize) + ) + + if (safeLength <= 0) return destination + + val safeEndOffset = startIndex + safeLength + return this@copyInto.copyInto( + destination = destination, + destinationOffset = destinationOffset, + startIndex = startIndex, + endIndex = safeEndOffset, + ) +} + +/** + * Copies up to the specified [kapacity] of bytes from this array into the [destination] array. + * + * **Zero Allocation & Safe Bounds:** This operation mutates the provided [destination] array + * directly without allocating new memory. It safely clamps the number of bytes copied to prevent + * `IndexOutOfBoundsException`. The actual number of bytes transferred will be the minimum of: + * the requested [kapacity], the available data in this source array after [startIndex], or the + * available space in the [destination] array after [destinationOffset]. + * + * @param destination The array to write the copied data into. + * @param destinationOffset The index in the [destination] array to begin writing at. Defaults to 0. + * @param startIndex The index in this source array to begin reading from. Defaults to 0. + * @param kapacity The maximum amount of data to copy. + * @return The original [destination] array, allowing for fluent method chaining. + */ +fun Array.copyInto( + destination: Array, + destinationOffset: Int = 0, + startIndex: Int = 0, + kapacity: Kapacity, +): Array { + val maxDestinationSize = (destination.size - destinationOffset) + val maxSourceSize = (this.size - startIndex) + val safeLength = minOf( + a = kapacity.rawBytesCoercedToIntRange, + b = minOf(a = maxSourceSize, b = maxDestinationSize) + ) + + if (safeLength <= 0) return destination + + val safeEndOffset = startIndex + safeLength + return this@copyInto.copyInto( + destination = destination, + destinationOffset = destinationOffset, + startIndex = startIndex, + endIndex = safeEndOffset, + ) +} +//endregion \ No newline at end of file diff --git a/kapacity/src/commonTest/kotlin/io/github/developrofthings/kapacity/KapacityArrayCopyTest.kt b/kapacity/src/commonTest/kotlin/io/github/developrofthings/kapacity/KapacityArrayCopyTest.kt new file mode 100644 index 0000000..22f638c --- /dev/null +++ b/kapacity/src/commonTest/kotlin/io/github/developrofthings/kapacity/KapacityArrayCopyTest.kt @@ -0,0 +1,124 @@ +package io.github.developrofthings.kapacity + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertSame + +class KapacityArrayCopyTest { + + //region ByteArray Tests + @Test + fun testByteArrayCopyInto_exactFit() { + val source = byteArrayOf(1, 2, 3, 4, 5) + val dest = ByteArray(5) + + val result = source.copyInto(destination = dest, kapacity = 5.byte) + + // PROVE ZERO ALLOCATION: The returned array must be the exact same instance in memory + assertSame(dest, result) + assertContentEquals(byteArrayOf(1, 2, 3, 4, 5), dest) + } + + @Test + fun testByteArrayCopyInto_limitedByKapacity() { + val source = byteArrayOf(1, 2, 3, 4, 5) + val dest = ByteArray(5) + + // We have 5, dest holds 5, but we only want to copy 3 + source.copyInto(destination = dest, kapacity = 3.byte) + + assertContentEquals(byteArrayOf(1, 2, 3, 0, 0), dest) + } + + @Test + fun testByteArrayCopyInto_limitedBySourceSpace() { + val source = byteArrayOf(1, 2, 3) + val dest = ByteArray(5) + + // Try to copy 5, but source only has 3 available + source.copyInto(destination = dest, kapacity = 5.byte) + + assertContentEquals(byteArrayOf(1, 2, 3, 0, 0), dest) + } + + @Test + fun testByteArrayCopyInto_limitedByDestinationSpace() { + val source = byteArrayOf(1, 2, 3, 4, 5) + val dest = ByteArray(3) + + // Try to copy 5, but destination only has 3 slots + source.copyInto(destination = dest, kapacity = 5.byte) + + assertContentEquals(byteArrayOf(1, 2, 3), dest) + } + + @Test + fun testByteArrayCopyInto_withOffsetsAndClamping() { + val source = byteArrayOf(9, 9, 1, 2, 3) // We want to skip the two 9s + val dest = ByteArray(5) + + // Start reading at index 2, start writing at index 1 + source.copyInto( + destination = dest, + destinationOffset = 1, + startIndex = 2, + kapacity = 10.byte // Ask for way too much to test clamping + ) + + // Should safely clamp to the 3 available source bytes + assertContentEquals(byteArrayOf(0, 1, 2, 3, 0), dest) + } + + @Test + fun testByteArrayCopyInto_zeroKapacity() { + val source = byteArrayOf(1, 2, 3) + val dest = ByteArray(3) + + source.copyInto(destination = dest, kapacity = 0.byte) + + // Destination should remain completely untouched + assertContentEquals(byteArrayOf(0, 0, 0), dest) + } + //endregion + + //region UByteArray Tests + @OptIn(ExperimentalUnsignedTypes::class) + @Test + fun testUByteArrayCopyInto() { + val source = ubyteArrayOf(255u, 254u, 253u) + val dest = UByteArray(3) + + val result = source.copyInto(destination = dest, kapacity = 2.byte) + + assertContentEquals(dest, result) + assertContentEquals(ubyteArrayOf(255u, 254u, 0u), dest) + } + //endregion + + //region Object Array Tests + @Test + fun testObjectByteArrayCopyInto() { + val source: Array = arrayOf(1, 2, 3) + val dest: Array = Array(3) { 0 } + + val result = source.copyInto(destination = dest, kapacity = 2.byte) + + assertSame(dest, result) + assertContentEquals(arrayOf(1, 2, 0), dest) + } + //endregion + + //region Object Array Tests + @OptIn(ExperimentalUnsignedTypes::class) + @Test + fun testObjectUByteArrayCopyInto() { + val source: Array = arrayOf(10u, 20u, 30u) + val dest: Array = Array(3) { 0u } + + val result = source.copyInto(destination = dest, kapacity = 2.byte) + + assertSame(dest, result) + assertContentEquals(arrayOf(10u, 20u, 0u), dest) + } + //endregion +} \ No newline at end of file