Repository: orchestr7/ktoml (kotlin)

Issue:
`@TomlMultiline` does not properly escape backslashes during serialization
Given:

```kotlin
@Serializable
data class Reproducer(
    @TomlMultiline
    val foo: String
)

val obj = Reproducer("\\\\, \\\"")
val str = Toml.encodeToString(obj)
println(str)
```

<table>
  <thead>
    <tr>
      <th>Expectation</th>
      <th>Reality</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>

```toml
foo = """
\\\\, \\"
"""
```
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</td>
  <td>

```toml


foo = """
\\, \"
"""
```
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
  </td>
</tr>
</tbody> </table>

Then, after deserialization,

```kotlin
println(Toml.decodeFromString<Reproducer>(str).foo)
```

backslashes get "unescaped" and the `foo` property of the deserialized object is not equal to the original value: 

<table>
  <thead>
    <tr>
      <th>Expectation</th>
      <th>Reality</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>

```
\\, \"
```
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</td>
  <td>

```
\, "
```
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
  </td>
</tr>
</tbody> </table>

Pull request:
Fixes #330

Patch:
diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/StringUtils.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/StringUtils.kt
index 8b364992..d1311eab 100644
--- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/StringUtils.kt
+++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/StringUtils.kt
@@ -89,6 +89,23 @@ internal fun String.convertLineEndingBackslash(): String {
     }
 }
 
+/**
+ * Checks if the backslash at the given index is a line-ending backslash
+ * A line-ending backslash is defined as a backslash that is followed only by
+ * whitespace characters and then a newline character or the end of the string
+ *
+ * @param backslashIndex The index of the backslash to check
+ * @return `true` if the backslash is a line-ending backslash, `false` otherwise
+ */
+internal fun String.isLineEndingBackslash(backslashIndex: Int): Boolean {
+    var j = backslashIndex + 1
+    while (j < length && this[j] != newLineChar() && this[j].isWhitespace()) {
+        j++
+    }
+
+    return j == length || this[j] == newLineChar() || j == length
+}
+
 /**
  * If this string starts and end with quotes("") - will return the string with quotes removed
  * Otherwise, returns this string.
diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/SpecialCharacters.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/SpecialCharacters.kt
index 16662b0e..29c29afa 100644
--- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/SpecialCharacters.kt
+++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/SpecialCharacters.kt
@@ -10,6 +10,7 @@
 package com.akuleshov7.ktoml.utils
 
 import com.akuleshov7.ktoml.exceptions.UnknownEscapeSymbolsException
+import com.akuleshov7.ktoml.parsers.isLineEndingBackslash
 
 internal const val COMPLEX_UNICODE_LENGTH = 8
 internal const val COMPLEX_UNICODE_PREFIX = 'U'
@@ -103,13 +104,11 @@ public fun StringBuilder.appendEscapedUnicode(
  */
 public fun String.escapeSpecialCharacters(multiline: Boolean = false): String =
     if (multiline) {
-        escapeControlChars(Char::isMultilineControlChar)
+        escapeControlChars(isMultiline = true)
             .replace("\"\"\"", "\"\"\\\"")
-            .escapeBackslashes("btnfruU\"\r\n")
     } else {
-        escapeControlChars(Char::isControlChar)
+        escapeControlChars(isMultiline = false)
             .replace("\"", "\\\"")
-            .escapeBackslashes("btnfruU\"")
     }
 
 /**
@@ -133,24 +132,60 @@ internal fun Char.isControlChar() = this in CharCategory.CONTROL && this != '\t'
  */
 internal fun Char.isMultilineControlChar() = isControlChar() && this !in "\n\r"
 
-private inline fun String.escapeControlChars(predicate: (Char) -> Boolean): String {
+private fun String.escapeControlChars(isMultiline: Boolean): String {
+    val isControlChar = if (isMultiline) {
+        Char::isMultilineControlChar
+    } else {
+        Char::isControlChar
+    }
+
     val sb = StringBuilder(length)
-    var last = 0
+    var slashCount = 0
     for ((i, char) in withIndex()) {
-        if (predicate(char)) {
-            sb.append(this, last, i)
-                .append(char.escapeControlChar())
-            last = i + 1
+        if (char == '\\') {
+            slashCount++
+            continue
+        }
+
+        if (slashCount > 0) {
+            sb.append(
+                escapeBackslashes(lastBackslashIndex = i - 1, slashCount = slashCount, isMultiline = isMultiline)
+            )
+            slashCount = 0
+        }
+
+        if (isControlChar(char)) {
+            sb.append(char.escapeControlChar())
+        } else {
+            sb.append(char)
         }
     }
 
-    if (last < length) {
-        sb.append(this, last, length)
+    if (isNotEmpty() && slashCount > 0) {
+        sb.append(
+            escapeBackslashes(lastBackslashIndex = length - 1, slashCount = slashCount, isMultiline = isMultiline)
+        )
     }
 
     return sb.toString()
 }
 
+private fun String.escapeBackslashes(
+    lastBackslashIndex: Int,
+    slashCount: Int,
+    isMultiline: Boolean,
+): String {
+    val isLineEndingBackslash = slashCount % 2 != 0 &&
+            isMultiline &&
+            isLineEndingBackslash(lastBackslashIndex)
+
+    return if (isLineEndingBackslash) {
+        "\\".repeat(slashCount * 2 - 1)
+    } else {
+        "\\".repeat(slashCount * 2)
+    }
+}
+
 private fun Char.escapeControlChar() = when (this) {
     '\t' -> "\\t"
     '\b' -> "\\b"
@@ -166,31 +201,6 @@ private fun Char.escapeControlChar() = when (this) {
     }
 }
 
-private fun String.escapeBackslashes(escapes: String): String {
-    val sb = StringBuilder(length)
-    var slashCount = 0
-    var last = 0
-    for ((i, char) in withIndex()) {
-        if (char == '\\') {
-            slashCount++
-        } else {
-            if (slashCount > 0 && char !in escapes && slashCount % 2 != 0) {
-                sb.append(this, last, i - 1)
-                    .append("\\\\$char")
-                last = i + 1
-            }
-
-            slashCount = 0
-        }
-    }
-
-    if (last < length) {
-        sb.append(this, last, length)
-    }
-
-    return sb.toString()
-}
-
 /**
  * just a newline character on different platforms/targets
  *


Test patch:
diff --git a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/encoders/EncodingAnnotationTest.kt b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/encoders/EncodingAnnotationTest.kt
index add1ac7b..9924601b 100644
--- a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/encoders/EncodingAnnotationTest.kt
+++ b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/encoders/EncodingAnnotationTest.kt
@@ -3,10 +3,13 @@ package com.akuleshov7.ktoml.encoders
 import com.akuleshov7.ktoml.Toml
 import com.akuleshov7.ktoml.TomlOutputConfig
 import com.akuleshov7.ktoml.annotations.*
+import com.akuleshov7.ktoml.utils.isControlChar
 import com.akuleshov7.ktoml.writers.IntegerRepresentation.*
 import kotlinx.serialization.EncodeDefault
 import kotlinx.serialization.ExperimentalSerializationApi
 import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
 import kotlin.test.Test
 
 class EncodingAnnotationTest {
@@ -252,17 +255,11 @@ class EncodingAnnotationTest {
             @TomlMultiline
             val mlTextA: String = "\n\\tMultiline\ntext!\n",
             @TomlMultiline
-            val mlTextB: String = """
-                
-                Text with escaped quotes ""\"\
-                and line break
-                
-            """.trimIndent(),
+            val mlTextB: String = "\nText with escaped quotes \"\"\"\\\nand line break\n",
             @TomlLiteral
             @TomlMultiline
             val mlTextC: String = "\n\"Multiline\ntext!\"\n"
         )
-
         val tripleQuotes = "\"\"\""
 
         assertEncodedEquals(
@@ -270,7 +267,7 @@ class EncodingAnnotationTest {
             expectedToml = """
                 mlTextA = $tripleQuotes
                 
-                \tMultiline
+                \\tMultiline
                 text!
                 
                 $tripleQuotes
@@ -290,6 +287,25 @@ class EncodingAnnotationTest {
         )
     }
 
+    @Test
+    fun encodeBackslashesInMultiline() {
+        @Serializable
+        data class Reproducer(
+            @TomlMultiline
+            val foo: String
+        )
+        val tripleQuotes = "\"\"\""
+
+        assertEncodedEquals(
+            Reproducer("\\\\, \\\""),
+            """
+                foo = $tripleQuotes
+                \\\\, \\"
+                $tripleQuotes
+            """.trimIndent()
+        )
+    }
+
     @OptIn(ExperimentalSerializationApi::class)
     @Test
     fun encodeDefaultAnnotation() {
diff --git a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/encoders/PrimitiveEncoderTest.kt b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/encoders/PrimitiveEncoderTest.kt
index 16369405..738cc51f 100644
--- a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/encoders/PrimitiveEncoderTest.kt
+++ b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/encoders/PrimitiveEncoderTest.kt
@@ -73,7 +73,7 @@ class PrimitiveEncoderTest {
 
         assertEncodedEquals(
             value = File("""hello \Uffffffff world"""),
-            expectedToml = """escapeString = "hello \Uffffffff world""""
+            expectedToml = """escapeString = "hello \\Uffffffff world""""
         )
 
         assertEncodedEquals(
diff --git a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/writers/ValueWriteTest.kt b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/writers/ValueWriteTest.kt
index 19fafca4..063b20d1 100644
--- a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/writers/ValueWriteTest.kt
+++ b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/writers/ValueWriteTest.kt
@@ -3,7 +3,6 @@ package com.akuleshov7.ktoml.writers
 import com.akuleshov7.ktoml.TomlOutputConfig
 import com.akuleshov7.ktoml.exceptions.TomlWritingException
 import com.akuleshov7.ktoml.tree.nodes.pairs.values.*
-import kotlinx.datetime.Instant
 import kotlinx.datetime.LocalDate
 import kotlinx.datetime.LocalDateTime
 import kotlinx.datetime.LocalTime
@@ -46,9 +45,17 @@ class PrimitiveValueWriteTest {
 
         // Backslash escaping
         testTomlValue(TomlBasicString("""hello\world""" as Any), """"hello\\world"""")
-        testTomlValue(TomlBasicString("""hello\\\ world""" as Any), """"hello\\\\ world"""")
-        testTomlValue(TomlBasicString("""hello\b\t\n\\\f\r world""" as Any), """"hello\b\t\n\\\f\r world"""")
-        testTomlValue(TomlBasicString("""hello\u0000\\\Uffffffff world""" as Any), """"hello\u0000\\\Uffffffff world"""")
+        testTomlValue(TomlBasicString("""hello\\\ world""" as Any), """"hello\\\\\\ world"""")
+        testTomlValue(TomlBasicString("hello\b\\t\n\\\\f\r world" as Any), """"hello\b\\t\n\\\\f\r world"""")
+        testTomlValue(TomlBasicString("hello\u0000\\\u0011 world" as Any), """"hello\u0000\\\u0011 world"""")
+    }
+
+    @Test
+    fun backslashEscapingWriteTest() {
+        testTomlValue(TomlBasicString("""\""" as Any), """"\\"""", TomlOutputConfig())
+        testTomlValue(TomlBasicString("""\a""" as Any), """"\\a"""", TomlOutputConfig())
+        testTomlValue(TomlBasicString("""\\\""" as Any), """"\\\\\\"""", TomlOutputConfig())
+        testTomlValue(TomlBasicString("""a\\b""" as Any), """"a\\\\b"""", TomlOutputConfig())
     }
 
     @Test