diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/KtomlConf.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/KtomlConf.kt index 78ca0aa4..69621755 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/KtomlConf.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/KtomlConf.kt @@ -1,12 +1,27 @@ package com.akuleshov7.ktoml +import com.akuleshov7.ktoml.KtomlConf.Indentation.FOUR_SPACES + /** * @property ignoreUnknownNames - a control to allow/prohibit unknown names during the deserialization * @property emptyValuesAllowed - a control to allow/prohibit empty values: a = # comment * @property escapedQuotesInLiteralStringsAllowed - a control to allow/prohibit escaping of single quotes in literal strings + * @property indentation */ public data class KtomlConf( val ignoreUnknownNames: Boolean = false, val emptyValuesAllowed: Boolean = true, - val escapedQuotesInLiteralStringsAllowed: Boolean = true -) + val escapedQuotesInLiteralStringsAllowed: Boolean = true, + val indentation: Indentation = FOUR_SPACES, +) { + /** + * @property value + */ + public enum class Indentation(public val value: String) { + FOUR_SPACES(" "), + NONE(""), + TAB("\t"), + TWO_SPACES(" "), + ; + } +} diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/Toml.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/Toml.kt index 2e330a06..fcd619ad 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/Toml.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/Toml.kt @@ -3,10 +3,16 @@ package com.akuleshov7.ktoml import com.akuleshov7.ktoml.decoders.TomlMainDecoder import com.akuleshov7.ktoml.exceptions.MissingRequiredFieldException import com.akuleshov7.ktoml.parsers.TomlParser -import com.akuleshov7.ktoml.parsers.node.TomlFile +import com.akuleshov7.ktoml.tree.TomlFile +import com.akuleshov7.ktoml.writers.TomlWriter import kotlin.native.concurrent.ThreadLocal -import kotlinx.serialization.* + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.StringFormat + import kotlinx.serialization.modules.EmptySerializersModule import kotlinx.serialization.modules.SerializersModule @@ -22,8 +28,10 @@ public open class Toml( private val config: KtomlConf = KtomlConf(), override val serializersModule: SerializersModule = EmptySerializersModule ) : StringFormat { - // parser is created once after the creation of the class, to reduce the number of created parsers for each toml + // parser and writer are created once after the creation of the class, to reduce + // the number of created parsers and writers for each toml public val tomlParser: TomlParser = TomlParser(config) + public val tomlWriter: TomlWriter = TomlWriter(config) // ================== basic overrides =============== diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlAbstractDecoder.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlAbstractDecoder.kt index dd8754cd..39541c01 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlAbstractDecoder.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlAbstractDecoder.kt @@ -2,7 +2,7 @@ package com.akuleshov7.ktoml.decoders import com.akuleshov7.ktoml.exceptions.IllegalTomlTypeException import com.akuleshov7.ktoml.exceptions.TomlCastException -import com.akuleshov7.ktoml.parsers.node.TomlKeyValue +import com.akuleshov7.ktoml.tree.TomlKeyValue import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.encoding.AbstractDecoder diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlArrayDecoder.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlArrayDecoder.kt index f1e19a17..d0ffdc8b 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlArrayDecoder.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlArrayDecoder.kt @@ -1,11 +1,11 @@ package com.akuleshov7.ktoml.decoders import com.akuleshov7.ktoml.KtomlConf -import com.akuleshov7.ktoml.parsers.node.TomlKeyValue -import com.akuleshov7.ktoml.parsers.node.TomlKeyValueArray -import com.akuleshov7.ktoml.parsers.node.TomlKeyValuePrimitive -import com.akuleshov7.ktoml.parsers.node.TomlNull -import com.akuleshov7.ktoml.parsers.node.TomlValue +import com.akuleshov7.ktoml.tree.TomlKeyValue +import com.akuleshov7.ktoml.tree.TomlKeyValueArray +import com.akuleshov7.ktoml.tree.TomlKeyValuePrimitive +import com.akuleshov7.ktoml.tree.TomlNull +import com.akuleshov7.ktoml.tree.TomlValue import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.CompositeDecoder diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMainDecoder.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMainDecoder.kt index 49ade972..fc788428 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMainDecoder.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMainDecoder.kt @@ -4,11 +4,14 @@ package com.akuleshov7.ktoml.decoders import com.akuleshov7.ktoml.KtomlConf import com.akuleshov7.ktoml.exceptions.* -import com.akuleshov7.ktoml.parsers.node.* -import kotlinx.serialization.* -import kotlinx.serialization.descriptors.* -import kotlinx.serialization.encoding.* -import kotlinx.serialization.modules.* +import com.akuleshov7.ktoml.tree.* +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule /** * Main entry point into the decoding process. It can create less common decoders inside, for example: diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlPrimitiveDecoder.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlPrimitiveDecoder.kt index bd6e0c32..b9a35aa4 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlPrimitiveDecoder.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlPrimitiveDecoder.kt @@ -1,7 +1,7 @@ package com.akuleshov7.ktoml.decoders -import com.akuleshov7.ktoml.parsers.node.TomlKeyValue -import com.akuleshov7.ktoml.parsers.node.TomlKeyValuePrimitive +import com.akuleshov7.ktoml.tree.TomlKeyValue +import com.akuleshov7.ktoml.tree.TomlKeyValuePrimitive import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.modules.EmptySerializersModule diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/Exceptions.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/Exceptions.kt index 006c23e4..d3b04735 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/Exceptions.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/Exceptions.kt @@ -10,6 +10,8 @@ public sealed class KtomlException(message: String) : Exception(message) internal class TomlParsingException(message: String, lineNo: Int) : KtomlException("Line $lineNo: $message") +internal class TomlWritingException(message: String) : KtomlException(message) + internal class InternalDecodingException(message: String) : KtomlException(message) internal class InternalAstException(message: String) : KtomlException(message) 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 8a11f8fa..a70f51c0 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 @@ -2,14 +2,14 @@ package com.akuleshov7.ktoml.parsers import com.akuleshov7.ktoml.KtomlConf import com.akuleshov7.ktoml.exceptions.InternalAstException -import com.akuleshov7.ktoml.parsers.node.TomlFile -import com.akuleshov7.ktoml.parsers.node.TomlKeyValue -import com.akuleshov7.ktoml.parsers.node.TomlKeyValueArray -import com.akuleshov7.ktoml.parsers.node.TomlKeyValuePrimitive -import com.akuleshov7.ktoml.parsers.node.TomlNode -import com.akuleshov7.ktoml.parsers.node.TomlStubEmptyNode -import com.akuleshov7.ktoml.parsers.node.TomlTable -import com.akuleshov7.ktoml.parsers.node.splitKeyValue +import com.akuleshov7.ktoml.tree.TomlFile +import com.akuleshov7.ktoml.tree.TomlKeyValue +import com.akuleshov7.ktoml.tree.TomlKeyValueArray +import com.akuleshov7.ktoml.tree.TomlKeyValuePrimitive +import com.akuleshov7.ktoml.tree.TomlNode +import com.akuleshov7.ktoml.tree.TomlStubEmptyNode +import com.akuleshov7.ktoml.tree.TomlTable +import com.akuleshov7.ktoml.tree.splitKeyValue /** * @property ktomlConf - object that stores configuration options for a parser diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlValue.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlValue.kt deleted file mode 100644 index 19d3e06b..00000000 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlValue.kt +++ /dev/null @@ -1,232 +0,0 @@ -/** - * All representations of TOML value nodes are stored in this file - */ - -package com.akuleshov7.ktoml.parsers.node - -import com.akuleshov7.ktoml.KtomlConf -import com.akuleshov7.ktoml.exceptions.TomlParsingException -import com.akuleshov7.ktoml.parsers.trimBrackets -import com.akuleshov7.ktoml.parsers.trimQuotes -import com.akuleshov7.ktoml.parsers.trimSingleQuotes - -/** - * Base class for all nodes that represent values - * @property lineNo - line number of original file - */ -public sealed class TomlValue(public val lineNo: Int) { - public abstract var content: Any -} - -/** - * Toml AST Node for a representation of literal string values: key = 'value' (with single quotes and no escaped symbols) - * The only difference from the TOML specification (https://toml.io/en/v1.0.0) is that we will have one escaped symbol - - * single quote and so it will be possible to use a single quote inside. - */ -public class TomlLiteralString( - content: String, - lineNo: Int, - ktomlConf: KtomlConf = KtomlConf()) : TomlValue(lineNo) { - override var content: Any = if (content.startsWith("'") && content.endsWith("'")) { - val contentString = content.trimSingleQuotes() - if (ktomlConf.escapedQuotesInLiteralStringsAllowed) contentString.convertSingleQuotes() else contentString - } else { - throw TomlParsingException( - "Literal string should be wrapped with single quotes (''), it looks that you have forgotten" + - " the single quote in the end of the following string: <$content>", lineNo - ) - } - - /** - * According to the TOML standard (https://toml.io/en/v1.0.0#string) single quote is prohibited. - * But in ktoml we don't see any reason why we cannot escape it. Anyway, by the TOML specification we should fail, so - * why not to try to handle this situation at least somehow. - * - * Conversion is done after we have trimmed technical quotes and won't break cases when the user simply used a backslash - * as the last symbol (single quote) will be removed. - */ - private fun String.convertSingleQuotes(): String = this.replace("\\'", "'") -} - -/** - * Toml AST Node for a representation of string values: key = "value" (always should have quotes due to TOML standard) - */ -public class TomlBasicString(content: String, lineNo: Int) : TomlValue(lineNo) { - override var content: Any = if (content.startsWith("\"") && content.endsWith("\"")) { - content.trimQuotes() - .checkOtherQuotesAreEscaped() - .convertSpecialCharacters() - } else { - throw TomlParsingException( - "According to the TOML specification string values (even Enums)" + - " should be wrapped (start and end) with quotes (\"\"), but the following value was not: <$content>." + - " Please note that multiline strings are not yet supported.", - lineNo - ) - } - - private fun String.checkOtherQuotesAreEscaped(): String { - this.forEachIndexed { index, ch -> - if (ch == '\"' && (index == 0 || this[index - 1] != '\\')) { - throw TomlParsingException( - "Found invalid quote that is not escaped." + - " Please remove the quote or use escaping" + - " in <$this> at position = [$index].", lineNo - ) - } - } - return this - } - - private fun String.convertSpecialCharacters(): String { - val resultString = StringBuilder() - var updatedOnPreviousStep = false - var i = 0 - while (i < this.length) { - val newCharacter = if (this[i] == '\\' && i != this.length - 1) { - updatedOnPreviousStep = true - when (this[i + 1]) { - // table that is used to convert escaped string literals to proper char symbols - 't' -> '\t' - 'b' -> '\b' - 'r' -> '\r' - 'n' -> '\n' - '\\' -> '\\' - '\'' -> '\'' - '"' -> '"' - else -> throw TomlParsingException( - "According to TOML documentation unknown" + - " escape symbols are not allowed. Please check: [\\${this[i + 1]}]", - lineNo - ) - } - } else { - this[i] - } - // need to skip the next character if we have processed special escaped symbol - if (updatedOnPreviousStep) { - updatedOnPreviousStep = false - i += 2 - } else { - i += 1 - } - - resultString.append(newCharacter) - } - return resultString.toString() - } -} - -/** - * Toml AST Node for a representation of Arbitrary 64-bit signed integers: key = 1 - */ -public class TomlLong(content: String, lineNo: Int) : TomlValue(lineNo) { - override var content: Any = content.toLong() -} - -/** - * Toml AST Node for a representation of float types: key = 1.01. - * Toml specification requires floating point numbers to be IEEE 754 binary64 values, - * so it should be Kotlin Double (64 bits) - */ -public class TomlDouble(content: String, lineNo: Int) : TomlValue(lineNo) { - override var content: Any = content.toDouble() -} - -/** - * Toml AST Node for a representation of boolean types: key = true | false - */ -public class TomlBoolean(content: String, lineNo: Int) : TomlValue(lineNo) { - override var content: Any = content.toBoolean() -} - -/** - * Toml AST Node for a representation of null: - * null, nil, NULL, NIL or empty (key = ) - */ -public class TomlNull(lineNo: Int) : TomlValue(lineNo) { - override var content: Any = "null" -} - -/** - * Toml AST Node for a representation of arrays: key = [value1, value2, value3] - */ -public class TomlArray( - private val rawContent: String, - lineNo: Int, - ktomlConf: KtomlConf = KtomlConf() -) : TomlValue(lineNo) { - override lateinit var content: Any - - init { - validateBrackets() - this.content = parse() - } - - /** - * small adaptor to make proper testing of parsing - * - * @param ktomlConf - * @return converted array to a list - */ - public fun parse(ktomlConf: KtomlConf = KtomlConf()): List = rawContent.parse(ktomlConf) - - /** - * recursively parse TOML array from the string: [ParsingArray -> Trimming values -> Parsing Nested Arrays] - */ - private fun String.parse(ktomlConf: KtomlConf = KtomlConf()): List = - this.parseArray() - .map { it.trim() } - .map { if (it.startsWith("[")) it.parse(ktomlConf) else it.parseValue(lineNo, ktomlConf) } - - /** - * method for splitting the string to the array: "[[a, b], [c], [d]]" to -> [a,b] [c] [d] - */ - @Suppress("TOO_MANY_LINES_IN_LAMBDA") - private fun String.parseArray(): MutableList { - // covering cases when the array is intentionally blank: myArray = []. It should be empty and not contain null - if (this.trimBrackets().isBlank()) { - return mutableListOf() - } - - var numberOfOpenBrackets = 0 - var numberOfClosedBrackets = 0 - var bufferBetweenCommas = StringBuilder() - val result: MutableList = mutableListOf() - - this.trimBrackets().forEach { - when (it) { - '[' -> { - numberOfOpenBrackets++ - bufferBetweenCommas.append(it) - } - ']' -> { - numberOfClosedBrackets++ - bufferBetweenCommas.append(it) - } - // split only if we are on the highest level of brackets (all brackets are closed) - ',' -> if (numberOfClosedBrackets == numberOfOpenBrackets) { - result.add(bufferBetweenCommas.toString()) - bufferBetweenCommas = StringBuilder() - } else { - bufferBetweenCommas.append(it) - } - else -> bufferBetweenCommas.append(it) - } - } - result.add(bufferBetweenCommas.toString()) - return result - } - - /** - * small validation for quotes: each quote should be closed in a key - */ - private fun validateBrackets() { - if (rawContent.count { it == '\"' } % 2 != 0 || rawContent.count { it == '\'' } % 2 != 0) { - throw TomlParsingException( - "Not able to parse the key: [$rawContent] as it does not have closing bracket", - lineNo - ) - } - } -} diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlKey.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlKey.kt similarity index 96% rename from ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlKey.kt rename to ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlKey.kt index b6c2981d..90bbc645 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlKey.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlKey.kt @@ -1,4 +1,4 @@ -package com.akuleshov7.ktoml.parsers.node +package com.akuleshov7.ktoml.tree import com.akuleshov7.ktoml.parsers.splitKeyToTokens import com.akuleshov7.ktoml.parsers.trimQuotes diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlKeyValue.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlKeyValue.kt similarity index 99% rename from ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlKeyValue.kt rename to ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlKeyValue.kt index e6f3a2bc..b51c8efd 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlKeyValue.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlKeyValue.kt @@ -1,4 +1,4 @@ -package com.akuleshov7.ktoml.parsers.node +package com.akuleshov7.ktoml.tree import com.akuleshov7.ktoml.KtomlConf import com.akuleshov7.ktoml.exceptions.TomlParsingException diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlNode.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlNode.kt similarity index 99% rename from ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlNode.kt rename to ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlNode.kt index 1742e6e5..482a2cf6 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/parsers/node/TomlNode.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlNode.kt @@ -2,7 +2,7 @@ * File contains all classes used in Toml AST node */ -package com.akuleshov7.ktoml.parsers.node +package com.akuleshov7.ktoml.tree import com.akuleshov7.ktoml.KtomlConf import com.akuleshov7.ktoml.exceptions.InternalAstException diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlValue.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlValue.kt new file mode 100644 index 00000000..19326d53 --- /dev/null +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/tree/TomlValue.kt @@ -0,0 +1,279 @@ +/** + * All representations of TOML value nodes are stored in this file + */ + +package com.akuleshov7.ktoml.tree + +import com.akuleshov7.ktoml.KtomlConf +import com.akuleshov7.ktoml.exceptions.TomlParsingException +import com.akuleshov7.ktoml.parsers.trimBrackets +import com.akuleshov7.ktoml.parsers.trimQuotes +import com.akuleshov7.ktoml.parsers.trimSingleQuotes + +/** + * Base class for all nodes that represent values + * @property lineNo - line number of original file + */ +public sealed class TomlValue(public val lineNo: Int) { + public abstract var content: Any +} + +/** + * Toml AST Node for a representation of literal string values: key = 'value' (with single quotes and no escaped symbols) + * The only difference from the TOML specification (https://toml.io/en/v1.0.0) is that we will have one escaped symbol - + * single quote and so it will be possible to use a single quote inside. + * @property content + */ +public class TomlLiteralString +internal constructor( + override var content: Any, + lineNo: Int +) : TomlValue(lineNo) { + public constructor( + content: String, + lineNo: Int, + ktomlConf: KtomlConf = KtomlConf() + ) : this(content.verifyAndTrimQuotes(lineNo, ktomlConf), lineNo) + + public companion object { + private fun String.verifyAndTrimQuotes(lineNo: Int, ktomlConf: KtomlConf): Any = + if (startsWith("'") && endsWith("'")) { + val contentString = trimSingleQuotes() + if (ktomlConf.escapedQuotesInLiteralStringsAllowed) contentString.convertSingleQuotes() else contentString + } else { + throw TomlParsingException( + "Literal string should be wrapped with single quotes (''), it looks that you have forgotten" + + " the single quote in the end of the following string: <$this>", lineNo + ) + } + + /** + * According to the TOML standard (https://toml.io/en/v1.0.0#string) single quote is prohibited. + * But in ktoml we don't see any reason why we cannot escape it. Anyway, by the TOML specification we should fail, so + * why not to try to handle this situation at least somehow. + * + * Conversion is done after we have trimmed technical quotes and won't break cases when the user simply used a backslash + * as the last symbol (single quote) will be removed. + */ + private fun String.convertSingleQuotes(): String = this.replace("\\'", "'") + } +} + +/** + * Toml AST Node for a representation of string values: key = "value" (always should have quotes due to TOML standard) + * @property content + */ +public class TomlBasicString +internal constructor( + override var content: Any, + lineNo: Int +) : TomlValue(lineNo) { + public constructor( + content: String, + lineNo: Int + ) : this(content.verifyAndTrimQuotes(lineNo), lineNo) + + public companion object { + private fun String.verifyAndTrimQuotes(lineNo: Int): Any = + if (startsWith("\"") && endsWith("\"")) { + trimQuotes() + .checkOtherQuotesAreEscaped(lineNo) + .convertSpecialCharacters(lineNo) + } else { + throw TomlParsingException( + "According to the TOML specification string values (even Enums)" + + " should be wrapped (start and end) with quotes (\"\"), but the following value was not: <$this>." + + " Please note that multiline strings are not yet supported.", + lineNo + ) + } + + private fun String.checkOtherQuotesAreEscaped(lineNo: Int): String { + this.forEachIndexed { index, ch -> + if (ch == '\"' && (index == 0 || this[index - 1] != '\\')) { + throw TomlParsingException( + "Found invalid quote that is not escaped." + + " Please remove the quote or use escaping" + + " in <$this> at position = [$index].", lineNo + ) + } + } + return this + } + + private fun String.convertSpecialCharacters(lineNo: Int): String { + val resultString = StringBuilder() + var updatedOnPreviousStep = false + var i = 0 + while (i < this.length) { + val newCharacter = if (this[i] == '\\' && i != this.length - 1) { + updatedOnPreviousStep = true + when (this[i + 1]) { + // table that is used to convert escaped string literals to proper char symbols + 't' -> '\t' + 'b' -> '\b' + 'r' -> '\r' + 'n' -> '\n' + '\\' -> '\\' + '\'' -> '\'' + '"' -> '"' + else -> throw TomlParsingException( + "According to TOML documentation unknown" + + " escape symbols are not allowed. Please check: [\\${this[i + 1]}]", + lineNo + ) + } + } else { + this[i] + } + // need to skip the next character if we have processed special escaped symbol + if (updatedOnPreviousStep) { + updatedOnPreviousStep = false + i += 2 + } else { + i += 1 + } + + resultString.append(newCharacter) + } + return resultString.toString() + } + } +} + +/** + * Toml AST Node for a representation of Arbitrary 64-bit signed integers: key = 1 + * @property content + */ +public class TomlLong +internal constructor( + override var content: Any, + lineNo: Int +) : TomlValue(lineNo) { + public constructor(content: String, lineNo: Int) : this(content.toLong(), lineNo) +} + +/** + * Toml AST Node for a representation of float types: key = 1.01. + * Toml specification requires floating point numbers to be IEEE 754 binary64 values, + * so it should be Kotlin Double (64 bits) + * @property content + */ +public class TomlDouble +internal constructor( + override var content: Any, + lineNo: Int +) : TomlValue(lineNo) { + public constructor(content: String, lineNo: Int) : this(content.toDouble(), lineNo) +} + +/** + * Toml AST Node for a representation of boolean types: key = true | false + * @property content + */ +public class TomlBoolean +internal constructor( + override var content: Any, + lineNo: Int +) : TomlValue(lineNo) { + public constructor(content: String, lineNo: Int) : this(content.toBoolean(), lineNo) +} + +/** + * Toml AST Node for a representation of null: + * null, nil, NULL, NIL or empty (key = ) + */ +public class TomlNull(lineNo: Int) : TomlValue(lineNo) { + override var content: Any = "null" +} + +/** + * Toml AST Node for a representation of arrays: key = [value1, value2, value3] + * @property content + */ +public class TomlArray +internal constructor( + override var content: Any, + private val rawContent: String, + lineNo: Int +) : TomlValue(lineNo) { + public constructor( + rawContent: String, + lineNo: Int, + ktomlConf: KtomlConf = KtomlConf() + ) : this( + rawContent.parse(lineNo, ktomlConf), + rawContent, + lineNo) { + validateBrackets() + } + + /** + * small adaptor to make proper testing of parsing + * + * @param ktomlConf + * @return converted array to a list + */ + public fun parse(ktomlConf: KtomlConf = KtomlConf()): List = rawContent.parse(lineNo, ktomlConf) + + /** + * small validation for quotes: each quote should be closed in a key + */ + private fun validateBrackets() { + if (rawContent.count { it == '\"' } % 2 != 0 || rawContent.count { it == '\'' } % 2 != 0) { + throw TomlParsingException( + "Not able to parse the key: [$rawContent] as it does not have closing bracket", + lineNo + ) + } + } + + public companion object { + /** + * recursively parse TOML array from the string: [ParsingArray -> Trimming values -> Parsing Nested Arrays] + */ + private fun String.parse(lineNo: Int, ktomlConf: KtomlConf = KtomlConf()): List = + this.parseArray() + .map { it.trim() } + .map { if (it.startsWith("[")) it.parse(lineNo, ktomlConf) else it.parseValue(lineNo, ktomlConf) } + + /** + * method for splitting the string to the array: "[[a, b], [c], [d]]" to -> [a,b] [c] [d] + */ + @Suppress("TOO_MANY_LINES_IN_LAMBDA") + private fun String.parseArray(): MutableList { + // covering cases when the array is intentionally blank: myArray = []. It should be empty and not contain null + if (this.trimBrackets().isBlank()) { + return mutableListOf() + } + + var numberOfOpenBrackets = 0 + var numberOfClosedBrackets = 0 + var bufferBetweenCommas = StringBuilder() + val result: MutableList = mutableListOf() + + this.trimBrackets().forEach { + when (it) { + '[' -> { + numberOfOpenBrackets++ + bufferBetweenCommas.append(it) + } + ']' -> { + numberOfClosedBrackets++ + bufferBetweenCommas.append(it) + } + // split only if we are on the highest level of brackets (all brackets are closed) + ',' -> if (numberOfClosedBrackets == numberOfOpenBrackets) { + result.add(bufferBetweenCommas.toString()) + bufferBetweenCommas = StringBuilder() + } else { + bufferBetweenCommas.append(it) + } + else -> bufferBetweenCommas.append(it) + } + } + result.add(bufferBetweenCommas.toString()) + return result + } + } +} diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/writers/IntegerRepresentation.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/writers/IntegerRepresentation.kt new file mode 100644 index 00000000..0a17522b --- /dev/null +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/writers/IntegerRepresentation.kt @@ -0,0 +1,19 @@ +package com.akuleshov7.ktoml.writers + +/** + * How a TOML integer should be represented during encoding. + * + * @property BINARY A binary number prefixed with `0b`. + * @property DECIMAL A decimal number. + * @property GROUPED A grouped decimal number, such as `1_000_000`. Todo: Add support. + * @property HEX A hexadecimal number prefixed with `0x`. + * @property OCTAL An octal number prefixed with `0o`. + */ +public enum class IntegerRepresentation { + BINARY, + DECIMAL, + GROUPED, + HEX, + OCTAL, + ; +} diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/writers/TomlEmitter.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/writers/TomlEmitter.kt new file mode 100644 index 00000000..e48e1564 --- /dev/null +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/writers/TomlEmitter.kt @@ -0,0 +1,250 @@ +package com.akuleshov7.ktoml.writers + +import com.akuleshov7.ktoml.KtomlConf +import com.akuleshov7.ktoml.writers.IntegerRepresentation.BINARY +import com.akuleshov7.ktoml.writers.IntegerRepresentation.DECIMAL +import com.akuleshov7.ktoml.writers.IntegerRepresentation.GROUPED +import com.akuleshov7.ktoml.writers.IntegerRepresentation.HEX +import com.akuleshov7.ktoml.writers.IntegerRepresentation.OCTAL +import kotlin.jvm.JvmStatic + +/** + * Abstracts the specifics of writing TOML files into "emit" operations. + */ +public abstract class TomlEmitter(ktomlConf: KtomlConf) { + private val indentation = ktomlConf.indentation.value + + /** + * The current indent depth, set by [indent] and [dedent]. + */ + @Suppress("CUSTOM_GETTERS_SETTERS") + public var indentDepth: Int = 0 + protected set + + /** + * Increments [indentDepth], returning its value after incrementing. + * + * @return The new indent depth. + */ + public fun indent(): Int = ++indentDepth + + /** + * Decrements [indentDepth], returning its value after decrementing. + * + * @return The new indent depth. + */ + public fun dedent(): Int = --indentDepth + + /** + * Emits [fragment] as a raw [String] to the output. + * + * @param fragment The raw text to write to the output. + */ + protected abstract fun emit(fragment: String) + + /** + * Emits [fragment] as a raw [Char] to the output. + * + * @param fragment The raw text to write to the output. + */ + protected abstract fun emit(fragment: Char) + + /** + * Emits a newline character. + */ + public fun emitNewLine(): Unit = emit('\n') + + /** + * Emits indentation up to the current [indentDepth]. + */ + public fun emitIndent(): Unit = repeat(indentDepth) { emit(indentation) } + + /** + * Emits [count] whitespace characters. + * + * @param count The number of whitespace characters to write. + */ + public fun emitWhitespace(count: Int = 1): Unit = repeat(count) { emit(' ') } + + /** + * Emits a [comment], optionally making it end-of-line. + * + * @param comment + * @param endOfLine Whether the comment is at the end of a line, e.g. after a + * table header. + */ + public fun emitComment(comment: String, endOfLine: Boolean = false) { + emit(if (endOfLine) " # " else "# ") + + emit(comment) + } + + /** + * Emits a [key]. Its type is inferred by its content, with bare keys being + * preferred. [emitBareKey] is called for simple keys, [emitQuotedKey] for + * non-simple keys. + * + * @param key + */ + public fun emitKey(key: String) { + if (key matches bareKeyRegex) { + emitBareKey(key) + } else { + emitQuotedKey(key, isLiteral = key matches literalKeyCandidateRegex) + } + } + + /** + * Emits a [key] as a bare key. + * + * @param key + */ + public fun emitBareKey(key: String): Unit = emit(key) + + /** + * Emits a [key] as a quoted key, optionally making it literal (single-quotes). + * + * @param key + * @param isLiteral Whether the key should be emitted as a literal string + * (single-quotes). + */ + public fun emitQuotedKey(key: String, isLiteral: Boolean = false): Unit = + emitValue(string = key, isLiteral) + + /** + * Emits a key separator. + */ + public fun emitKeyDot(): Unit = emit('.') + + /** + * Emits the table header start character. + */ + public fun startTableHeader(): Unit = emit('[') + + /** + * Emits the table header end character. + */ + public fun endTableHeader(): Unit = emit(']') + + /** + * Emits the table array header start characters. + */ + public fun emitTableArrayHeaderStart(): Unit = emit("[[") + + /** + * Emits the table array header end characters. + */ + public fun emitTableArrayHeaderEnd(): Unit = emit("]]") + + /** + * Emit a string value, optionally making it literal and/or multiline. + * + * @param string + * @param isLiteral Whether the string is literal (single-quotes). + * @param isMultiline Whether the string is multiline. + */ + public fun emitValue( + string: String, + isLiteral: Boolean = false, + isMultiline: Boolean = false): Unit = + if (isMultiline) { + val quotes = if (isLiteral) "'''" else "\"\"\"" + + emit(quotes) + emitNewLine() + emit(string) + emit(quotes) + } else { + val quote = if (isLiteral) '\'' else '"' + + emit(quote) + emit(string) + emit(quote) + } + + /** + * Emits an integer value, optionally changing its representation from decimal. + * + * @param integer + * @param representation How the integer will be represented in TOML. + */ + public fun emitValue(integer: Long, representation: IntegerRepresentation = DECIMAL): Unit = + when (representation) { + DECIMAL -> emit(integer.toString()) + HEX -> { + emit("0x") + emit(integer.toString(16)) + } + BINARY -> { + emit("0b") + emit(integer.toString(2)) + } + OCTAL -> { + emit("0o") + emit(integer.toString(8)) + } + GROUPED -> TODO() + } + + /** + * Emits a floating-point value. + * + * @param float + */ + public fun emitValue(float: Double): Unit = + emit(when (float) { + Double.NaN -> "nan" + Double.POSITIVE_INFINITY -> "inf" + Double.NEGATIVE_INFINITY -> "-inf" + else -> float.toString() + }) + + /** + * Emits a boolean value. + * + * @param boolean + */ + public fun emitValue(boolean: Boolean): Unit = emit(boolean.toString()) + + /** + * Emits the array start character. + */ + public fun startArray(): Unit = emit('[') + + /** + * Emits the array end character. + */ + public fun endArray(): Unit = emit(']') + + /** + * Emits the inline table start character. + */ + public fun startInlineTable(): Unit = emit('{') + + /** + * Emits the inline table end character. + */ + public fun endInlineTable(): Unit = emit('}') + + /** + * Emits an array/inline table element delimiter. + */ + public fun emitElementDelimiter(): Unit = emit(", ") + + /** + * Emits a key-value delimiter. + */ + public fun emitPairDelimiter(): Unit = emit(" = ") + + public companion object { + @JvmStatic + private val bareKeyRegex = Regex("[A-Za-z0-9_-]+") + + /** + * Matches a key with at least one unescaped double quote and no single + * quotes. + */ + @JvmStatic + private val literalKeyCandidateRegex = Regex("""[^'"]*((? + throw TomlWritingException( + "A file node is not allowed as a child of another file node." + ) + is TomlKeyValueArray -> TODO() + is TomlKeyValuePrimitive -> TODO() + is TomlStubEmptyNode -> TODO() + is TomlTable -> TODO() + } + + private fun TomlEmitter.writeKey(key: TomlKey) { + val keys = key.keyParts + + if (keys.isEmpty()) { + emitQuotedKey("") + + return + } + + emitKey(keys[0]) + + // Todo: Use an iterator instead of creating a new list. + keys.drop(1).forEach { + emitKeyDot() + emitKey(it) + } + } +} diff --git a/ktoml-core/src/commonTest/kotlin/node/parser/CommonParserTest.kt b/ktoml-core/src/commonTest/kotlin/node/parser/CommonParserTest.kt index b6373303..41e5790a 100644 --- a/ktoml-core/src/commonTest/kotlin/node/parser/CommonParserTest.kt +++ b/ktoml-core/src/commonTest/kotlin/node/parser/CommonParserTest.kt @@ -1,7 +1,7 @@ package node.parser -import com.akuleshov7.ktoml.parsers.node.TomlArray -import com.akuleshov7.ktoml.parsers.node.TomlBasicString +import com.akuleshov7.ktoml.tree.TomlArray +import com.akuleshov7.ktoml.tree.TomlBasicString import kotlin.test.Test import kotlin.test.assertEquals diff --git a/ktoml-core/src/commonTest/kotlin/node/parser/DottedKeyParserTest.kt b/ktoml-core/src/commonTest/kotlin/node/parser/DottedKeyParserTest.kt index 479a1d10..09e0596b 100644 --- a/ktoml-core/src/commonTest/kotlin/node/parser/DottedKeyParserTest.kt +++ b/ktoml-core/src/commonTest/kotlin/node/parser/DottedKeyParserTest.kt @@ -2,7 +2,9 @@ package com.akuleshov7.ktoml.test.node.parser import com.akuleshov7.ktoml.KtomlConf import com.akuleshov7.ktoml.exceptions.TomlParsingException -import com.akuleshov7.ktoml.parsers.node.* +import com.akuleshov7.ktoml.tree.TomlFile +import com.akuleshov7.ktoml.tree.TomlKey +import com.akuleshov7.ktoml.tree.TomlKeyValuePrimitive import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/ktoml-core/src/commonTest/kotlin/node/parser/TomlNodeTest.kt b/ktoml-core/src/commonTest/kotlin/node/parser/TomlNodeTest.kt index f7853d12..99a0526e 100644 --- a/ktoml-core/src/commonTest/kotlin/node/parser/TomlNodeTest.kt +++ b/ktoml-core/src/commonTest/kotlin/node/parser/TomlNodeTest.kt @@ -1,7 +1,7 @@ package com.akuleshov7.ktoml.test.node.parser -import com.akuleshov7.ktoml.parsers.node.TomlFile -import com.akuleshov7.ktoml.parsers.node.TomlTable +import com.akuleshov7.ktoml.tree.TomlFile +import com.akuleshov7.ktoml.tree.TomlTable import kotlin.test.Test import kotlin.test.assertTrue diff --git a/ktoml-core/src/commonTest/kotlin/node/parser/TomlTableTest.kt b/ktoml-core/src/commonTest/kotlin/node/parser/TomlTableTest.kt index ed106a5c..b73fc2e5 100644 --- a/ktoml-core/src/commonTest/kotlin/node/parser/TomlTableTest.kt +++ b/ktoml-core/src/commonTest/kotlin/node/parser/TomlTableTest.kt @@ -1,7 +1,7 @@ package com.akuleshov7.ktoml.test.node.parser -import com.akuleshov7.ktoml.parsers.node.TomlFile -import com.akuleshov7.ktoml.parsers.node.TomlTable +import com.akuleshov7.ktoml.tree.TomlFile +import com.akuleshov7.ktoml.tree.TomlTable import kotlin.test.Test import kotlin.test.assertEquals diff --git a/ktoml-core/src/commonTest/kotlin/node/parser/ValueParserTest.kt b/ktoml-core/src/commonTest/kotlin/node/parser/ValueParserTest.kt index a4847fe9..567aeaa6 100644 --- a/ktoml-core/src/commonTest/kotlin/node/parser/ValueParserTest.kt +++ b/ktoml-core/src/commonTest/kotlin/node/parser/ValueParserTest.kt @@ -2,7 +2,7 @@ package com.akuleshov7.ktoml.test.node.parser import com.akuleshov7.ktoml.KtomlConf import com.akuleshov7.ktoml.exceptions.TomlParsingException -import com.akuleshov7.ktoml.parsers.node.* +import com.akuleshov7.ktoml.tree.* import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/ktoml-file/src/commonMain/kotlin/com/akuleshov7/ktoml/file/FileUtils.kt b/ktoml-file/src/commonMain/kotlin/com/akuleshov7/ktoml/file/FileUtils.kt index 2a968bdd..1e35e4dc 100644 --- a/ktoml-file/src/commonMain/kotlin/com/akuleshov7/ktoml/file/FileUtils.kt +++ b/ktoml-file/src/commonMain/kotlin/com/akuleshov7/ktoml/file/FileUtils.kt @@ -4,9 +4,11 @@ package com.akuleshov7.ktoml.file +import okio.BufferedSink import okio.FileNotFoundException import okio.FileSystem import okio.Path.Companion.toPath +import okio.buffer /** * Simple file reading with okio (returning a list with strings) @@ -28,6 +30,23 @@ internal fun readAndParseFile(tomlFile: String): List { } } +/** + * Opens a file for writing via a [BufferedSink]. + * + * @param tomlFile The path string pointing to a .toml file. + * @return A [BufferedSink] writing to the specified [tomlFile] path. + * @throws FileNotFoundException + */ +internal fun openFileForWrite(tomlFile: String): BufferedSink { + try { + val ktomlPath = tomlFile.toPath() + return getOsSpecificFileSystem().sink(ktomlPath).buffer() + } catch (e: FileNotFoundException) { + println("Not able to find toml-file in the following path: $tomlFile") + throw e + } +} + /** * Implementation for getting proper file system to read files with okio * diff --git a/ktoml-file/src/commonMain/kotlin/com/akuleshov7/ktoml/file/TomlFileWriter.kt b/ktoml-file/src/commonMain/kotlin/com/akuleshov7/ktoml/file/TomlFileWriter.kt new file mode 100644 index 00000000..f583c71d --- /dev/null +++ b/ktoml-file/src/commonMain/kotlin/com/akuleshov7/ktoml/file/TomlFileWriter.kt @@ -0,0 +1,39 @@ +package com.akuleshov7.ktoml.file + +import com.akuleshov7.ktoml.KtomlConf +import com.akuleshov7.ktoml.Toml +import com.akuleshov7.ktoml.tree.TomlFile + +import okio.use + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +/** + * Writes to a file in the TOML format. + * @property serializersModule + */ +@OptIn(ExperimentalSerializationApi::class) +public open class TomlFileWriter( + private val config: KtomlConf = KtomlConf(), + override val serializersModule: SerializersModule = EmptySerializersModule +) : Toml(config, serializersModule) { + public fun encodeToFile( + serializer: SerializationStrategy, + value: T, + tomlFilePath: String + ) { + val fileTree = TomlFile(config) + + // Todo: Write an encoder implementation. + + TomlSinkEmitter( + openFileForWrite(tomlFilePath), + config + ).use { + tomlWriter.write(fileTree, it) + } + } +} diff --git a/ktoml-file/src/commonMain/kotlin/com/akuleshov7/ktoml/file/TomlSinkEmitter.kt b/ktoml-file/src/commonMain/kotlin/com/akuleshov7/ktoml/file/TomlSinkEmitter.kt new file mode 100644 index 00000000..b3494ec2 --- /dev/null +++ b/ktoml-file/src/commonMain/kotlin/com/akuleshov7/ktoml/file/TomlSinkEmitter.kt @@ -0,0 +1,26 @@ +@file:Suppress("UNUSED_IMPORT")// TomlComposer used for documentation only + +package com.akuleshov7.ktoml.file + +import com.akuleshov7.ktoml.KtomlConf +import com.akuleshov7.ktoml.writers.TomlEmitter +import okio.BufferedSink +import okio.Closeable + +/** + * A [TomlEmitter] implementation that writes to a [BufferedSink]. + */ +internal class TomlSinkEmitter( + private val sink: BufferedSink, + ktomlConf: KtomlConf +) : TomlEmitter(ktomlConf), Closeable { + override fun emit(fragment: String) { + sink.writeUtf8(fragment) + } + + override fun emit(fragment: Char) { + sink.writeUtf8CodePoint(fragment.code) + } + + override fun close(): Unit = sink.close() +} diff --git a/ktoml-file/src/commonTest/kotlin/file/TomlFileParserTest.kt b/ktoml-file/src/commonTest/kotlin/file/TomlFileParserTest.kt index 3f43afad..30761991 100644 --- a/ktoml-file/src/commonTest/kotlin/file/TomlFileParserTest.kt +++ b/ktoml-file/src/commonTest/kotlin/file/TomlFileParserTest.kt @@ -2,7 +2,7 @@ package com.akuleshov7.ktoml.file import com.akuleshov7.ktoml.* import com.akuleshov7.ktoml.parsers.TomlParser -import com.akuleshov7.ktoml.parsers.node.TomlTable +import com.akuleshov7.ktoml.tree.TomlTable import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.serializer