Repository: orchestr7/ktoml (kotlin)

Issue:
Inline arrays of tables
TOML standard allows inline arrays of tables in the same way how it is done for single inline tables (https://github.com/akuleshov7/ktoml/issues/48)

```
points = [ { x = 1, y = 2, z = 3 },
           { x = 7, y = 8, z = 9 },
           { x = 2, y = 4, z = 8 } ]

```

After https://github.com/akuleshov7/ktoml/issues/88 will be finished - this issue should be implemented

Pull request description:
Fixes #99 
Fixes #259

Patch:
diff --git a/README.md b/README.md
index fe587e51..310d7470 100644
--- a/README.md
+++ b/README.md
@@ -78,8 +78,9 @@ We are still developing and testing this library, so it has several limitations:
 :white_check_mark: Maps (for anonymous key-value pairs) \
 :white_check_mark: Nested Inline Tables \
 :white_check_mark: Array of Tables \
+:white_check_mark: Inline Array of Tables (decoding) \
 :x: Arrays: of Different Types \
-:x: Inline Array of Tables
+:x: Inline Array of Tables (encoding)
 
 ## Dependency
 The library is hosted on the [Maven Central](https://search.maven.org/artifact/com.akuleshov7/ktoml-core).
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 0928b667..e3717add 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
@@ -232,6 +232,15 @@ internal fun String.indexOfNextOutsideQuotes(
     return -1
 }
 
+/**
+ * @param prefix - the string to check for
+ * @return true if the string starts with the prefix, ignoring all whitespaces
+ */
+@Suppress("FUNCTION_BOOLEAN_PREFIX")
+internal fun String.startsWithIgnoreAllWhitespaces(prefix: String): Boolean = this.filterNot {
+    it.isWhitespace()
+}.startsWith(prefix)
+
 /**
  * @param allowEscapedQuotesInLiteralStrings value from TomlInputConfig
  * @param placeholder - the string to replace escaped quotes with
diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/TomlParser.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/TomlParser.kt
index 71d59ecd..80ce17e7 100644
--- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/TomlParser.kt
+++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/TomlParser.kt
@@ -251,8 +251,12 @@ public fun String.parseTomlKeyValue(
 ): TomlNode {
     val keyValuePair = this.splitKeyValue(lineNo, config)
     return when {
-        keyValuePair.second.startsWith("[") -> TomlKeyValueArray(keyValuePair, lineNo, comments, inlineComment, config)
+        keyValuePair.second.startsWithIgnoreAllWhitespaces("[{") ->
+            TomlInlineTable(
+                keyValuePair, lineNo, comments, inlineComment, config
+            )
         keyValuePair.second.startsWith("{") -> TomlInlineTable(keyValuePair, lineNo, comments, inlineComment, config)
+        keyValuePair.second.startsWith("[") -> TomlKeyValueArray(keyValuePair, lineNo, comments, inlineComment, config)
         else -> TomlKeyValuePrimitive(keyValuePair, lineNo, comments, inlineComment, config)
     }
 }
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 d719d72f..a4682d21 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,10 +4,7 @@ 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.parsers.*
 import com.akuleshov7.ktoml.tree.nodes.pairs.keys.TomlKey
 import com.akuleshov7.ktoml.writers.TomlEmitter
 
@@ -42,7 +39,7 @@ public class TomlInlineTable internal constructor(
         config: TomlInputConfig = TomlInputConfig()
     ) : this(
         TomlKey(keyValuePair.first, lineNo),
-        keyValuePair.second.parseInlineTableValue(lineNo, config),
+        keyValuePair.second.parseInlineTableValue(keyValuePair, lineNo, config),
         lineNo,
         comments,
         inlineComment
@@ -66,19 +63,7 @@ public class TomlInlineTable internal constructor(
     )
 
     public fun returnTable(tomlFileHead: TomlFile, currentParentalNode: TomlNode): TomlTable {
-        val tomlTable = TomlTable(
-            TomlKey(
-                if (currentParentalNode is TomlTable) {
-                    currentParentalNode.fullTableKey.keyParts + key.keyParts
-                } else {
-                    listOf(name)
-                }
-            ),
-            lineNo,
-            type = TableType.PRIMITIVE,
-            comments,
-            inlineComment
-        )
+        val tomlTable = createTableRoot(currentParentalNode)
 
         // FixMe: this code duplication can be unified with the logic in TomlParser
         tomlKeyValues.forEach { keyValue ->
@@ -97,9 +82,10 @@ public class TomlInlineTable internal constructor(
                     keyValue.returnTable(tomlFileHead, tomlTable)
                 )
 
+                keyValue is TomlArrayOfTablesElement -> tomlTable.appendChild(keyValue)
+
                 // otherwise, it should simply append the keyValue to the parent
                 else -> tomlTable.appendChild(keyValue)
-
             }
         }
         return tomlTable
@@ -128,11 +114,37 @@ public class TomlInlineTable internal constructor(
             .endInlineTable()
     }
 
+    private fun createTableRoot(currentParentalNode: TomlNode): TomlTable = TomlTable(
+        TomlKey(
+            when (currentParentalNode) {
+                is TomlTable -> currentParentalNode.fullTableKey.keyParts + key.keyParts
+                is TomlArrayOfTablesElement -> (currentParentalNode.parent as TomlTable)
+                    .fullTableKey.keyParts + key.keyParts
+                else -> listOf(name)
+            },
+        ),
+        lineNo,
+        type = if (this.isInlineArrayOfTables()) {
+            TableType.ARRAY
+        } else {
+            TableType.PRIMITIVE
+        },
+        comments,
+        inlineComment
+    )
+
+    private fun isInlineArrayOfTables(): Boolean = tomlKeyValues.any { it is TomlArrayOfTablesElement }
+
     public companion object {
         private fun String.parseInlineTableValue(
+            keyValuePair: Pair<String, String>,
             lineNo: Int,
             config: TomlInputConfig
         ): List<TomlNode> {
+            if (this.startsWithIgnoreAllWhitespaces("[{")) {
+                return parseInlineArrayOfTables(keyValuePair, lineNo, config)
+            }
+
             val inlineTableValueString = this.trimCurlyBraces().trim()
             val parsedList = inlineTableValueString
                 .also {
@@ -146,11 +158,95 @@ public class TomlInlineTable internal constructor(
                     }
                 }
                 .splitInlineTableToKeyValue(config.allowEscapedQuotesInLiteralStrings, lineNo)
-                .map { it.parseTomlKeyValue(lineNo, comments = emptyList(), inlineComment = "", config) }
+                .map {
+                    it.parseTomlKeyValue(lineNo, comments = emptyList(), inlineComment = "", config)
+                }
 
             return parsedList
         }
 
+        private fun String.parseInlineArrayOfTables(
+            keyValuePair: Pair<String, String>,
+            lineNo: Int,
+            config: TomlInputConfig
+        ): List<TomlNode> {
+            val inlineTableValues = this.splitInlineArrayOfTables()
+
+            return inlineTableValues.map { tableValue ->
+                val inlineTableValues = tableValue.parseInlineTableValue(
+                    keyValuePair.first to tableValue.trim(),
+                    lineNo,
+                    config,
+                )
+
+                TomlArrayOfTablesElement(lineNo, emptyList(), "").also { arrayOfTableElement ->
+                    inlineTableValues.forEach { value ->
+                        arrayOfTableElement.appendChild(value)
+                    }
+                }
+            }
+        }
+
+        /**
+         *  Split by "}," - but skip all whitespaces between '}' and ','
+         *  Also ignore characters inside strings and keep closing '}' in each value
+         */
+        @Suppress("TOO_LONG_FUNCTION")
+        private fun String.splitInlineArrayOfTables(): List<String> {
+            val clearedString = this
+                .removePrefix("[")
+                .removeSuffix("]")
+            val result: MutableList<String> = mutableListOf()
+            val current = StringBuilder()
+            var openQuoteChar: Char? = null
+            var isCurlyBracesFound = false
+
+            clearedString.forEach { currentChar ->
+                // currentChar is inside quotes, so just add it
+                if (openQuoteChar != null && currentChar != openQuoteChar) {
+                    current.append(currentChar)
+                    return@forEach
+                }
+
+                when (currentChar) {
+                    openQuoteChar -> {
+                        openQuoteChar = null
+                        current.append(currentChar)
+                    }
+
+                    '\"', '\'' -> {
+                        openQuoteChar = currentChar
+                        current.append(currentChar)
+                    }
+
+                    '}' -> {
+                        isCurlyBracesFound = true
+                        current.append(currentChar)
+                    }
+
+                    ',' -> if (isCurlyBracesFound) {
+                        // comma between inline tables
+                        isCurlyBracesFound = false
+                        result.add(current.toString().trim())
+                        current.clear()
+                    } else {
+                        // comma between inline table values
+                        current.append(currentChar)
+                    }
+
+                    else -> if (!isCurlyBracesFound) {
+                        current.append(currentChar)
+                    }
+                }
+            }
+
+            // 'current' is blank when array has trailing comma
+            if (current.isNotBlank()) {
+                result.add(current.toString().trim())
+            }
+            return result
+        }
+
         /**
          * That's basically split(",") function, but we ignore all commas inside arrays [ ],
          * nested tables { } and quotes " "/' '


Test patch:
diff --git a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/ArrayOfTablesDecoderTest.kt b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/ArrayOfTablesDecoderTest.kt
index 6ad947f3..cd6885af 100644
--- a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/ArrayOfTablesDecoderTest.kt
+++ b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/ArrayOfTablesDecoderTest.kt
@@ -177,4 +177,43 @@ class ArrayOfTablesDecoderTest {
             Toml.decodeFromString<Fruits>(toml),
         )
     }
+
+    @Test
+    fun decodeWithInlineArrayOfTablesInside() {
+        @Serializable
+        data class Option(
+            val name: String,
+            val description: String,
+        )
+        @Serializable
+        data class Command(
+            val name: String,
+            val description: String,
+            val options: List<Option>? = null,
+        )
+        @Serializable
+        data class Commands(
+            val commands: List<Command>,
+        )
+
+        val toml = """
+            [[commands]]
+	            name = "ping"
+	            description = "description"
+	            options = [{ name = "user", description = "descr" }]
+        """.trimIndent()
+
+        assertEquals(
+            Commands(
+                commands = listOf(
+                    Command(
+                        name = "ping",
+                        description = "description",
+                        options = listOf(Option(name = "user", description = "descr"))
+                    )
+                )
+            ),
+            Toml.decodeFromString<Commands>(toml),
+        )
+    }
 }
diff --git a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/InlineArrayOfTableDecoderTest.kt b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/InlineArrayOfTableDecoderTest.kt
new file mode 100644
index 00000000..62ac7c76
--- /dev/null
+++ b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/InlineArrayOfTableDecoderTest.kt
@@ -0,0 +1,211 @@
+package com.akuleshov7.ktoml.decoders
+
+import com.akuleshov7.ktoml.Toml
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class InlineArrayOfTableDecoderTest {
+    @Serializable
+    data class Option(
+        val name: String,
+        val description: String,
+    )
+    @Serializable
+    data class Options(
+        val options: List<Option>,
+    )
+
+    @Test
+    fun decodeInlineArrayOfTableWithSingleElement() {
+        val toml1 = """
+            [[options]]
+            name = "name1"
+            description = "description1"
+        """.trimIndent()
+
+        val toml2 = """
+            options = [{ name = "name1", description = "description1" }]
+        """.trimIndent()
+
+        assertEquals(
+            Toml.decodeFromString<Options>(toml1),
+            Toml.decodeFromString<Options>(toml2),
+        )
+    }
+
+    @Test
+    fun decodeSimpleInlineArrayOfTable() {
+        val toml1 = """
+            [[options]]
+            name = "name1"
+            description = "description1"
+
+            [[options]]
+            name = "name2"
+            description = "description2"
+        """.trimIndent()
+
+        val toml2 = """
+            options = [{ name = "name1", description = "description1" }, { name = "name2", description = "description2" }]
+        """.trimIndent()
+
+        assertEquals(
+            Toml.decodeFromString<Options>(toml1),
+            Toml.decodeFromString<Options>(toml2),
+        )
+    }
+
+    @Test
+    fun decodeTomlWithExtraSpaces() {
+        var toml1 = """
+            [[options]]
+            name = "name1"
+            description = "description1"
+        """.trimIndent()
+
+        var toml2 = """
+            options = [     {   name = "name1",     description = "description1"}    ]
+        """.trimIndent()
+
+        assertEquals(
+            Toml.decodeFromString<Options>(toml1),
+            Toml.decodeFromString<Options>(toml2),
+        )
+
+        toml1 = """
+            [[options]]
+            name = "name1"
+            description = "description1"
+
+            [[options]]
+            name = "name2"
+            description = "description2"
+        """.trimIndent()
+
+        toml2 = """
+            options = [  { name = "name1", description = "description1" },   {   name = "name2", description = "description2" }  ]
+        """.trimIndent()
+
+        assertEquals(
+            Toml.decodeFromString<Options>(toml1),
+            Toml.decodeFromString<Options>(toml2),
+        )
+    }
+
+    @Serializable
+    data class Point(
+        val x: Int,
+        val y: Int,
+        val z: Int,
+    )
+    @Serializable
+    data class Points(
+        val points: List<Point>,
+    )
+
+    @Test
+    fun decodeMultilineInlineArrayOfTable() {
+        val toml = """
+            points = [  { x = 1, y = 2, z = 3 },
+                        { x = 7, y = 8, z = 9 },
+                        { x = 2, y = 4, z = 8 }
+                     ]
+        """.trimIndent()
+
+        assertEquals(
+            Points(
+                points = listOf(
+                    Point(1, 2, 3),
+                    Point(7, 8, 9),
+                    Point(2, 4, 8),
+                ),
+            ),
+            Toml.decodeFromString<Points>(toml),
+        )
+    }
+
+    @Test
+    fun decodeMultilineWithTrailingComma() {
+        val toml = """
+            points = [  { x = 1, y = 2, z = 3 },
+                        { x = 7, y = 8, z = 9 } ,
+                        { x = 2, y = 4, z = 8 },
+                     ]
+        """.trimIndent()
+
+        assertEquals(
+            Points(
+                points = listOf(
+                    Point(1, 2, 3),
+                    Point(7, 8, 9),
+                    Point(2, 4, 8),
+                ),
+            ),
+            Toml.decodeFromString<Points>(toml),
+        )
+    }
+
+    @Test
+    fun decodeWithArrayElements() {
+        @Serializable
+        data class Point(
+            val points: List<Int>,
+        )
+        @Serializable
+        data class Value(
+            val value: List<Point>,
+        )
+
+        val toml = """
+            value = [  { points = [1, 2] },
+                       { points = [3, 4] }]
+        """.trimIndent()
+        assertEquals(
+            Value(
+                value = listOf(
+                    Point(points = listOf(1, 2)),
+                    Point(points = listOf(3, 4)),
+                ),
+            ),
+            Toml.decodeFromString<Value>(toml),
+        )
+    }
+
+    @Test
+    fun decodeInsideOtherTable() {
+        @Serializable
+        data class Option(
+            val name: String,
+            val description: String,
+        )
+        @Serializable
+        data class Command(
+            val name: String,
+            val description: String,
+            val options: List<Option>? = null,
+        )
+        @Serializable
+        data class Commands(
+            val command: Command,
+        )
+
+        val toml = """
+            [command]
+	            name = "ping"
+	            description = "description"
+	            options = [{ name = "user", description = "descr" }]
+        """.trimIndent()
+        assertEquals(
+            Commands(
+                command = Command(
+                    name = "ping",
+                    description = "description",
+                    options = listOf(Option(name = "user", description = "descr"))
+                )
+            ),
+            Toml.decodeFromString<Commands>(toml),
+        )
+    }
+}
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 9b28beea..ced36d3a 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
@@ -122,7 +122,12 @@ class InlineTableDecoderTest {
             """.trimMargin()
 
         val result = Toml.decodeFromString<TableWithArrayWrapper>(test)
-        println(result)
+        assertEquals(
+            TableWithArrayWrapper(
+                table = TableWithArray(arr = listOf(1, 2, 3))
+            ),
+            result
+        )
     }
 
     @Test