diff --git a/formats/properties/api/kotlinx-serialization-properties.api b/formats/properties/api/kotlinx-serialization-properties.api index 9f15dc0613..986366189d 100644 --- a/formats/properties/api/kotlinx-serialization-properties.api +++ b/formats/properties/api/kotlinx-serialization-properties.api @@ -1,3 +1,20 @@ +public final class kotlinx/serialization/properties/KeyValueSeparator : java/lang/Enum { + public static final field COLON Lkotlinx/serialization/properties/KeyValueSeparator; + public static final field EQUALS Lkotlinx/serialization/properties/KeyValueSeparator; + public final fun char ()C + public static fun valueOf (Ljava/lang/String;)Lkotlinx/serialization/properties/KeyValueSeparator; + public static fun values ()[Lkotlinx/serialization/properties/KeyValueSeparator; +} + +public final class kotlinx/serialization/properties/LineSeparator : java/lang/Enum { + public static final field CR Lkotlinx/serialization/properties/LineSeparator; + public static final field CRLF Lkotlinx/serialization/properties/LineSeparator; + public static final field LF Lkotlinx/serialization/properties/LineSeparator; + public final fun chars ()[C + public static fun valueOf (Ljava/lang/String;)Lkotlinx/serialization/properties/LineSeparator; + public static fun values ()[Lkotlinx/serialization/properties/LineSeparator; +} + public abstract class kotlinx/serialization/properties/Properties : kotlinx/serialization/SerialFormat { public static final field Default Lkotlinx/serialization/properties/Properties$Default; public synthetic fun (Lkotlinx/serialization/modules/SerializersModule;Ljava/lang/Void;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -11,8 +28,38 @@ public abstract class kotlinx/serialization/properties/Properties : kotlinx/seri public final class kotlinx/serialization/properties/Properties$Default : kotlinx/serialization/properties/Properties { } +public final class kotlinx/serialization/properties/PropertiesBuilder { + public final fun getKeyValueSeparator ()Lkotlinx/serialization/properties/KeyValueSeparator; + public final fun getLineSeparator ()Lkotlinx/serialization/properties/LineSeparator; + public final fun getModule ()Lkotlinx/serialization/modules/SerializersModule; + public final fun getSpacesAfterSeparator ()I + public final fun getSpacesBeforeSeparator ()I + public final fun setKeyValueSeparator (Lkotlinx/serialization/properties/KeyValueSeparator;)V + public final fun setLineSeparator (Lkotlinx/serialization/properties/LineSeparator;)V + public final fun setModule (Lkotlinx/serialization/modules/SerializersModule;)V + public final fun setSpacesAfterSeparator (I)V + public final fun setSpacesBeforeSeparator (I)V +} + public final class kotlinx/serialization/properties/PropertiesKt { public static final fun Properties (Lkotlinx/serialization/modules/SerializersModule;)Lkotlinx/serialization/properties/Properties; public static final fun noImpl ()Ljava/lang/Void; } +public abstract class kotlinx/serialization/properties/StringProperties : kotlinx/serialization/SerialFormat { + public static final field Default Lkotlinx/serialization/properties/StringProperties$Default; + public synthetic fun (Lkotlinx/serialization/properties/PropertiesConf;Lkotlinx/serialization/properties/Properties;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lkotlinx/serialization/properties/PropertiesConf;Lkotlinx/serialization/properties/Properties;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun decodeFromString (Lkotlinx/serialization/DeserializationStrategy;Ljava/lang/String;)Ljava/lang/Object; + public final fun encodeToString (Lkotlinx/serialization/SerializationStrategy;Ljava/lang/Object;)Ljava/lang/String; + public fun getSerializersModule ()Lkotlinx/serialization/modules/SerializersModule; +} + +public final class kotlinx/serialization/properties/StringProperties$Default : kotlinx/serialization/properties/StringProperties { +} + +public final class kotlinx/serialization/properties/StringPropertiesKt { + public static final fun StringProperties (Lkotlin/jvm/functions/Function1;)Lkotlinx/serialization/properties/StringProperties; + public static synthetic fun StringProperties$default (Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlinx/serialization/properties/StringProperties; +} + diff --git a/formats/properties/commonMain/src/kotlinx/serialization/properties/Properties.kt b/formats/properties/commonMain/src/kotlinx/serialization/properties/Properties.kt index 8760950c3c..700e805d98 100644 --- a/formats/properties/commonMain/src/kotlinx/serialization/properties/Properties.kt +++ b/formats/properties/commonMain/src/kotlinx/serialization/properties/Properties.kt @@ -26,7 +26,7 @@ import kotlinx.serialization.modules.* * @Serializable * class DataHolder(val data: Data, val property2: String) * - * val map = Properties.store(DataHolder(Data("value1"), "value2")) + * val map = Properties.encodeToMap(DataHolder(Data("value1"), "value2")) * // map contents will be the following: * // property2 = value2 * // data.property1 = value1 diff --git a/formats/properties/commonMain/src/kotlinx/serialization/properties/StringProperties.kt b/formats/properties/commonMain/src/kotlinx/serialization/properties/StringProperties.kt new file mode 100644 index 0000000000..99d7b1ba56 --- /dev/null +++ b/formats/properties/commonMain/src/kotlinx/serialization/properties/StringProperties.kt @@ -0,0 +1,291 @@ +/* + * Copyright 2017-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization.properties + +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import kotlinx.serialization.internal.* +import kotlinx.serialization.modules.* + +/** + * Transforms a [Serializable] class' properties into a single flat [String] representing the class data + * in the properties format. + * + * If the given class has non-primitive property `d` of arbitrary type `D`, `D` values are inserted + * into the same map; keys for such values are prefixed with string `d.`: + * + * ``` + * @Serializable + * class Data(val property1: String) + * + * @Serializable + * class DataHolder(val data: Data, val property2: String) + * + * val string = StringProperties.encodeToString(properties) + * // string contents will be the following: + * """ + * property2 = value2 + * data.property1 = value1 + * """ + * ``` + * + * If the given class has a [List] property `l`, each value from the list + * would be prefixed with `l.N.`, where N is an index for a particular value. + * [Map] is treated as a `[key,value,...]` list. + + * Conversely, this class can convert a properties string into a [Serializable] class instance. + * ``` + * @Serializable + * class Data(val property1: String) + * + * @Serializable + * class DataHolder(val data: Data, val property2: String) + * + * val string = """ + * property2 = value2 + * data.property1 = value1 + * """ + * val data = StringProperties.decodeToString(string, DataHolder.serializer()) + * // data contents will be the following: + * // DataHolder(data = Data(property1 = "value1"), property2 = "value2") + * ``` + * + * @param conf A [PropertiesConf] which contain configuration for customising the output string. + */ +@ExperimentalSerializationApi +@Suppress("UNUSED_PARAMETER") +public sealed class StringProperties( + private val conf: PropertiesConf, + private val properties: Properties = Properties(conf.serializersModule), +) : SerialFormat by properties, StringFormat { + + /** + * Encodes properties from the given [value] to a properties String using the given [serializer]. + * `null` values are omitted from the output. + */ + @ExperimentalSerializationApi + public override fun encodeToString(serializer: SerializationStrategy, value: T): String { + val map = properties.encodeToMap(serializer, value) + val builder = StringBuilder() + for ((k, v) in map) { + builder.append(k) + repeat(conf.spacesBeforeSeparator) { + builder.append(' ') + } + builder.append(conf.keyValueSeparator.char()) + repeat(conf.spacesAfterSeparator) { + builder.append(' ') + } + builder.append(v) + builder.append(conf.lineSeparator.chars()) + } + return builder.toString() + } + + /** + * Decodes properties from the given [string] to a value of type [T] using the given [deserializer]. + * [String] values are converted to respective primitive types using default conversion methods. + * [T] may contain properties of nullable types; they will be filled by non-null values from the [map], if present. + */ + public override fun decodeFromString(deserializer: DeserializationStrategy, string: String): T { + val result = mutableMapOf() + for (line in string.logicalLines()) { + val parsedLine = line.unescaped() + var keyEnd = parsedLine.length + for (i in parsedLine.indices) { + if (parsedLine[i] in separators) { + keyEnd = i + break + } + } + + var valueBegin = parsedLine.length + var separatorFound = false + for (i in keyEnd..parsedLine.lastIndex) { + if (separatorFound && parsedLine[i] != ' ') { + valueBegin = i + break + } + if (parsedLine[i] in nonBlankSeparators) { + separatorFound = true + } + if (parsedLine[i] !in separators) { + valueBegin = i + break + } + } + + result[parsedLine.substring(0, keyEnd)] = parsedLine.substring(valueBegin) + } + return properties.decodeFromStringMap(deserializer, result) + } + + /** + * A [Properties] instance that can be used as default and does not have any [SerializersModule] installed. + */ + @ExperimentalSerializationApi + public companion object Default : StringProperties(PropertiesConf()) +} + +@OptIn(ExperimentalSerializationApi::class) +private class StringPropertiesImpl(conf: PropertiesConf) : StringProperties(conf) + +/** + * Creates an instance of [StringProperties] with a given [builderAction]. + * TODO: doc + */ +@ExperimentalSerializationApi +public fun StringProperties(builderAction: StringPropertiesBuilder.() -> Unit = {}): StringProperties { + val builder = StringPropertiesBuilder(PropertiesConf()) + builder.builderAction() + return StringPropertiesImpl(builder.build()) +} + +/** + * Encodes properties from given [value] to a string using serializer for reified type [T] and returns this string. + * Converts all primitive types to [String] using [toString] method. + * `null` values are omitted from the output. + */ +@ExperimentalSerializationApi +public inline fun StringProperties.encodeToString(value: T): String = + encodeToString(serializersModule.serializer(), value) + +/** + * Decodes properties from given [propertiesString], assigns them to an object using serializer for reified type [T] and returns this object. + * [String] values are converted to respective primitive types using default conversion methods. + * [T] may contain properties of nullable types; they will be filled by non-null values from the [map], if present. + */ +@ExperimentalSerializationApi +public inline fun StringProperties.decodeFromString(propertiesString: String): T = + decodeFromString(serializersModule.serializer(), propertiesString) + +/** + * Builder of the [StringProperties] instance provided by `StringProperties { ... }` factory function. + */ +@ExperimentalSerializationApi +public class StringPropertiesBuilder internal constructor(from: PropertiesConf) { + + /** + * A [LineSeparator] to be used for separating lines when encoding to a string. + * Default value is [LineSeparator.LF]. + */ + public var lineSeparator: LineSeparator = from.lineSeparator + + /** + * A [KeyValueSeparator] to be used for separating keys and values when encoding to a string. + * Default value is [KeyValueSeparator.EQUALS]. + */ + public var keyValueSeparator: KeyValueSeparator = from.keyValueSeparator + + /** + * A number of spaces to be inserted before the [keyValueSeparator] when encoding to a string. + * Default value is `0`. + */ + public var spacesBeforeSeparator: Int = from.spacesBeforeSeparator + + /** + * A number of spaces to be inserted after the [keyValueSeparator] when encoding to a string. + * Default value is `0`. + */ + public var spacesAfterSeparator: Int = from.spacesAfterSeparator + + /** + * A [SerializersModule] to be used for encoding and decoding. + * Default value is [EmptySerializersModule]. + */ + public var module: SerializersModule = from.serializersModule + + internal fun build(): PropertiesConf { + return PropertiesConf( + lineSeparator, + keyValueSeparator, + spacesBeforeSeparator, + spacesAfterSeparator, + module + ) + } +} + +@ExperimentalSerializationApi +internal data class PropertiesConf( + val lineSeparator: LineSeparator = LineSeparator.LF, + val keyValueSeparator: KeyValueSeparator = KeyValueSeparator.EQUALS, + val spacesBeforeSeparator: Int = 0, + val spacesAfterSeparator: Int = 0, + val serializersModule: SerializersModule = EmptySerializersModule() +) + +@ExperimentalSerializationApi +public enum class LineSeparator(private val s: String) { + LF("\n"), + CR("\r"), + CRLF("\r\n"); + + public fun chars(): CharArray { + return s.toCharArray() + } +} + +@ExperimentalSerializationApi +public enum class KeyValueSeparator(private val c: Char) { + EQUALS('='), + COLON(':'); + + public fun char(): Char = c +} + +private val nonBlankSeparators = setOf('=', ':') +private val separators = nonBlankSeparators + ' ' +private val wellKnownEscapeChars = mapOf( + '\\' to '\\', + 'n' to '\n', + 'r' to '\r', + 't' to '\t' +) + +private fun String.unescaped(): String { + val sb = StringBuilder(this.length) + var i = 0 + while (i < this.length) { + if (i < this.length - 1 && this[i] == '\\') { + if (this[i + 1] in wellKnownEscapeChars) { + sb.append(wellKnownEscapeChars[this[i + 1]]) + i += 2 + } else { + i++ + } + } else { + sb.append(this[i]) + i++ + } + } + return sb.toString() +} + +private fun String.logicalLines(): List { + val commentFilter = "[ \\t\\f]*[#!].*".toRegex() + val lines = lines() + .filterNot { it.isBlank() || commentFilter.matches(it) } + .toMutableList() + val logicalLines = mutableListOf() + + var currentLine = "" + for (line in lines) { + val trimmedLine = line.trimStart() + if (trimmedLine.endsWith("\\")) { + currentLine += trimmedLine.dropLast(1) + } else { + currentLine += trimmedLine + logicalLines.add(currentLine) + currentLine = "" + } + } + if (currentLine.isNotBlank()) { + logicalLines.add(currentLine) + } + + return logicalLines +} diff --git a/formats/properties/commonTest/src/kotlinx/serialization/properties/StringPropertiesTest.kt b/formats/properties/commonTest/src/kotlinx/serialization/properties/StringPropertiesTest.kt new file mode 100644 index 0000000000..84839ebf53 --- /dev/null +++ b/formats/properties/commonTest/src/kotlinx/serialization/properties/StringPropertiesTest.kt @@ -0,0 +1,396 @@ +/* + * Copyright 2017-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + + +package kotlinx.serialization.properties + +import kotlinx.serialization.* +import kotlinx.serialization.builtins.* +import kotlin.test.* + +class StringPropertiesTest { + + @Serializable + data class Data(val list: List, val property: String) + + @Serializable + data class Recursive(val data: Data, val property: String) + + @Serializable + data class NullableData(val property: String, val nullable: String? = null, val nullable2: String? = null) + + @Serializable + data class Category(var name: String? = null, var subCategory: SubCategory? = null) + + @Serializable + data class SubCategory(var name: String? = null, var option: String? = null) + + @Serializable + data class DataWithMap(val map: Map = mapOf()) + + @Serializable + data class MultiType( + val first: Int, + val second: String, + val unit: Unit = Unit, + val last: Boolean = true + ) + + @Serializable + data class TestWithSize( + val p: String? = null, + val size: String? = null + ) + + @Serializable + data class EnumData(val data: TestEnum) + + @Serializable + data class NullableEnumData(val data0: TestEnum?, val data1: TestEnum?) + + @Serializable + data class SharedPrefixNames( + val first: String = "100", + val firstSecond: String = "100" + ) + + @Serializable + enum class TestEnum { ZERO, ONE } + + private inline fun assertMappedAndRestored( + expectedString: String, + obj: T, + serializer: KSerializer + ) { + val string = StringProperties.encodeToString(serializer, obj) + assertEquals(expectedString, string) + + val unmap = StringProperties.decodeFromString(serializer, string) + assertEquals(obj, unmap) + } + + private inline fun assertStringDecodesInto( + input: String, + expectedObj: T, + serializer: KSerializer + ) { + val result = StringProperties.decodeFromString(serializer, input) + assertEquals(expectedObj, result) + } + + private inline fun assertMappedAndRestoredCustomFormat( + expectedString: String, + obj: T, + serializer: KSerializer + ) { + val stringProperties = StringProperties { + lineSeparator = LineSeparator.CRLF + keyValueSeparator = KeyValueSeparator.COLON + spacesBeforeSeparator = 4 + spacesAfterSeparator = 2 + } + val string = stringProperties.encodeToString(serializer, obj) + assertEquals(expectedString, string) + + val unmap = StringProperties.decodeFromString(serializer, string) + assertEquals(obj, unmap) + } + + private inline fun assertMappedNullableAndRestored( + expectedString: String, + obj: T, + serializer: KSerializer + ) { + val map = StringProperties.encodeToString(serializer, obj) + assertEquals(expectedString, map) + val unmap = StringProperties.decodeFromString(serializer, map) + assertEquals(obj, unmap) + } + + @Test + fun testMultipleTypes() { + val data = MultiType(1, "2") + val expectedString = """ + first=1 + second=2 + last=true + + """.trimIndent() + assertMappedAndRestored( + expectedString, + data, + MultiType.serializer() + ) + } + + @Test + fun testUnitIsEmptyString() { + assertEquals("", StringProperties.encodeToString(Unit.serializer(), Unit)) + } + + @Test + fun testList() { + val data = Data(listOf("element1"), "property") + val expectedString = """ + list.0=element1 + property=property + + """.trimIndent() + assertMappedAndRestored( + expectedString, + data, + Data.serializer() + ) + } + + @Test + fun testNestedStructure() { + val recursive = Recursive( + Data( + listOf("l1", "l2"), + "property" + ), "string" + ) + val expectedString = """ + data.list.0=l1 + data.list.1=l2 + data.property=property + property=string + + """.trimIndent() + assertMappedAndRestored(expectedString, recursive, Recursive.serializer()) + } + + @Test + fun testCustomFormat() { + val recursive = Recursive( + Data( + listOf("l1", "l2"), + "property" + ), "string" + ) + val expectedString = listOf( + "data.list.0 : l1", + "data.list.1 : l2", + "data.property : property", + "property : string", + "" + ).joinToString("\r\n") + assertMappedAndRestoredCustomFormat(expectedString, recursive, Recursive.serializer()) + } + + @Test + fun testNullableProperties() { + val data = NullableData("property", null, null) + val expectedString = """ + property=property + + """.trimIndent() + assertMappedNullableAndRestored(expectedString, data, NullableData.serializer()) + } + + @Test + fun testNestedNull() { + val category = Category(name = "Name") + val expectedString = """ + name=Name + + """.trimIndent() + assertMappedNullableAndRestored(expectedString, category, Category.serializer()) + } + + @Test + fun testLoadOptionalProps() { + val string = """ + name=Name + + """.trimIndent() + val restored = StringProperties.decodeFromString(Category.serializer(), string) + assertEquals(Category("Name"), restored) + } + + @Test + fun testLoadOptionalNestedProps() { + val string = """ + name=Name + subCategory.name=SubName + + """.trimIndent() + val restored = StringProperties.decodeFromString(Category.serializer(), string) + assertEquals(Category("Name", SubCategory("SubName")), restored) + } + + @Test + fun testOmitsNullAndCanLoadBack() { + val category = Category(name = "Name") + val expectedString = """ + name=Name + + """.trimIndent() + assertMappedAndRestored(expectedString, category, Category.serializer()) + } + + @Test + fun testThrowsOnIncorrectMaps() { + val string = """ + name=Name + + """.trimIndent() + assertFailsWith { + StringProperties.decodeFromString(Data.serializer(), string) + } + } + + @Test + fun testNestedMap() { + val map0 = DataWithMap(mapOf()) + val string0 = "" + + val map1 = DataWithMap(mapOf("one" to 1)) + val string1 = """ + map.0=one + map.1=1 + + """.trimIndent() + + val map2 = DataWithMap(mapOf("one" to 1, "two" to 2)) + val string2 = """ + map.0=one + map.1=1 + map.2=two + map.3=2 + + """.trimIndent() + + assertMappedAndRestored(string0, map0, DataWithMap.serializer()) + assertMappedAndRestored(string1, map1, DataWithMap.serializer()) + assertMappedAndRestored(string2, map2, DataWithMap.serializer()) + } + + @Test + fun testEnumString() { + val string = """ + data=ZERO + """.trimIndent() + val loaded = StringProperties.decodeFromString(EnumData.serializer(), string) + assertEquals(EnumData(TestEnum.ZERO), loaded) + } + + @Test + fun testEnumInteger() { + val string = """ + data=ZERO + """.trimIndent() + val loaded = StringProperties.decodeFromString(EnumData.serializer(), string) + assertEquals(EnumData(TestEnum.ZERO), loaded) + } + + @Test + fun testCanReadSizeProperty() { + val string = """ + p=a + size=b + + """.trimIndent() + assertMappedAndRestored(string, TestWithSize("a", "b"), TestWithSize.serializer()) + } + + @Test + fun testSharedPrefixNames() { + val string = """ + firstSecond=42 + """.trimIndent() + val restored = StringProperties.decodeFromString(SharedPrefixNames.serializer(), string) + assertEquals(SharedPrefixNames("100", "42"), restored) + } + + @Test + fun testEnumElementNotFound() { + val wrongElementName = "wrong" + val expectedMessage = + "Enum '${TestEnum.serializer().descriptor.serialName}' does not contain element with name '${wrongElementName}'" + val string = """ + data=${wrongElementName} + """.trimIndent() + val exception = assertFailsWith(SerializationException::class) { + StringProperties.decodeFromString(EnumData.serializer(), string) + } + assertEquals(expectedMessage, exception.message) + } + + @Test + fun testCommentsDecode() { + val map1 = Data(listOf("l1", "l2#this is not a comment"), "property") + val string1 = """ + # This a hashtag comment + ! This a exclamation point comment + list.0=l1 + list.1=l2#this is not a comment + #list.2=l3 + property=property + """.trimIndent() + + assertStringDecodesInto(string1, map1, Data.serializer()) + } + + @Test + fun testKeyValueSeparatorDecode() { + val data = Data(listOf("l1", "l2"), "property") + val string = """ + list.0:l1 + list.1:l2 + property:property + """.trimIndent() + + assertStringDecodesInto(string, data, Data.serializer()) + } + + @Test + fun testLogicalLinesDecode() { + val data = Data( + listOf( + "apple, banana, pear, cantaloupe, watermelon, kiwi, mango", + "Detroit,Chicago,Los Angeles" + ), + "property" + ) + val string = """ + property=property + list.1=\ + Detroit,\ + Chicago,\ + Los Angeles + list.0=apple, banana, pear, \ + cantaloupe, watermelon, \ + kiwi, mango + """.trimIndent() + + assertStringDecodesInto(string, data, Data.serializer()) + } + + @Test + fun testWitheSpacesDecode() { + val data = Data(listOf("l1 ", "l2"), "property") + val string = """ + list.0= l1 + list.1= l2 + property=property + """.trimIndent() + + assertStringDecodesInto(string, data, Data.serializer()) + } + + @Test + fun testBackslashDecode() { + val data = Data(listOf("l1", "l2\n\r\t"), "property") + val string = """ + list.0\=l1 + list.1=l\2\n\r\t + \property=property + """.trimIndent() + + assertStringDecodesInto(string, data, Data.serializer()) + } + +}