Repository: Kotlin/kotlinx-io (kotlin)

Issue:
Missing API: `Sink.write(CharSequence)`
At the moment, when receiving data from a mutable `CharSequence`, one has to use `sink.writeString(chars.toString())`, which may be inefficient (copying data to create a `String`, just to then encode it again as UTF-8 bytes in the buffer).

It would be nice to have a `Sink.write(CharSequence)` (or `Sink.writeUtf8(CharSequence)`) extension so we can efficiently transfer characters from a `CharSequence` into a `Buffer` via UTF-8 encoding.

Pull request description:
Fixes #261

Patch:
diff --git a/core/api/kotlinx-io-core.api b/core/api/kotlinx-io-core.api
index 1f1dff89a..78260853c 100644
--- a/core/api/kotlinx-io-core.api
+++ b/core/api/kotlinx-io-core.api
@@ -195,7 +195,9 @@ public final class kotlinx/io/Utf8Kt {
 	public static final fun readString (Lkotlinx/io/Source;)Ljava/lang/String;
 	public static final fun readString (Lkotlinx/io/Source;J)Ljava/lang/String;
 	public static final fun writeCodePointValue (Lkotlinx/io/Sink;I)V
+	public static final fun writeString (Lkotlinx/io/Sink;Ljava/lang/CharSequence;II)V
 	public static final fun writeString (Lkotlinx/io/Sink;Ljava/lang/String;II)V
+	public static synthetic fun writeString$default (Lkotlinx/io/Sink;Ljava/lang/CharSequence;IIILjava/lang/Object;)V
 	public static synthetic fun writeString$default (Lkotlinx/io/Sink;Ljava/lang/String;IIILjava/lang/Object;)V
 }
 
diff --git a/core/api/kotlinx-io-core.klib.api b/core/api/kotlinx-io-core.klib.api
index 49e886dc6..8bd72bce5 100644
--- a/core/api/kotlinx-io-core.klib.api
+++ b/core/api/kotlinx-io-core.klib.api
@@ -92,6 +92,7 @@ final fun (kotlinx.io/Sink).kotlinx.io/writeHexadecimalUnsignedLong(kotlin/Long)
 final fun (kotlinx.io/Sink).kotlinx.io/writeIntLe(kotlin/Int) // kotlinx.io/writeIntLe|writeIntLe@kotlinx.io.Sink(kotlin.Int){}[0]
 final fun (kotlinx.io/Sink).kotlinx.io/writeLongLe(kotlin/Long) // kotlinx.io/writeLongLe|writeLongLe@kotlinx.io.Sink(kotlin.Long){}[0]
 final fun (kotlinx.io/Sink).kotlinx.io/writeShortLe(kotlin/Short) // kotlinx.io/writeShortLe|writeShortLe@kotlinx.io.Sink(kotlin.Short){}[0]
+final fun (kotlinx.io/Sink).kotlinx.io/writeString(kotlin/CharSequence, kotlin/Int =..., kotlin/Int =...) // kotlinx.io/writeString|writeString@kotlinx.io.Sink(kotlin.CharSequence;kotlin.Int;kotlin.Int){}[0]
 final fun (kotlinx.io/Sink).kotlinx.io/writeString(kotlin/String, kotlin/Int =..., kotlin/Int =...) // kotlinx.io/writeString|writeString@kotlinx.io.Sink(kotlin.String;kotlin.Int;kotlin.Int){}[0]
 final fun (kotlinx.io/Sink).kotlinx.io/writeUByte(kotlin/UByte) // kotlinx.io/writeUByte|writeUByte@kotlinx.io.Sink(kotlin.UByte){}[0]
 final fun (kotlinx.io/Sink).kotlinx.io/writeUInt(kotlin/UInt) // kotlinx.io/writeUInt|writeUInt@kotlinx.io.Sink(kotlin.UInt){}[0]
diff --git a/core/common/src/Utf8.kt b/core/common/src/Utf8.kt
index e5b762429..eaa9912d2 100644
--- a/core/common/src/Utf8.kt
+++ b/core/common/src/Utf8.kt
@@ -162,8 +162,31 @@ public fun Sink.writeCodePointValue(codePoint: Int): Unit =
  * @sample kotlinx.io.samples.KotlinxIoCoreCommonSamples.writeUtf8Sample
  */
 @OptIn(DelicateIoApi::class)
-public fun Sink.writeString(string: String, startIndex: Int = 0, endIndex: Int = string.length): Unit =
-    writeToInternalBuffer { it.commonWriteUtf8(string, startIndex, endIndex) }
+public fun Sink.writeString(string: String, startIndex: Int = 0, endIndex: Int = string.length) {
+    checkBounds(string.length, startIndex, endIndex)
+
+    writeToInternalBuffer { it.commonWriteUtf8(startIndex, endIndex, string::get) }
+}
+
+/**
+ * Encodes the characters at [startIndex] up to [endIndex] from [chars] in UTF-8 and writes it to this sink.
+ *
+ * @param chars the string to be encoded.
+ * @param startIndex the index (inclusive) of the first character to encode, 0 by default.
+ * @param endIndex the index (exclusive) of a character past to a last character to encode, `chars.length` by default.
+ *
+ * @throws IndexOutOfBoundsException when [startIndex] or [endIndex] is out of range of [chars] indices.
+ * @throws IllegalArgumentException when `startIndex > endIndex`.
+ * @throws IllegalStateException when the sink is closed.
+ *
+ * @sample kotlinx.io.samples.KotlinxIoCoreCommonSamples.writeUtf8SeqSample
+ */
+@OptIn(DelicateIoApi::class)
+public fun Sink.writeString(chars: CharSequence, startIndex: Int = 0, endIndex: Int = chars.length) {
+    checkBounds(chars.length, startIndex, endIndex)
+
+    writeToInternalBuffer { it.commonWriteUtf8(startIndex, endIndex, chars::get) }
+}
 
 /**
  * Removes all bytes from this source, decodes them as UTF-8, and returns the string.
@@ -431,13 +454,11 @@ private fun Buffer.commonReadUtf8CodePoint(): Int {
     }
 }
 
-private fun Buffer.commonWriteUtf8(string: String, beginIndex: Int, endIndex: Int) {
-    checkBounds(string.length, beginIndex, endIndex)
-
-    // Transcode a UTF-16 Java String to UTF-8 bytes.
+private inline fun Buffer.commonWriteUtf8(beginIndex: Int, endIndex: Int, charAt: (Int) -> Char) {
+    // Transcode a UTF-16 chars to UTF-8 bytes.
     var i = beginIndex
     while (i < endIndex) {
-        var c = string[i].code
+        var c = charAt(i).code
 
         when {
             c < 0x80 -> {
@@ -452,7 +473,7 @@ private fun Buffer.commonWriteUtf8(string: String, beginIndex: Int, endIndex: In
                 // Fast-path contiguous runs of ASCII characters. This is ugly, but yields a ~4x performance
                 // improvement over independent calls to writeByte().
                 while (i < runLimit) {
-                    c = string[i].code
+                    c = charAt(i).code
                     if (c >= 0x80) break
                     data[segmentOffset + i++] = c.toByte() // 0xxxxxxx
                 }
@@ -487,7 +508,7 @@ private fun Buffer.commonWriteUtf8(string: String, beginIndex: Int, endIndex: In
                 // c is a surrogate. Make sure it is a high surrogate & that its successor is a low
                 // surrogate. If not, the UTF-16 is invalid, in which case we emit a replacement
                 // character.
-                val low = (if (i + 1 < endIndex) string[i + 1].code else 0)
+                val low = (if (i + 1 < endIndex) charAt(i + 1).code else 0)
                 if (c > 0xdbff || low !in 0xdc00..0xdfff) {
                     writeByte('?'.code.toByte())
                     i++


Test patch:
diff --git a/core/common/test/AbstractSinkTest.kt b/core/common/test/AbstractSinkTest.kt
index 1c2aaa4fe..f52f5eb7b 100644
--- a/core/common/test/AbstractSinkTest.kt
+++ b/core/common/test/AbstractSinkTest.kt
@@ -400,6 +400,27 @@ abstract class AbstractSinkTest internal constructor(
         assertFailsWith<IllegalArgumentException> { sink.writeString("hello", startIndex = 6) }
     }
 
+    @Test
+    fun writeCharSequenceFromIndex() {
+        sink.writeString(StringBuilder("12345"), 3)
+        sink.emit()
+        assertEquals("45", data.readString())
+    }
+
+    @Test
+    fun writeCharSequenceFromRange() {
+        sink.writeString(StringBuilder("0123456789"), 4, 7)
+        sink.emit()
+        assertEquals("456", data.readString())
+    }
+
+    @Test
+    fun writeCharSequenceWithInvalidIndexes() {
+        assertFailsWith<IndexOutOfBoundsException> { sink.writeString(StringBuilder("hello"), startIndex = -1) }
+        assertFailsWith<IndexOutOfBoundsException> { sink.writeString(StringBuilder("hello"), startIndex = 0, endIndex = 6) }
+        assertFailsWith<IllegalArgumentException> { sink.writeString(StringBuilder("hello"), startIndex = 6) }
+    }
+
     @Test
     fun writeUByte() {
         sink.writeUByte(0xffu)
diff --git a/core/common/test/Utf8Test.kt b/core/common/test/Utf8Test.kt
index 5d2b2786b..2f1853985 100644
--- a/core/common/test/Utf8Test.kt
+++ b/core/common/test/Utf8Test.kt
@@ -202,24 +202,40 @@ class Utf8Test {
         bufferWriteUtf8StringCheck(Segment.SIZE - 1)
     }
 
-    private fun bufferWriteUtf8StringCheck(prefixLength: Int) {
+    @Test
+    fun bufferWriteUtf8CharSequence() {
+        bufferWriteUtf8StringCheck(0) { buffer, string ->  buffer.writeString(StringBuilder(string)) }
+    }
+
+    @Test
+    fun bufferWriteUtf8CharSequenceCrossSegments() {
+        bufferWriteUtf8StringCheck(Segment.SIZE - 1) { buffer, string ->
+            buffer.writeString(StringBuilder(string))
+        }
+    }
+
+    private inline fun bufferWriteUtf8StringCheck(
+        prefixLength: Int,
+        writeAction: (Buffer, String) -> Unit = { b, s -> b.writeString(s) }
+    ) {
         val buffer = Buffer()
-        buffer.assertUtf8StringEncoded("68656c6c6f", "hello", prefixLength)
+        buffer.assertUtf8StringEncoded("68656c6c6f", "hello", prefixLength, writeAction)
         buffer.assertUtf8StringEncoded("cf87ceb5cf81ceb5cf84ceb9cf83cebccf8ccf82", "χερετισμός",
-            prefixLength)
+            prefixLength, writeAction)
         buffer.assertUtf8StringEncoded(
             "e18392e18390e1839be18390e183a0e183afe1839de18391e18390",
             "გამარჯობა",
-            prefixLength
+            prefixLength,
+            writeAction
         )
         buffer.assertUtf8StringEncoded(
             "f093878bf0938bb4f09380a5",
             "\uD80C\uDDCB\uD80C\uDEF4\uD80C\uDC25",/* 𓇋𓋴𓀥, to hail, AN EGYPTIAN HIEROGLYPHIC DICTIONARY, p. 79b */
-            prefixLength
+            prefixLength, writeAction
         )
 
         // two consecutive high surrogates, replace with '?'
-        buffer.assertUtf8StringEncoded("3f3f", "\ud801\uD801", prefixLength)
+        buffer.assertUtf8StringEncoded("3f3f", "\ud801\uD801", prefixLength, writeAction)
     }
 
     @Test
@@ -452,9 +468,10 @@ class Utf8Test {
         assertEquals(expectedCodePoint, readCodePointValue())
     }
 
-    private fun Buffer.assertUtf8StringEncoded(expectedHex: String, string: String, prefixLength: Int = 0) {
+    private inline fun Buffer.assertUtf8StringEncoded(expectedHex: String, string: String, prefixLength: Int = 0,
+                                               writeAction: (Buffer, String) -> Unit) {
         write(ByteArray(prefixLength))
-        writeString(string)
+        writeAction(this, string)
         skip(prefixLength.toLong())
         assertArrayEquals(expectedHex.decodeHex(), readByteArray())
     }
diff --git a/core/common/test/samples/samples.kt b/core/common/test/samples/samples.kt
index ac371f9f6..870bfa764 100644
--- a/core/common/test/samples/samples.kt
+++ b/core/common/test/samples/samples.kt
@@ -337,6 +337,23 @@ class KotlinxIoCoreCommonSamples {
         assertContentEquals(byteArrayOf(0xce.toByte(), 0x94.toByte()), buffer.readByteArray())
     }
 
+    @Test
+    fun writeUtf8SeqSample() {
+        val buffer = Buffer()
+
+        buffer.writeString(StringBuilder("hello"), startIndex = 1, endIndex = 4)
+        assertContentEquals(
+            byteArrayOf(
+                'e'.code.toByte(),
+                'l'.code.toByte(),
+                'l'.code.toByte()
+            ), buffer.readByteArray()
+        )
+
+        buffer.writeString(StringBuilder("Δ"))
+        assertContentEquals(byteArrayOf(0xce.toByte(), 0x94.toByte()), buffer.readByteArray())
+    }
+
     @Test
     fun readUtf8() {
         val buffer = Buffer()