diff --git a/libraries/stdlib/jdk7/src/kotlin/io/path/PathReadWrite.kt b/libraries/stdlib/jdk7/src/kotlin/io/path/PathReadWrite.kt index 89cd524d59d..20eab88422a 100644 --- a/libraries/stdlib/jdk7/src/kotlin/io/path/PathReadWrite.kt +++ b/libraries/stdlib/jdk7/src/kotlin/io/path/PathReadWrite.kt @@ -10,6 +10,7 @@ package kotlin.io.path import java.io.* +import java.nio.CharBuffer import java.nio.charset.Charset import java.nio.file.Files import java.nio.file.OpenOption @@ -168,7 +169,22 @@ public fun Path.readText(charset: Charset = Charsets.UTF_8): String = @WasExperimental(ExperimentalPathApi::class) @Throws(IOException::class) public fun Path.writeText(text: CharSequence, charset: Charset = Charsets.UTF_8, vararg options: OpenOption) { - Files.newOutputStream(this, *options).writer(charset).use { it.append(text) } + Files.newOutputStream(this, *options).use { out -> + if (text is String) { + out.writeTextImpl(text, charset) + return@use + } + + val encoder = charset.newReplaceEncoder() + val charBuffer = if (text is CharBuffer) text.asReadOnlyBuffer() else CharBuffer.wrap(text) + val byteBuffer = byteBufferForEncoding(chunkSize = minOf(text.length, DEFAULT_BUFFER_SIZE), encoder) + + while (charBuffer.hasRemaining()) { + encoder.encode(charBuffer, byteBuffer, /*endOfInput = */true).also { check(!it.isError) } + out.write(byteBuffer.array(), 0, byteBuffer.position()) + byteBuffer.clear() + } + } } /** @@ -181,7 +197,7 @@ public fun Path.writeText(text: CharSequence, charset: Charset = Charsets.UTF_8, @WasExperimental(ExperimentalPathApi::class) @Throws(IOException::class) public fun Path.appendText(text: CharSequence, charset: Charset = Charsets.UTF_8) { - Files.newOutputStream(this, StandardOpenOption.APPEND).writer(charset).use { it.append(text) } + writeText(text, charset, StandardOpenOption.APPEND) } /** diff --git a/libraries/stdlib/jdk7/test/PathReadWriteTest.kt b/libraries/stdlib/jdk7/test/PathReadWriteTest.kt index 8b23086fd13..b45ecb2926c 100644 --- a/libraries/stdlib/jdk7/test/PathReadWriteTest.kt +++ b/libraries/stdlib/jdk7/test/PathReadWriteTest.kt @@ -5,12 +5,130 @@ package kotlin.jdk7.test +import java.io.ByteArrayOutputStream +import java.nio.CharBuffer +import java.nio.charset.Charset +import java.nio.file.Path import java.nio.file.StandardOpenOption import kotlin.io.path.* import kotlin.random.Random import kotlin.test.* class PathReadWriteTest : AbstractPathTest() { + + private fun String.encodeToByteArray(charset: Charset): ByteArray { + val out = ByteArrayOutputStream() + out.writer(charset).use { it.append(this) } + return out.toByteArray() + } + + private val hexFormat = HexFormat { + bytes.bytesPerLine = 32 + bytes.bytesPerGroup = 8 + } + + private fun Path.testContentEquals(expectedContent: ByteArray, charset: Charset) { + val expected = expectedContent.toHexString(hexFormat) + val actualContent = readBytes() + val actual = actualContent.toHexString(hexFormat) + assertEquals(expected, actual, "$charset. Expected size is ${expectedContent.size}, actual size is ${actualContent.size}") + } + + private fun Path.testWriteText(text: String, charset: Charset) { + // Path.writeText + val encodedText = text.encodeToByteArray(charset) + + writeText(text, charset) + testContentEquals(encodedText, charset) + + writeText(StringBuilder(text), charset) + testContentEquals(encodedText, charset) + + val position = 1.coerceAtMost(text.length) + val limit = (text.length - 1).coerceAtLeast(position) + val charBuffer = CharBuffer.wrap(text, position, limit) + val encodedCharBuffer = text.substring(position, limit).encodeToByteArray(charset) + + writeText(charBuffer, charset) + testContentEquals(encodedCharBuffer, charset) + assertEquals(position, charBuffer.position()) + assertEquals(limit, charBuffer.limit()) + assertEquals(text.length, charBuffer.capacity()) + + // Path.appendText + val prefix = "_" + val encodedPrefix = prefix.encodeToByteArray(charset) + + writeText(prefix, charset) + appendText(text, charset) + testContentEquals(encodedPrefix + encodedText, charset) + + writeText(prefix, charset) + appendText(StringBuilder(text), charset) + testContentEquals(encodedPrefix + encodedText, charset) + + writeText(prefix, charset) + appendText(charBuffer, charset) + testContentEquals(encodedPrefix + encodedCharBuffer, charset) + assertEquals(position, charBuffer.position()) + assertEquals(limit, charBuffer.limit()) + assertEquals(text.length, charBuffer.capacity()) + + // File.writeText + toFile().writeText(text, charset) + testContentEquals(encodedText, charset) + + // File.appendText + toFile().writeText(prefix, charset) + toFile().appendText(text, charset) + testContentEquals(encodedPrefix + encodedText, charset) + } + + @Test fun writeText() { + val charsets = listOf( + Charsets.UTF_8, + Charsets.UTF_16, + Charsets.UTF_32, + Charsets.ISO_8859_1, + Charsets.US_ASCII, + ) + + val highSurrogate = Char.MIN_HIGH_SURROGATE + val lowSurrogate = Char.MIN_LOW_SURROGATE + + val smallString = "Hello" + + val chunkSize = DEFAULT_BUFFER_SIZE + val string = "k".repeat(chunkSize - 1) + + val path = createTempFile().cleanup() + + for (charset in charsets) { + path.testWriteText("$highSurrogate", charset) + + path.testWriteText("$lowSurrogate", charset) + + path.testWriteText("$smallString$highSurrogate", charset) + + path.testWriteText("$smallString$lowSurrogate", charset) + + path.testWriteText("$string$highSurrogate", charset) + + path.testWriteText("$string$lowSurrogate", charset) + + path.testWriteText("$string$highSurrogate$lowSurrogate$string", charset) + + path.testWriteText("$string$lowSurrogate$highSurrogate$string", charset) + + path.testWriteText( + "$string$highSurrogate$lowSurrogate${string.substring(2)}$highSurrogate$lowSurrogate", + charset + ) + + path.testWriteText("$string$lowSurrogate$highSurrogate$lowSurrogate$string", charset) + } + } + @Test fun appendText() { val file = createTempFile().cleanup() diff --git a/libraries/stdlib/jvm/src/kotlin/io/FileReadWrite.kt b/libraries/stdlib/jvm/src/kotlin/io/FileReadWrite.kt index 94f583b552d..b5102f4b44e 100644 --- a/libraries/stdlib/jvm/src/kotlin/io/FileReadWrite.kt +++ b/libraries/stdlib/jvm/src/kotlin/io/FileReadWrite.kt @@ -9,9 +9,13 @@ package kotlin.io import java.io.* +import java.nio.ByteBuffer +import java.nio.CharBuffer import java.util.* import java.nio.charset.Charset -import kotlin.internal.* +import java.nio.charset.CharsetEncoder +import java.nio.charset.CodingErrorAction +import kotlin.math.ceil /** @@ -131,7 +135,8 @@ public fun File.readText(charset: Charset = Charsets.UTF_8): String = reader(cha * @param text text to write into file. * @param charset character set to use. */ -public fun File.writeText(text: String, charset: Charset = Charsets.UTF_8): Unit = writeBytes(text.toByteArray(charset)) +public fun File.writeText(text: String, charset: Charset = Charsets.UTF_8): Unit = + FileOutputStream(this).use { it.writeTextImpl(text, charset) } /** * Appends [text] to the content of this file using UTF-8 or the specified [charset]. @@ -139,7 +144,54 @@ public fun File.writeText(text: String, charset: Charset = Charsets.UTF_8): Unit * @param text text to append to file. * @param charset character set to use. */ -public fun File.appendText(text: String, charset: Charset = Charsets.UTF_8): Unit = appendBytes(text.toByteArray(charset)) +public fun File.appendText(text: String, charset: Charset = Charsets.UTF_8): Unit = + FileOutputStream(this, true).use { it.writeTextImpl(text, charset) } + +internal fun OutputStream.writeTextImpl(text: String, charset: Charset) { + val chunkSize = DEFAULT_BUFFER_SIZE + + if (text.length < 2 * chunkSize) { + this.write(text.toByteArray(charset)) + return + } + + val encoder = charset.newReplaceEncoder() + val charBuffer = CharBuffer.allocate(chunkSize) + val byteBuffer = byteBufferForEncoding(chunkSize, encoder) + + var startIndex = 0 + var leftover = 0 + + while (startIndex < text.length) { + val copyLength = minOf(chunkSize - leftover, text.length - startIndex) + val endIndex = startIndex + copyLength + + text.toCharArray(charBuffer.array(), leftover, startIndex, endIndex) + charBuffer.limit(copyLength + leftover) + encoder.encode(charBuffer, byteBuffer, /*endOfInput = */endIndex == text.length).also { check(it.isUnderflow) } + this.write(byteBuffer.array(), 0, byteBuffer.position()) + + if (charBuffer.position() != charBuffer.limit()) { + charBuffer.put(0, charBuffer.get()) // the last char is a high surrogate + leftover = 1 + } else { + leftover = 0 + } + + charBuffer.clear() + byteBuffer.clear() + startIndex = endIndex + } +} + +internal fun Charset.newReplaceEncoder() = newEncoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + +internal fun byteBufferForEncoding(chunkSize: Int, encoder: CharsetEncoder): ByteBuffer { + val maxBytesPerChar = ceil(encoder.maxBytesPerChar()).toInt() // including replacement sequence + return ByteBuffer.allocate(chunkSize * maxBytesPerChar) +} /** * Reads file by byte blocks and calls [action] for each block read.