Repository: orchestr7/ktoml (kotlin)

Issue:
Add the proper support of nested inline tables
```
table2 = { someNumber = 5, table2."akuleshov7.com" = { name = 'this is a "literal" string', configurationList = ["a",  "b",  "c", null] }}
```

Pull request description:
Fixes #107

- Support Nested Inline Tables
- Support arrays in inline tables


First problem was in `TomlInlineTable::parseInlineTableValue`, when we called `.split(",")`. It doesn't work with arrays (cause they also have comma) and with nested inline tables. Now we do the same split, but ignore all commas inside arrays/nested inline.


Second problem was in `TomlInlineTable::returnTable`, in test case we had this TomlTable fullTableKey:
![image](https://github.com/user-attachments/assets/b2807071-8655-4b90-8ac4-771615cbe77a)

Patch:
diff --git a/README.md b/README.md
index 7ab2306b..9ccd01f3 100644
--- a/README.md
+++ b/README.md
@@ -75,8 +75,8 @@ We are still developing and testing this library, so it has several limitations:
 :white_check_mark: Multiline Strings \
 :white_check_mark: Arrays (including multiline and nested arrays) \
 :white_check_mark: Maps (for anonymous key-value pairs) \
+:white_check_mark: Nested Inline Tables \
 :x: Arrays: of Different Types \
-:x: Nested Inline Tables \
 :x: Array of Tables \
 :x: Inline Array of Tables
 
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 2e20e130..0928b667 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
@@ -166,7 +166,7 @@ internal fun String.trimDoubleBrackets(): String = trimSymbols(this, "[[", "]]")
  * ```
  */
 internal fun String.takeBeforeComment(allowEscapedQuotesInLiteralStrings: Boolean): String {
-    val commentStartIndex = indexOfFirstOutsideQuotes(allowEscapedQuotesInLiteralStrings, '#')
+    val commentStartIndex = indexOfNextOutsideQuotes(allowEscapedQuotesInLiteralStrings, '#')
 
     return if (commentStartIndex == -1) {
         this
@@ -185,7 +185,7 @@ internal fun String.takeBeforeComment(allowEscapedQuotesInLiteralStrings: Boolea
  * ```
  */
 internal fun String.trimComment(allowEscapedQuotesInLiteralStrings: Boolean): String {
-    val commentStartIndex = indexOfFirstOutsideQuotes(allowEscapedQuotesInLiteralStrings, '#')
+    val commentStartIndex = indexOfNextOutsideQuotes(allowEscapedQuotesInLiteralStrings, '#')
 
     return if (commentStartIndex == -1) {
         ""
@@ -203,29 +203,21 @@ internal fun String.getCountOfOccurrencesOfSubstring(substring: String): Int = t
 /**
  * @param allowEscapedQuotesInLiteralStrings value from TomlInputConfig
  * @param searchChar - the character to search for
+ * @param startIndex - the index to start searching from
  * @return the index of the first occurrence of the searchChar that is not enclosed in quotation marks
  */
-internal fun String.indexOfFirstOutsideQuotes(allowEscapedQuotesInLiteralStrings: Boolean, searchChar: Char): Int {
-    val isEscapingDisabled = if (allowEscapedQuotesInLiteralStrings) {
-        // escaping is disabled when the config option is true AND we have a literal string
-        val firstQuoteLetter = this.firstOrNull { it == '\"' || it == '\'' }
-        firstQuoteLetter == '\''
-    } else {
-        false
-    }
-
-    val chars = if (!isEscapingDisabled) {
-        this.replace("\\\"", "__")
-            .replace("\\\'", "__")
-    } else {
-        this
-    }.toCharArray()
+internal fun String.indexOfNextOutsideQuotes(
+    allowEscapedQuotesInLiteralStrings: Boolean,
+    searchChar: Char,
+    startIndex: Int = 0,
+): Int {
+    val chars = this.drop(startIndex).replaceEscaped(allowEscapedQuotesInLiteralStrings)
     var currentQuoteChar: Char? = null
 
     chars.forEachIndexed { idx, symbol ->
         // take searchChar index if it's not enclosed in quotation marks
         if (symbol == searchChar && currentQuoteChar == null) {
-            return idx
+            return idx + startIndex
         }
 
         if (symbol == '\"' || symbol == '\'') {
@@ -240,6 +232,28 @@ internal fun String.indexOfFirstOutsideQuotes(allowEscapedQuotesInLiteralStrings
     return -1
 }
 
+/**
+ * @param allowEscapedQuotesInLiteralStrings value from TomlInputConfig
+ * @param placeholder - the string to replace escaped quotes with
+ * @return the string with escaped quotes replaced with a placeholder
+ */
+internal fun String.replaceEscaped(allowEscapedQuotesInLiteralStrings: Boolean, placeholder: String = "__"): String {
+    val isEscapingDisabled = if (allowEscapedQuotesInLiteralStrings) {
+        // escaping is disabled when the config option is true AND we have a literal string
+        val firstQuoteLetter = this.firstOrNull { it == '\"' || it == '\'' }
+        firstQuoteLetter == '\''
+    } else {
+        false
+    }
+
+    return if (!isEscapingDisabled) {
+        this.replace("\\\"", placeholder)
+            .replace("\\\'", placeholder)
+    } else {
+        this
+    }
+}
+
 private fun String.validateSpaces(lineNo: Int, fullKey: String) {
     if (this.trim().count { it == ' ' } > 0 && this.isNotQuoted()) {
         throw ParseException(
diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/nodes/pairs/TomlKeyValue.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/nodes/pairs/TomlKeyValue.kt
index 27e19f43..d9dc7bbe 100644
--- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/nodes/pairs/TomlKeyValue.kt
+++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/nodes/pairs/TomlKeyValue.kt
@@ -3,7 +3,7 @@ package com.akuleshov7.ktoml.tree.nodes
 import com.akuleshov7.ktoml.TomlInputConfig
 import com.akuleshov7.ktoml.TomlOutputConfig
 import com.akuleshov7.ktoml.exceptions.ParseException
-import com.akuleshov7.ktoml.parsers.indexOfFirstOutsideQuotes
+import com.akuleshov7.ktoml.parsers.indexOfNextOutsideQuotes
 import com.akuleshov7.ktoml.parsers.takeBeforeComment
 import com.akuleshov7.ktoml.tree.nodes.pairs.keys.TomlKey
 import com.akuleshov7.ktoml.tree.nodes.pairs.values.*
@@ -96,7 +96,7 @@ public fun String.splitKeyValue(lineNo: Int, config: TomlInputConfig = TomlInput
     val pair = takeBeforeComment(config.allowEscapedQuotesInLiteralStrings)
 
     // searching for an equals sign that should be placed main part of the string (not in the comment)
-    val firstEqualsSign = pair.indexOfFirstOutsideQuotes(config.allowEscapedQuotesInLiteralStrings, '=')
+    val firstEqualsSign = pair.indexOfNextOutsideQuotes(config.allowEscapedQuotesInLiteralStrings, '=')
 
     // equals sign not found in the string
     if (firstEqualsSign == -1) {
diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/nodes/tables/TomlInlineTable.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/nodes/tables/TomlInlineTable.kt
index 5b73d916..d719d72f 100644
--- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/nodes/tables/TomlInlineTable.kt
+++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/nodes/tables/TomlInlineTable.kt
@@ -4,7 +4,9 @@ import com.akuleshov7.ktoml.TomlConfig
 import com.akuleshov7.ktoml.TomlInputConfig
 import com.akuleshov7.ktoml.TomlOutputConfig
 import com.akuleshov7.ktoml.exceptions.ParseException
+import com.akuleshov7.ktoml.parsers.indexOfNextOutsideQuotes
 import com.akuleshov7.ktoml.parsers.parseTomlKeyValue
+import com.akuleshov7.ktoml.parsers.replaceEscaped
 import com.akuleshov7.ktoml.parsers.trimCurlyBraces
 import com.akuleshov7.ktoml.tree.nodes.pairs.keys.TomlKey
 import com.akuleshov7.ktoml.writers.TomlEmitter
@@ -67,7 +69,7 @@ public class TomlInlineTable internal constructor(
         val tomlTable = TomlTable(
             TomlKey(
                 if (currentParentalNode is TomlTable) {
-                    currentParentalNode.fullTableKey.keyParts + name
+                    currentParentalNode.fullTableKey.keyParts + key.keyParts
                 } else {
                     listOf(name)
                 }
@@ -131,9 +133,8 @@ public class TomlInlineTable internal constructor(
             lineNo: Int,
             config: TomlInputConfig
         ): List<TomlNode> {
-            val parsedList = this
-                .trimCurlyBraces()
-                .trim()
+            val inlineTableValueString = this.trimCurlyBraces().trim()
+            val parsedList = inlineTableValueString
                 .also {
                     if (it.isEmpty()) {
                         return listOf(TomlStubEmptyNode(lineNo))
@@ -144,10 +145,73 @@ public class TomlInlineTable internal constructor(
                         )
                     }
                 }
-                .split(",")
+                .splitInlineTableToKeyValue(config.allowEscapedQuotesInLiteralStrings, lineNo)
                 .map { it.parseTomlKeyValue(lineNo, comments = emptyList(), inlineComment = "", config) }
 
             return parsedList
         }
+
+        /**
+         * That's basically split(",") function, but we ignore all commas inside arrays [ ],
+         * nested tables { } and quotes " "/' '
+         */
+        @Suppress("TOO_LONG_FUNCTION")
+        private fun String.splitInlineTableToKeyValue(
+            allowEscapedQuotesInLiteralStrings: Boolean,
+            lineNo: Int,
+        ): List<String> {
+            val clearedString = this.replaceEscaped(allowEscapedQuotesInLiteralStrings)
+            val keyValueList: MutableList<String> = mutableListOf()
+            var isLastAdded = false
+            var currentQuoteChar: Char? = null
+            var prevIdx = 0
+            var curIdx = 0
+
+            while (curIdx < clearedString.length) {
+                val ch = clearedString[curIdx]
+                if (ch == ',' && currentQuoteChar == null) {
+                    keyValueList.add(this.substring(prevIdx, curIdx).trim())
+                    prevIdx = curIdx + 1
+                } else if (currentQuoteChar == null && (ch == '[' || ch == '{')) {
+                    val closeBracketIdx = this.indexOfNextOutsideQuotes(
+                        allowEscapedQuotesInLiteralStrings = allowEscapedQuotesInLiteralStrings,
+                        searchChar = getCloseBracket(ch, lineNo),
+                        startIndex = curIdx,
+                    )
+                    keyValueList.add(this.substring(prevIdx, closeBracketIdx + 1).trim())
+                    val nextCommaIdx = this.indexOfNextOutsideQuotes(
+                        allowEscapedQuotesInLiteralStrings = allowEscapedQuotesInLiteralStrings,
+                        searchChar = ',',
+                        startIndex = closeBracketIdx,
+                    )
+                    if (nextCommaIdx == -1) {
+                        isLastAdded = true
+                        break
+                    }
+                    prevIdx = nextCommaIdx + 1
+                    curIdx = nextCommaIdx + 1
+                } else if (ch == '\'' || ch == '\"') {
+                    if (currentQuoteChar == null) {
+                        currentQuoteChar = ch
+                    } else if (currentQuoteChar == ch) {
+                        currentQuoteChar = null
+                    }
+                }
+                curIdx++
+            }
+            if (!isLastAdded) {
+                keyValueList.add(this.substring(prevIdx, this.length).trim())
+            }
+
+            return keyValueList
+        }
+
+        private fun getCloseBracket(openBracket: Char, lineNo: Int): Char = if (openBracket == '[') {
+            ']'
+        } else if (openBracket == '{') {
+            '}'
+        } else {
+            throw ParseException("Invalid open bracket: $openBracket, should never happen", lineNo)
+        }
     }
 }


Test patch:
diff --git a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/InlineTableDecoderTest.kt b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/InlineTableDecoderTest.kt
index 955deecb..9b28beea 100644
--- a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/InlineTableDecoderTest.kt
+++ b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/InlineTableDecoderTest.kt
@@ -5,7 +5,9 @@ import com.akuleshov7.ktoml.exceptions.ParseException
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 import kotlinx.serialization.decodeFromString
-import kotlin.test.*
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
 
 class InlineTableDecoderTest {
     @Serializable
@@ -30,22 +32,99 @@ class InlineTableDecoderTest {
     data class Version(val ref: String)
 
     @Test
-    @Ignore
+    fun decodeDottedNestedInlineTable() {
+        @Serializable
+        data class NestedTableWithContent(
+            val name: String,
+            @SerialName("configurationList")
+            val overriddenName: List<String?> = listOf(),
+        )
+
+        @Serializable
+        data class MyTable(
+            @SerialName("akuleshov7.com")
+            val inlineTable: NestedTableWithContent,
+        )
+
+        @Serializable
+        data class NestedTable(
+            val table: MyTable,
+            val i: Int,
+        )
+
+        @Serializable
+        data class MyClass(
+            val table: NestedTable,
+        )
+
+        val toml1 =
+            """
+            |table = { i = 1, table."akuleshov7.com" = { name = 'this is a "literal" string', configurationList = ["a",  "b",  "c", null   ]}}
+            |
+            """.trimMargin()
+        val toml2 = """
+            [table]
+                i = 1
+            [table.table."akuleshov7.com"]
+                name = 'this is a "literal" string'
+                configurationList = ["a",  "b",  "c", null   ]
+        """.trimIndent()
+
+        assertEquals(
+            MyClass(
+                table = NestedTable(
+                    table = MyTable(
+                        NestedTableWithContent("this is a \"literal\" string", listOf("a", "b", "c", null))
+                    ),
+                    i = 1,
+                ),
+            ),
+            Toml.decodeFromString<MyClass>(toml1),
+        )
+
+        assertEquals(
+            Toml.decodeFromString<MyClass>(toml1),
+            Toml.decodeFromString<MyClass>(toml2),
+        )
+    }
+
+    @Test
     fun decodeInlineTable() {
         val test =
             """
             |someBooleanProperty = true
             |
             |table1 = { property1 = null, property2 = 6 }
-            |table2 = { someNumber = 5, table2."akuleshov7.com" = { name = 'this is a "literal" string', configurationList = ["a",  "b",  "c", null] }}
-            |table2 = { otherNumber = 5.56 }
-            |inlineTable = { inlineValStr = "inline", inlineValInt = -1 }
-            |       
+            |table2 = { someNumber = 5, "akuleshov7.com" = { name = 'this is a "literal" string', configurationList = ["a",  "b",  "c", null]   }   , charFromInteger = 123  }
+            |table2 = { otherNumber = 5.56, charFromString = 'a' }
+            |gradle-libs-like-property = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+            |
+            |[myMap]
+            |   a = "b"
+            |   c = "d"
             """.trimMargin()
 
         Toml.decodeFromString<ReadMeExampleTest.MyClass>(test)
     }
 
+    @Test
+    fun arrayInInlineTable() {
+        @Serializable
+        data class TableWithArray(val arr: List<Int>)
+
+        @Serializable
+        data class TableWithArrayWrapper(val table: TableWithArray)
+
+        val test =
+            """
+            |table = { arr = [1, 2, 3,  ]   }
+            |
+            """.trimMargin()
+
+        val result = Toml.decodeFromString<TableWithArrayWrapper>(test)
+        println(result)
+    }
+
     @Test
     fun trailingCommaIsNotPermitted() {
         val test =