From 206b9ea5fedd0e58020942a9843d914e69b8d09a Mon Sep 17 00:00:00 2001 From: EdwarDDay <4127904+edwardday@users.noreply.github.com> Date: Sun, 17 Nov 2024 20:41:00 +0100 Subject: [PATCH 1/5] Make Yaml nodes serializable - fixes #641 --- .../kotlin/com/charleskorn/kaml/YamlInput.kt | 7 + .../kotlin/com/charleskorn/kaml/YamlNode.kt | 8 + .../charleskorn/kaml/YamlNodeSerializer.kt | 173 ++++++++++++++++++ .../com/charleskorn/kaml/YamlNullInput.kt | 2 +- .../charleskorn/kaml/YamlPolymorphicInput.kt | 2 +- .../charleskorn/kaml/YamlNullReadingTest.kt | 29 +++ .../com/charleskorn/kaml/YamlReadingTest.kt | 120 ++++++++++++ .../charleskorn/kaml/YamlScalarReadingTest.kt | 30 +++ .../com/charleskorn/kaml/YamlWritingTest.kt | 129 +++++++++++++ .../kaml/testobjects/TestObjects.kt | 42 +++++ 10 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt index 620ec896..7b7630e0 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt @@ -62,6 +62,13 @@ public sealed class YamlInput( is YamlList -> when (descriptor.kind) { is StructureKind.LIST -> YamlListInput(node, yaml, context, configuration) is SerialKind.CONTEXTUAL -> createContextual(node, yaml, context, configuration, descriptor) + is PolymorphicKind -> { + if (descriptor.isContentBasedPolymorphic) { + createContextual(node, yaml, context, configuration, descriptor) + } else { + throw MissingTypeTagException(node.path) + } + } else -> throw IncorrectTypeException("Expected ${descriptor.kind.friendlyDescription}, but got a list", node.path) } diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt index bd213ddb..08213376 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt @@ -18,6 +18,9 @@ package com.charleskorn.kaml +import kotlinx.serialization.Serializable + +@Serializable(with = YamlNodeSerializer::class) public sealed class YamlNode(public open val path: YamlPath) { public val location: Location get() = path.endLocation @@ -30,6 +33,7 @@ public sealed class YamlNode(public open val path: YamlPath) { YamlPath(newParentPath.segments + child.path.segments.drop(path.segments.size)) } +@Serializable(with = YamlScalarSerializer::class) public data class YamlScalar(val content: String, override val path: YamlPath) : YamlNode(path) { override fun equivalentContentTo(other: YamlNode): Boolean = other is YamlScalar && this.content == other.content override fun contentToString(): String = "'$content'" @@ -104,6 +108,7 @@ public data class YamlScalar(val content: String, override val path: YamlPath) : override fun toString(): String = "scalar @ $path : $content" } +@Serializable(with = YamlNullSerializer::class) public data class YamlNull(override val path: YamlPath) : YamlNode(path) { override fun equivalentContentTo(other: YamlNode): Boolean = other is YamlNull override fun contentToString(): String = "null" @@ -111,6 +116,7 @@ public data class YamlNull(override val path: YamlPath) : YamlNode(path) { override fun toString(): String = "null @ $path" } +@Serializable(with = YamlListSerializer::class) public data class YamlList(val items: List, override val path: YamlPath) : YamlNode(path) { override fun equivalentContentTo(other: YamlNode): Boolean { if (other !is YamlList) { @@ -152,6 +158,7 @@ public data class YamlList(val items: List, override val path: YamlPat } } +@Serializable(with = YamlMapSerializer::class) public data class YamlMap(val entries: Map, override val path: YamlPath) : YamlNode(path) { init { val keys = entries.keys.sortedWith { a, b -> @@ -240,6 +247,7 @@ public data class YamlMap(val entries: Map, override val p } } +@Serializable(with = YamlTaggedNodeSerializer::class) public data class YamlTaggedNode(val tag: String, val innerNode: YamlNode) : YamlNode(innerNode.path) { override fun equivalentContentTo(other: YamlNode): Boolean { if (other !is YamlTaggedNode) { diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt new file mode 100644 index 00000000..e6d4bc8c --- /dev/null +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt @@ -0,0 +1,173 @@ +/* + + Copyright 2018-2023 Charles Korn. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +@file:OptIn(ExperimentalSerializationApi::class, InternalSerializationApi::class) + +package com.charleskorn.kaml + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.PolymorphicKind +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.descriptors.buildSerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.encodeStructure + +internal object YamlNodeSerializer : YamlContentPolymorphicSerializer(YamlNode::class) { + override val descriptor: SerialDescriptor = super.descriptor.nullable + + override fun serialize(encoder: Encoder, value: YamlNode) { + encoder.asYamlOutput() + when (value) { + is YamlList -> encoder.encodeSerializableValue(YamlListSerializer, value) + is YamlMap -> encoder.encodeSerializableValue(YamlMapSerializer, value) + is YamlNull -> encoder.encodeSerializableValue(YamlNullSerializer, value) + is YamlScalar -> encoder.encodeSerializableValue(YamlScalarSerializer, value) + is YamlTaggedNode -> encoder.encodeSerializableValue(YamlTaggedNodeSerializer, value) + } + } + + override fun deserialize(decoder: Decoder): YamlNode { + val input = decoder.asYamlInput() + return if (input is YamlPolymorphicInput) YamlTaggedNode(input.typeName, input.node) else input.node + } + + override fun selectDeserializer(node: YamlNode): DeserializationStrategy { + error("implemented custom serialize logic") + } +} + +internal object YamlScalarSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("com.charleskorn.kaml.YamlScalar", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: YamlScalar) { + encoder.asYamlOutput() + try { + return encoder.encodeBoolean(value.toBoolean()) + } catch (_: SerializationException) { + } + try { + return encoder.encodeLong(value.toLong()) + } catch (_: SerializationException) { + } + try { + return encoder.encodeDouble(value.toDouble()) + } catch (_: SerializationException) { + } + encoder.encodeString(value.contentToString()) + } + + override fun deserialize(decoder: Decoder): YamlScalar { + val result = decoder.asYamlInput() + return result.scalar + } +} + +@OptIn(ExperimentalSerializationApi::class) +internal object YamlNullSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildSerialDescriptor("com.charleskorn.kaml.YamlNull", SerialKind.ENUM) + + override fun serialize(encoder: Encoder, value: YamlNull) { + encoder.asYamlOutput().encodeNull() + } + + override fun deserialize(decoder: Decoder): YamlNull { + val input = decoder.asYamlInput() + return input.nullValue + } +} + +internal object YamlTaggedNodeSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + buildSerialDescriptor("com.charleskorn.kaml.YamlTaggedNode", PolymorphicKind.OPEN) { + element("tag", String.serializer().descriptor) + element("node", YamlNodeSerializer.descriptor) + } + + override fun serialize(encoder: Encoder, value: YamlTaggedNode) { + encoder.asYamlOutput().encodeStructure(descriptor) { + encodeStringElement(descriptor, 0, value.tag) + encodeSerializableElement(descriptor, 1, YamlNodeSerializer, value.innerNode) + } + } + + override fun deserialize(decoder: Decoder): YamlTaggedNode { + val input = decoder.asYamlInput() + return YamlTaggedNode(input.typeName, input.contentNode) + } +} + +internal object YamlMapSerializer : KSerializer { + + private object YamlMapDescriptor : + SerialDescriptor by MapSerializer(YamlScalarSerializer, YamlNodeSerializer).descriptor { + override val serialName: String = "com.charleskorn.kaml.YamlMap" + } + + override val descriptor: SerialDescriptor = YamlMapDescriptor + + override fun serialize(encoder: Encoder, value: YamlMap) { + encoder.asYamlOutput() + MapSerializer(YamlScalarSerializer, YamlNodeSerializer).serialize(encoder, value.entries) + } + + override fun deserialize(decoder: Decoder): YamlMap { + val input = decoder.asYamlInput() + return input.node as YamlMap + } +} + +internal object YamlListSerializer : KSerializer { + + private object YamlListDescriptor : SerialDescriptor by ListSerializer(YamlNodeSerializer).descriptor { + override val serialName: String = "com.charleskorn.kaml.YamlList" + } + + override val descriptor: SerialDescriptor = YamlListDescriptor + + override fun serialize(encoder: Encoder, value: YamlList) { + encoder.asYamlOutput() + ListSerializer(YamlNodeSerializer).serialize(encoder, value.items) + } + + override fun deserialize(decoder: Decoder): YamlList { + val input = decoder.asYamlInput() + return input.list + } +} + +private inline fun Decoder.asYamlInput(): I = checkNotNull(this as? I) { + "This serializer can be used only with Yaml format. Expected Decoder to be ${I::class.simpleName}, got ${this::class}" +} + +private fun Encoder.asYamlOutput() = checkNotNull(this as? YamlOutput) { + "This serializer can be used only with Yaml format. Expected Encoder to be YamlOutput, got ${this::class}" +} diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNullInput.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNullInput.kt index b6979c2c..e300b167 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNullInput.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNullInput.kt @@ -22,7 +22,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.modules.SerializersModule -internal class YamlNullInput(val nullValue: YamlNode, yaml: Yaml, context: SerializersModule, configuration: YamlConfiguration) : YamlInput(nullValue, yaml, context, configuration) { +internal class YamlNullInput(val nullValue: YamlNull, yaml: Yaml, context: SerializersModule, configuration: YamlConfiguration) : YamlInput(nullValue, yaml, context, configuration) { override fun decodeNotNullMark(): Boolean = false override fun decodeValue(): Any = throw UnexpectedNullValueException(nullValue.path) diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlPolymorphicInput.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlPolymorphicInput.kt index 5cce3841..8994ddf3 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlPolymorphicInput.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlPolymorphicInput.kt @@ -32,7 +32,7 @@ import kotlinx.serialization.modules.SerializersModuleCollector import kotlin.reflect.KClass @OptIn(ExperimentalSerializationApi::class) -internal class YamlPolymorphicInput(private val typeName: String, private val typeNamePath: YamlPath, private val contentNode: YamlNode, yaml: Yaml, context: SerializersModule, configuration: YamlConfiguration) : YamlInput(contentNode, yaml, context, configuration) { +internal class YamlPolymorphicInput(val typeName: String, private val typeNamePath: YamlPath, val contentNode: YamlNode, yaml: Yaml, context: SerializersModule, configuration: YamlConfiguration) : YamlInput(contentNode, yaml, context, configuration) { private var currentField = CurrentField.NotStarted private lateinit var contentDecoder: YamlInput diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/YamlNullReadingTest.kt b/src/commonTest/kotlin/com/charleskorn/kaml/YamlNullReadingTest.kt index 12415f31..d6d35a30 100644 --- a/src/commonTest/kotlin/com/charleskorn/kaml/YamlNullReadingTest.kt +++ b/src/commonTest/kotlin/com/charleskorn/kaml/YamlNullReadingTest.kt @@ -18,10 +18,13 @@ package com.charleskorn.kaml +import com.charleskorn.kaml.testobjects.TestClassWithNestedNode +import com.charleskorn.kaml.testobjects.TestClassWithNestedNull import com.charleskorn.kaml.testobjects.TestEnum import io.kotest.assertions.asClue import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.nullable @@ -296,4 +299,30 @@ class YamlNullReadingTest : FlatFunSpec({ } } } + + context("a YAML parser parsing nested null values") { + + context("given a nested null node") { + val input = """ + text: "OK" + node: null + """.trimIndent() + + context("parsing that input as a null node") { + val result = Yaml.default.decodeFromString(TestClassWithNestedNull.serializer(), input) + + test("deserializes scalar to double") { + result.node.shouldBeInstanceOf() + } + } + + context("parsing that input as a node") { + val result = Yaml.default.decodeFromString(TestClassWithNestedNode.serializer(), input) + + test("deserializes node to null") { + result.node.shouldBeInstanceOf() + } + } + } + } }) diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/YamlReadingTest.kt b/src/commonTest/kotlin/com/charleskorn/kaml/YamlReadingTest.kt index 232bef0a..91d52d16 100644 --- a/src/commonTest/kotlin/com/charleskorn/kaml/YamlReadingTest.kt +++ b/src/commonTest/kotlin/com/charleskorn/kaml/YamlReadingTest.kt @@ -25,6 +25,10 @@ import com.charleskorn.kaml.testobjects.PolymorphicWrapper import com.charleskorn.kaml.testobjects.SealedWrapper import com.charleskorn.kaml.testobjects.SimpleStructure import com.charleskorn.kaml.testobjects.Team +import com.charleskorn.kaml.testobjects.TestClassWithNestedList +import com.charleskorn.kaml.testobjects.TestClassWithNestedMap +import com.charleskorn.kaml.testobjects.TestClassWithNestedNode +import com.charleskorn.kaml.testobjects.TestClassWithNestedTaggedNode import com.charleskorn.kaml.testobjects.TestEnum import com.charleskorn.kaml.testobjects.TestSealedStructure import com.charleskorn.kaml.testobjects.UnsealedClass @@ -35,6 +39,7 @@ import com.charleskorn.kaml.testobjects.UnwrappedString import com.charleskorn.kaml.testobjects.polymorphicModule import io.kotest.assertions.asClue import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.maps.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.serialization.Contextual @@ -287,6 +292,39 @@ class YamlReadingTest : FlatFunSpec({ } } + context("parsing nested list node") { + val input = """ + text: "OK" + node: + - 1.2 + - 3 + - .Inf + - null + """.trimIndent() + + context("parsing that input as a list node") { + val result = Yaml.default.decodeFromString(TestClassWithNestedList.serializer(), input) + val resultList = result.node.items.map { if (it is YamlNull) null else it.yamlScalar.toDouble() } + + test("deserializes list") { + resultList shouldBe listOf(1.2, 3.0, Double.POSITIVE_INFINITY, null) + } + } + + context("parsing that input as a node") { + val result = Yaml.default.decodeFromString(TestClassWithNestedNode.serializer(), input) + + test("deserializes node to list") { + result.node.shouldBeInstanceOf() + } + + test("deserializes node to double list") { + val resultList = result.node.yamlList.items.map { if (it is YamlNull) null else it.yamlScalar.toDouble() } + resultList shouldBe listOf(1.2, 3.0, Double.POSITIVE_INFINITY, null) + } + } + } + context("parsing objects") { context("given some input representing an object with an optional value specified") { val input = """ @@ -950,6 +988,56 @@ class YamlReadingTest : FlatFunSpec({ } } + context("parsing nested map node") { + val input = """ + text: "OK" + node: + foo1: "bar" + foo2: null + foo3: 3.14 + foo4: + - 1 + - 2 + - 3 + foo5: + element1: 1 + element2: 2 + """.trimIndent() + + context("parsing that input as a map node") { + val result = Yaml.default.decodeFromString(TestClassWithNestedMap.serializer(), input) + + test("deserializes map") { + result.node.entries shouldHaveSize 5 + result.node.get("foo1")!!.content shouldBe "bar" + result.node.get("foo2").shouldBeInstanceOf() + result.node.get("foo3")!!.toDouble() shouldBe 3.14 + result.node.get("foo4")!!.items.map { it.yamlScalar.toInt() } shouldBe listOf(1, 2, 3) + result.node.get("foo5")!!.get("element1")!!.toInt() shouldBe 1 + result.node.get("foo5")!!.get("element2")!!.toInt() shouldBe 2 + } + } + + context("parsing that input as a node") { + val result = Yaml.default.decodeFromString(TestClassWithNestedNode.serializer(), input) + + test("deserializes node to map") { + result.node.shouldBeInstanceOf() + } + + test("deserializes node to double list") { + val node = result.node.yamlMap + node.entries shouldHaveSize 5 + node.get("foo1")!!.content shouldBe "bar" + node.get("foo2").shouldBeInstanceOf() + node.get("foo3")!!.toDouble() shouldBe 3.14 + node.get("foo4")!!.items.map { it.yamlScalar.toInt() } shouldBe listOf(1, 2, 3) + node.get("foo5")!!.get("element1")!!.toInt() shouldBe 1 + node.get("foo5")!!.get("element2")!!.toInt() shouldBe 2 + } + } + } + context("parsing polymorphic values") { context("given tags are used to store the type information") { val polymorphicYaml = Yaml(serializersModule = polymorphicModule, configuration = YamlConfiguration(polymorphismStyle = PolymorphismStyle.Tag)) @@ -1712,6 +1800,38 @@ class YamlReadingTest : FlatFunSpec({ } } + context("parsing nested tagged node") { + val input = """ + text: "OK" + node: !testtag 2024-01-01 + """.trimIndent() + + context("parsing that input as a tagged node") { + val result = Yaml.default.decodeFromString(TestClassWithNestedTaggedNode.serializer(), input) + + test("deserializes tagged node") { + result.node.tag shouldBe "!testtag" + result.node.innerNode.shouldBeInstanceOf() + result.node.innerNode.yamlScalar.content shouldBe "2024-01-01" + } + } + + context("parsing that input as a node") { + val result = Yaml.default.decodeFromString(TestClassWithNestedNode.serializer(), input) + + test("deserializes node to tagged node") { + result.node.shouldBeInstanceOf() + } + + test("deserializes node to tagged node content") { + val node = result.node.yamlTaggedNode + node.tag shouldBe "!testtag" + node.innerNode.shouldBeInstanceOf() + node.innerNode.yamlScalar.content shouldBe "2024-01-01" + } + } + } + context("parsing values with a dynamically installed serializer") { context("parsing a literal with a contextual serializer") { val contextSerializer = object : KSerializer { diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/YamlScalarReadingTest.kt b/src/commonTest/kotlin/com/charleskorn/kaml/YamlScalarReadingTest.kt index 11891666..c0fb780c 100644 --- a/src/commonTest/kotlin/com/charleskorn/kaml/YamlScalarReadingTest.kt +++ b/src/commonTest/kotlin/com/charleskorn/kaml/YamlScalarReadingTest.kt @@ -18,11 +18,14 @@ package com.charleskorn.kaml +import com.charleskorn.kaml.testobjects.TestClassWithNestedNode +import com.charleskorn.kaml.testobjects.TestClassWithNestedScalar import com.charleskorn.kaml.testobjects.TestEnum import com.charleskorn.kaml.testobjects.TestEnumWithExplicitNames import io.kotest.assertions.asClue import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.nullable import kotlinx.serialization.builtins.serializer @@ -67,6 +70,33 @@ class YamlScalarReadingTest : FlatFunSpec({ } } + context("given a nested floating point node") { + val input = """ + text: "OK" + node: 5.4 + """.trimIndent() + + context("parsing that input as a scalar node") { + val result = Yaml.default.decodeFromString(TestClassWithNestedScalar.serializer(), input) + + test("deserializes scalar to double") { + result.node.toDouble() shouldBe 5.4 + } + } + + context("parsing that input as a node") { + val result = Yaml.default.decodeFromString(TestClassWithNestedNode.serializer(), input) + + test("deserializes node to scalar") { + result.node.shouldBeInstanceOf() + } + + test("deserializes node to double") { + (result.node as YamlScalar).toDouble() shouldBe 5.4 + } + } + } + context("given the input '123'") { val input = "123" diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt b/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt index 2ed733d6..bcee2f38 100644 --- a/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt +++ b/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt @@ -22,6 +22,11 @@ import com.charleskorn.kaml.testobjects.NestedObjects import com.charleskorn.kaml.testobjects.SealedWrapper import com.charleskorn.kaml.testobjects.SimpleStructure import com.charleskorn.kaml.testobjects.Team +import com.charleskorn.kaml.testobjects.TestClassWithNestedList +import com.charleskorn.kaml.testobjects.TestClassWithNestedMap +import com.charleskorn.kaml.testobjects.TestClassWithNestedNode +import com.charleskorn.kaml.testobjects.TestClassWithNestedScalar +import com.charleskorn.kaml.testobjects.TestClassWithNestedTaggedNode import com.charleskorn.kaml.testobjects.TestEnum import com.charleskorn.kaml.testobjects.TestSealedStructure import com.charleskorn.kaml.testobjects.UnsealedClass @@ -306,6 +311,31 @@ class YamlWritingTest : FlatFunSpec({ } } + context("serializing nested scalar node") { + val node = Yaml.default.parseToYamlNode("1.2") as YamlScalar + val expectedOutput = """ + text: "test" + node: 1.2 + """.trimIndent() + context("as scalar node") { + val value = TestClassWithNestedScalar(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedScalar.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + + context("as general node") { + val value = TestClassWithNestedNode(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + } + context("serializing serial names using YamlNamingStrategies") { @Serializable data class NamingStrategyTestData(val serialName: String) @@ -640,6 +670,40 @@ class YamlWritingTest : FlatFunSpec({ } } + context("serializing nested list node") { + val node = Yaml.default.parseToYamlNode( + """ + - 1 + - 2 + - 3 + """.trimIndent(), + ) as YamlList + val expectedOutput = """ + text: "test" + node: + - 1 + - 2 + - 3 + """.trimIndent() + context("as list node") { + val value = TestClassWithNestedList(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedList.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + + context("as general node") { + val value = TestClassWithNestedNode(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + } + context("serializing maps") { context("serializing a map of strings to strings") { val input = mapOf( @@ -729,6 +793,46 @@ class YamlWritingTest : FlatFunSpec({ } } + context("serializing nested map node") { + val node = Yaml.default.parseToYamlNode( + """ + foo: bar + baz: 1 + test: + - 1 + - 2 + - 3 + """.trimIndent(), + ) as YamlMap + val expectedOutput = """ + text: "test" + node: + "'foo'": "'bar'" + "'baz'": 1 + "'test'": + - 1 + - 2 + - 3 + """.trimIndent() + context("as map node") { + val value = TestClassWithNestedMap(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedMap.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + + context("as general node") { + val value = TestClassWithNestedNode(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + } + context("serializing objects") { context("serializing a simple object") { val input = SimpleStructure("The name") @@ -1101,6 +1205,31 @@ class YamlWritingTest : FlatFunSpec({ } } + context("serializing nested tagged node") { + val node = Yaml.default.parseToYamlNode("!testtag 2024-01-01") as YamlTaggedNode + val expectedOutput = """ + text: "test" + node: !testtag "'2024-01-01'" + """.trimIndent() + context("as tagged node") { + val value = TestClassWithNestedTaggedNode(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedTaggedNode.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + + context("as general node") { + val value = TestClassWithNestedNode(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + } + context("handling default values") { context("when encoding defaults") { val defaultEncoder = Yaml.default diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/testobjects/TestObjects.kt b/src/commonTest/kotlin/com/charleskorn/kaml/testobjects/TestObjects.kt index 1a8444a2..3601ac2e 100644 --- a/src/commonTest/kotlin/com/charleskorn/kaml/testobjects/TestObjects.kt +++ b/src/commonTest/kotlin/com/charleskorn/kaml/testobjects/TestObjects.kt @@ -18,6 +18,12 @@ package com.charleskorn.kaml.testobjects +import com.charleskorn.kaml.YamlList +import com.charleskorn.kaml.YamlMap +import com.charleskorn.kaml.YamlNode +import com.charleskorn.kaml.YamlNull +import com.charleskorn.kaml.YamlScalar +import com.charleskorn.kaml.YamlTaggedNode import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -54,3 +60,39 @@ enum class TestEnumWithExplicitNames { @SerialName("With space") WithSpace, } + +@Serializable +data class TestClassWithNestedNode( + val text: String, + val node: YamlNode, +) + +@Serializable +data class TestClassWithNestedScalar( + val text: String, + val node: YamlScalar, +) + +@Serializable +data class TestClassWithNestedNull( + val text: String, + val node: YamlNull, +) + +@Serializable +data class TestClassWithNestedMap( + val text: String, + val node: YamlMap, +) + +@Serializable +data class TestClassWithNestedList( + val text: String, + val node: YamlList, +) + +@Serializable +data class TestClassWithNestedTaggedNode( + val text: String, + val node: YamlTaggedNode, +) From 542d66568e4421016344cd08e68d4eb926309088 Mon Sep 17 00:00:00 2001 From: EdwarDDay <4127904+EdwarDDay@users.noreply.github.com> Date: Sun, 24 Nov 2024 18:01:47 +0100 Subject: [PATCH 2/5] Make Yaml nodes serializable - fix small issues --- .../com/charleskorn/kaml/YamlNodeSerializer.kt | 15 +++++++-------- .../com/charleskorn/kaml/YamlWritingTest.kt | 8 ++++---- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt index e6d4bc8c..44b34edc 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt @@ -20,7 +20,6 @@ package com.charleskorn.kaml -import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer @@ -39,8 +38,11 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.encodeStructure -internal object YamlNodeSerializer : YamlContentPolymorphicSerializer(YamlNode::class) { - override val descriptor: SerialDescriptor = super.descriptor.nullable +internal object YamlNodeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildSerialDescriptor("com.charleskorn.kaml.YamlNode", PolymorphicKind.SEALED) { + annotations += YamlContentPolymorphicSerializer.Marker() + }.nullable override fun serialize(encoder: Encoder, value: YamlNode) { encoder.asYamlOutput() @@ -57,10 +59,6 @@ internal object YamlNodeSerializer : YamlContentPolymorphicSerializer( val input = decoder.asYamlInput() return if (input is YamlPolymorphicInput) YamlTaggedNode(input.typeName, input.node) else input.node } - - override fun selectDeserializer(node: YamlNode): DeserializationStrategy { - error("implemented custom serialize logic") - } } internal object YamlScalarSerializer : KSerializer { @@ -81,7 +79,8 @@ internal object YamlScalarSerializer : KSerializer { return encoder.encodeDouble(value.toDouble()) } catch (_: SerializationException) { } - encoder.encodeString(value.contentToString()) + value.content.singleOrNull()?.also { return encoder.encodeChar(it) } + encoder.encodeString(value.content) } override fun deserialize(decoder: Decoder): YamlScalar { diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt b/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt index bcee2f38..0272484c 100644 --- a/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt +++ b/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt @@ -807,9 +807,9 @@ class YamlWritingTest : FlatFunSpec({ val expectedOutput = """ text: "test" node: - "'foo'": "'bar'" - "'baz'": 1 - "'test'": + "foo": "bar" + "baz": 1 + "test": - 1 - 2 - 3 @@ -1209,7 +1209,7 @@ class YamlWritingTest : FlatFunSpec({ val node = Yaml.default.parseToYamlNode("!testtag 2024-01-01") as YamlTaggedNode val expectedOutput = """ text: "test" - node: !testtag "'2024-01-01'" + node: !testtag "2024-01-01" """.trimIndent() context("as tagged node") { val value = TestClassWithNestedTaggedNode(text = "test", node = node) From 4bd44e1dfbce3a9c9a0d4b90abc428cff45e90b6 Mon Sep 17 00:00:00 2001 From: EdwarDDay <4127904+EdwarDDay@users.noreply.github.com> Date: Sun, 15 Dec 2024 11:55:35 +0100 Subject: [PATCH 3/5] Make Yaml nodes serializable - avoid exception catching in scalar serializer --- .../kotlin/com/charleskorn/kaml/YamlNode.kt | 32 +++++++++++++++---- .../charleskorn/kaml/YamlNodeSerializer.kt | 18 +++-------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt index 08213376..daf4c4e1 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt @@ -42,10 +42,16 @@ public data class YamlScalar(val content: String, override val path: YamlPath) : public fun toShort(): Short = convertToIntegerLikeValue(String::toShort, "short") public fun toInt(): Int = convertToIntegerLikeValue(String::toInt, "integer") public fun toLong(): Long = convertToIntegerLikeValue(String::toLong, "long") + internal fun toLongOrNull(): Long? = convertToIntegerLikeValueOrNull(String::toLongOrNull) private fun convertToIntegerLikeValue(converter: (String, Int) -> T, description: String): T { - try { - return when { + return convertToIntegerLikeValueOrNull(converter) + ?: throw YamlScalarFormatException("Value '$content' is not a valid $description value.", path, content) + } + + private fun convertToIntegerLikeValueOrNull(converter: (String, Int) -> T?): T? { + return try { + when { content.startsWith("0x") -> converter(content.substring(2), 16) content.startsWith("-0x") -> converter("-" + content.substring(3), 16) content.startsWith("0o") -> converter(content.substring(2), 8) @@ -53,7 +59,7 @@ public data class YamlScalar(val content: String, override val path: YamlPath) : else -> converter(content, 10) } } catch (e: NumberFormatException) { - throw YamlScalarFormatException("Value '$content' is not a valid $description value.", path, content) + null } } @@ -76,6 +82,11 @@ public data class YamlScalar(val content: String, override val path: YamlPath) : } public fun toDouble(): Double { + return toDoubleOrNull() + ?: throw YamlScalarFormatException("Value '$content' is not a valid floating point value.", path, content) + } + + internal fun toDoubleOrNull(): Double? { return when (content) { ".inf", ".Inf", ".INF" -> Double.POSITIVE_INFINITY "-.inf", "-.Inf", "-.INF" -> Double.NEGATIVE_INFINITY @@ -84,24 +95,31 @@ public data class YamlScalar(val content: String, override val path: YamlPath) : try { content.toDouble() } catch (e: NumberFormatException) { - throw YamlScalarFormatException("Value '$content' is not a valid floating point value.", path, content) + null } catch (e: IndexOutOfBoundsException) { // Workaround for https://youtrack.jetbrains.com/issue/KT-69327 // TODO: remove once it is fixed - throw YamlScalarFormatException("Value '$content' is not a valid floating point value.", path, content) + null } } } public fun toBoolean(): Boolean { + return toBooleanOrNull() + ?: throw YamlScalarFormatException("Value '$content' is not a valid boolean, permitted choices are: true or false", path, content) + } + + internal fun toBooleanOrNull(): Boolean? { return when (content) { "true", "True", "TRUE" -> true "false", "False", "FALSE" -> false - else -> throw YamlScalarFormatException("Value '$content' is not a valid boolean, permitted choices are: true or false", path, content) + else -> null } } - public fun toChar(): Char = content.singleOrNull() ?: throw YamlScalarFormatException("Value '$content' is not a valid character value.", path, content) + public fun toChar(): Char = toCharOrNull() ?: throw YamlScalarFormatException("Value '$content' is not a valid character value.", path, content) + + internal fun toCharOrNull(): Char? = content.singleOrNull() override fun withPath(newPath: YamlPath): YamlScalar = this.copy(path = newPath) diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt index 44b34edc..c7debfdf 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt @@ -23,7 +23,6 @@ package com.charleskorn.kaml import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer @@ -67,19 +66,10 @@ internal object YamlScalarSerializer : KSerializer { override fun serialize(encoder: Encoder, value: YamlScalar) { encoder.asYamlOutput() - try { - return encoder.encodeBoolean(value.toBoolean()) - } catch (_: SerializationException) { - } - try { - return encoder.encodeLong(value.toLong()) - } catch (_: SerializationException) { - } - try { - return encoder.encodeDouble(value.toDouble()) - } catch (_: SerializationException) { - } - value.content.singleOrNull()?.also { return encoder.encodeChar(it) } + value.toBooleanOrNull()?.also { return encoder.encodeBoolean(it) } + value.toLongOrNull()?.also { return encoder.encodeLong(it) } + value.toDoubleOrNull()?.also { return encoder.encodeDouble(it) } + value.toCharOrNull()?.also { return encoder.encodeChar(it) } encoder.encodeString(value.content) } From fd952dc6fd19057ac0fb815e3efa64e96bc0aee9 Mon Sep 17 00:00:00 2001 From: EdwarDDay <4127904+EdwarDDay@users.noreply.github.com> Date: Sun, 15 Dec 2024 12:21:43 +0100 Subject: [PATCH 4/5] Make Yaml nodes serializable - tests for scalar output --- .../com/charleskorn/kaml/YamlWritingTest.kt | 81 ++++++++++++++++++- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt b/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt index 0272484c..892b60ec 100644 --- a/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt +++ b/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt @@ -312,12 +312,12 @@ class YamlWritingTest : FlatFunSpec({ } context("serializing nested scalar node") { - val node = Yaml.default.parseToYamlNode("1.2") as YamlScalar - val expectedOutput = """ + context("as scalar node") { + val node = YamlScalar("1.2", YamlPath.root) + val expectedOutput = """ text: "test" node: 1.2 """.trimIndent() - context("as scalar node") { val value = TestClassWithNestedScalar(text = "test", node = node) val output = Yaml.default.encodeToString(TestClassWithNestedScalar.serializer(), value) @@ -327,6 +327,81 @@ class YamlWritingTest : FlatFunSpec({ } context("as general node") { + val node = YamlScalar("1.2", YamlPath.root) + val expectedOutput = """ + text: "test" + node: 1.2 + """.trimIndent() + val value = TestClassWithNestedNode(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + + context("of boolean") { + val node = YamlScalar("true", YamlPath.root) + val expectedOutput = """ + text: "test" + node: true + """.trimIndent() + val value = TestClassWithNestedNode(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + + context("of integer like number") { + val node = YamlScalar("-5", YamlPath.root) + val expectedOutput = """ + text: "test" + node: -5 + """.trimIndent() + val value = TestClassWithNestedNode(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + + context("of floating point number") { + val node = YamlScalar("5.34", YamlPath.root) + val expectedOutput = """ + text: "test" + node: 5.34 + """.trimIndent() + val value = TestClassWithNestedNode(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + + context("of character") { + val node = YamlScalar("%", YamlPath.root) + val expectedOutput = """ + text: "test" + node: "%" + """.trimIndent() + val value = TestClassWithNestedNode(text = "test", node = node) + val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedOutput + } + } + + context("of string") { + val node = YamlScalar("foo bar \n 42", YamlPath.root) + val expectedOutput = """ + text: "test" + node: "foo bar \n 42" + """.trimIndent() val value = TestClassWithNestedNode(text = "test", node = node) val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) From aadea3a30cb41c7bbc1d261e5eb166e0b00fea99 Mon Sep 17 00:00:00 2001 From: EdwarDDay <4127904+EdwarDDay@users.noreply.github.com> Date: Sun, 15 Dec 2024 12:32:21 +0100 Subject: [PATCH 5/5] Make Yaml nodes serializable - fix formatting --- .../kotlin/com/charleskorn/kaml/YamlNode.kt | 2 +- .../kotlin/com/charleskorn/kaml/YamlWritingTest.kt | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt index daf4c4e1..1e579fd8 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt @@ -50,7 +50,7 @@ public data class YamlScalar(val content: String, override val path: YamlPath) : } private fun convertToIntegerLikeValueOrNull(converter: (String, Int) -> T?): T? { - return try { + return try { when { content.startsWith("0x") -> converter(content.substring(2), 16) content.startsWith("-0x") -> converter("-" + content.substring(3), 16) diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt b/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt index 892b60ec..8aaa5188 100644 --- a/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt +++ b/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt @@ -317,7 +317,7 @@ class YamlWritingTest : FlatFunSpec({ val expectedOutput = """ text: "test" node: 1.2 - """.trimIndent() + """.trimIndent() val value = TestClassWithNestedScalar(text = "test", node = node) val output = Yaml.default.encodeToString(TestClassWithNestedScalar.serializer(), value) @@ -331,7 +331,7 @@ class YamlWritingTest : FlatFunSpec({ val expectedOutput = """ text: "test" node: 1.2 - """.trimIndent() + """.trimIndent() val value = TestClassWithNestedNode(text = "test", node = node) val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) @@ -345,7 +345,7 @@ class YamlWritingTest : FlatFunSpec({ val expectedOutput = """ text: "test" node: true - """.trimIndent() + """.trimIndent() val value = TestClassWithNestedNode(text = "test", node = node) val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) @@ -359,7 +359,7 @@ class YamlWritingTest : FlatFunSpec({ val expectedOutput = """ text: "test" node: -5 - """.trimIndent() + """.trimIndent() val value = TestClassWithNestedNode(text = "test", node = node) val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) @@ -373,7 +373,7 @@ class YamlWritingTest : FlatFunSpec({ val expectedOutput = """ text: "test" node: 5.34 - """.trimIndent() + """.trimIndent() val value = TestClassWithNestedNode(text = "test", node = node) val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) @@ -387,7 +387,7 @@ class YamlWritingTest : FlatFunSpec({ val expectedOutput = """ text: "test" node: "%" - """.trimIndent() + """.trimIndent() val value = TestClassWithNestedNode(text = "test", node = node) val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value) @@ -401,7 +401,7 @@ class YamlWritingTest : FlatFunSpec({ val expectedOutput = """ text: "test" node: "foo bar \n 42" - """.trimIndent() + """.trimIndent() val value = TestClassWithNestedNode(text = "test", node = node) val output = Yaml.default.encodeToString(TestClassWithNestedNode.serializer(), value)