Skip to content

Commit

Permalink
Support tagged nodes
Browse files Browse the repository at this point in the history
- implement charleskorn#4
- support yaml tags for polymorphic de-/serialization
  • Loading branch information
Eduard Wolf committed Sep 9, 2019
1 parent 65c9f1c commit 30fc12b
Show file tree
Hide file tree
Showing 9 changed files with 485 additions and 10 deletions.
2 changes: 1 addition & 1 deletion src/main/kotlin/com/charleskorn/kaml/Yaml.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class Yaml(
override fun flush() { }
}

val output = YamlOutput(writer, configuration)
val output = YamlOutput(writer, this)
output.encode(serializer, obj)

return writer.toString()
Expand Down
49 changes: 48 additions & 1 deletion src/main/kotlin/com/charleskorn/kaml/YamlInput.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ sealed class YamlInput(val node: YamlNode, override var context: SerialModule, v
is YamlNull -> YamlNullInput(node, context, configuration)
is YamlList -> YamlListInput(node, context, configuration)
is YamlMap -> YamlMapInput(node, context, configuration)
is YamlTaggedNode -> YamlTaggedInput(node, context, configuration)
}
}

Expand Down Expand Up @@ -214,7 +215,7 @@ private class YamlMapInput(val map: YamlMap, context: SerialModule, configuratio

private fun getPropertyName(key: YamlNode): String = when (key) {
is YamlScalar -> key.content
is YamlNull, is YamlMap, is YamlList -> throw MalformedYamlException("Property name must not be a list, map or null value. (To use 'null' as a property name, enclose it in quotes.)", key.location)
is YamlNull, is YamlMap, is YamlList, is YamlTaggedNode -> throw MalformedYamlException("Property name must not be a list, map, null or tagged value. (To use 'null' as a property name, enclose it in quotes.)", key.location)
}

private fun throwUnknownProperty(name: String, location: Location, desc: SerialDescriptor): Nothing {
Expand Down Expand Up @@ -289,3 +290,49 @@ private class YamlMapInput(val map: YamlMap, context: SerialModule, configuratio

override fun getCurrentLocation(): Location = currentValueDecoder.node.location
}

private class YamlTaggedInput(val taggedNode: YamlTaggedNode, context: SerialModule, configuration: YamlConfiguration) : YamlInput(taggedNode, context, configuration) {
/**
* index 0 -> tag
* index 1 -> child node
*/
private var currentIndex = -1
private val childDecoder: YamlInput = createFor(taggedNode.node, context, configuration)

override fun getCurrentLocation(): Location = if (currentIndex == 1) childDecoder.getCurrentLocation() else taggedNode.location

override fun decodeNotNullMark(): Boolean = when (currentIndex) {
0 -> true
1 -> childDecoder.decodeNotNullMark()
else -> super.decodeNotNullMark()
}

override fun decodeElementIndex(desc: SerialDescriptor): Int {
return when (++currentIndex) {
0, 1 -> currentIndex
else -> READ_DONE
}
}

override fun decodeString(): String {
return when (currentIndex) {
0 -> taggedNode.tag
1 -> childDecoder.decodeString()
else -> super.decodeString()
}
}

override fun decodeNull(): Nothing? = if (currentIndex == 1) childDecoder.decodeNull() else super.decodeNull()
override fun decodeUnit() = if (currentIndex == 1) childDecoder.decodeUnit() else super.decodeUnit()
override fun decodeInt(): Int = if (currentIndex == 1) childDecoder.decodeInt() else super.decodeInt()
override fun decodeLong(): Long = if (currentIndex == 1) childDecoder.decodeLong() else super.decodeLong()
override fun decodeShort(): Short = if (currentIndex == 1) childDecoder.decodeShort() else super.decodeShort()
override fun decodeByte(): Byte = if (currentIndex == 1) childDecoder.decodeByte() else super.decodeByte()
override fun decodeDouble(): Double = if (currentIndex == 1) childDecoder.decodeDouble() else super.decodeDouble()
override fun decodeFloat(): Float = if (currentIndex == 1) childDecoder.decodeFloat() else super.decodeFloat()
override fun decodeBoolean(): Boolean = if (currentIndex == 1) childDecoder.decodeBoolean() else super.decodeBoolean()
override fun decodeChar(): Char = if (currentIndex == 1) childDecoder.decodeChar() else super.decodeChar()
override fun decodeEnum(enumDescription: EnumDescriptor): Int = if (currentIndex == 1) childDecoder.decodeEnum(enumDescription) else super.decodeEnum(enumDescription)
override fun beginStructure(desc: SerialDescriptor, vararg typeParams: KSerializer<*>): CompositeDecoder =
if (currentIndex == 1) childDecoder.beginStructure(desc, *typeParams) else super.beginStructure(desc, *typeParams)
}
16 changes: 16 additions & 0 deletions src/main/kotlin/com/charleskorn/kaml/YamlNode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,19 @@ data class YamlMap(val entries: Map<YamlNode, YamlNode>, override val location:
override fun contentToString(): String =
"{" + entries.map { (key, value) -> "${key.contentToString()}: ${value.contentToString()}" }.joinToString(", ") + "}"
}

data class YamlTaggedNode(val tag: String, val node: YamlNode) : YamlNode(node.location) {
override fun equivalentContentTo(other: YamlNode): Boolean {
if (other !is YamlTaggedNode) {
return false
}

if (tag != other.tag) {
return false
}

return node.equivalentContentTo(other.node)
}

override fun contentToString(): String = "!$tag ${node.contentToString()}"
}
10 changes: 7 additions & 3 deletions src/main/kotlin/com/charleskorn/kaml/YamlNodeReader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import org.snakeyaml.engine.v1.events.MappingStartEvent
import org.snakeyaml.engine.v1.events.NodeEvent
import org.snakeyaml.engine.v1.events.ScalarEvent
import org.snakeyaml.engine.v1.events.SequenceStartEvent
import java.util.Optional

class YamlNodeReader(
private val parser: YamlParser,
Expand Down Expand Up @@ -52,9 +53,9 @@ class YamlNodeReader(
}

private fun readFromEvent(event: Event, isTopLevel: Boolean): YamlNode = when (event) {
is ScalarEvent -> readScalarOrNull(event)
is SequenceStartEvent -> readSequence(event.location)
is MappingStartEvent -> readMapping(event.location, isTopLevel)
is ScalarEvent -> readScalarOrNull(event).maybeToTaggedNode(event.tag)
is SequenceStartEvent -> readSequence(event.location).maybeToTaggedNode(event.tag)
is MappingStartEvent -> readMapping(event.location, isTopLevel).maybeToTaggedNode(event.tag)
is AliasEvent -> readAlias(event)
else -> throw MalformedYamlException("Unexpected ${event.eventId}", event.location)
}
Expand Down Expand Up @@ -112,6 +113,9 @@ class YamlNodeReader(
}
}

private fun YamlNode.maybeToTaggedNode(tag: Optional<String>): YamlNode =
tag.map<YamlNode> { YamlTaggedNode(it, this) }.orElse(this)

private fun YamlNode.isScalarAndStartsWith(prefix: String): Boolean = this is YamlScalar && this.content.startsWith(prefix)

private fun doMerges(items: Map<YamlNode, YamlNode>): Map<YamlNode, YamlNode> {
Expand Down
38 changes: 34 additions & 4 deletions src/main/kotlin/com/charleskorn/kaml/YamlOutput.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ package com.charleskorn.kaml
import kotlinx.serialization.CompositeEncoder
import kotlinx.serialization.ElementValueEncoder
import kotlinx.serialization.KSerializer
import kotlinx.serialization.PolymorphicSerializer
import kotlinx.serialization.SerialDescriptor
import kotlinx.serialization.SerializationStrategy
import kotlinx.serialization.StructureKind
import kotlinx.serialization.internal.EnumDescriptor
import kotlinx.serialization.modules.SerialModule
import org.snakeyaml.engine.v1.api.DumpSettingsBuilder
import org.snakeyaml.engine.v1.api.StreamDataWriter
import org.snakeyaml.engine.v1.common.FlowStyle
Expand All @@ -41,10 +44,19 @@ import java.util.Optional

internal class YamlOutput(
writer: StreamDataWriter,
private val configuration: YamlConfiguration
private val yaml: Yaml
) : ElementValueEncoder() {
private val settings = DumpSettingsBuilder().build()
private val emitter = Emitter(settings, writer)
private var currentTag: String? = null
private val configuration: YamlConfiguration get() = yaml.configuration
override val context: SerialModule get() = yaml.context
private val yamlTag: Optional<String>
get() {
return Optional.ofNullable(currentTag).also {
currentTag = null
}
}

init {
emitter.emit(StreamStartEvent())
Expand Down Expand Up @@ -75,10 +87,24 @@ internal class YamlOutput(
return super.encodeElement(desc, index)
}

override fun <T> encodeSerializableValue(serializer: SerializationStrategy<T>, value: T) {
if (serializer !is PolymorphicSerializer<*>) {
serializer.serialize(this, value)
return
}

@Suppress("UNCHECKED_CAST")
val actualSerializer = serializer.findPolymorphicSerializer(this, value as Any) as KSerializer<Any>
currentTag = actualSerializer.descriptor.name
actualSerializer.serialize(this, value)
}

override fun beginStructure(desc: SerialDescriptor, vararg typeParams: KSerializer<*>): CompositeEncoder {
val tag = yamlTag
val implicit = tag.isEmpty()
when (desc.kind) {
is StructureKind.LIST -> emitter.emit(SequenceStartEvent(Optional.empty(), Optional.empty(), true, FlowStyle.BLOCK))
is StructureKind.MAP, StructureKind.CLASS -> emitter.emit(MappingStartEvent(Optional.empty(), Optional.empty(), true, FlowStyle.BLOCK))
is StructureKind.LIST -> emitter.emit(SequenceStartEvent(Optional.empty(), tag, implicit, FlowStyle.BLOCK))
is StructureKind.MAP, StructureKind.CLASS -> emitter.emit(MappingStartEvent(Optional.empty(), tag, implicit, FlowStyle.BLOCK))
}

return super.beginStructure(desc, *typeParams)
Expand All @@ -94,5 +120,9 @@ internal class YamlOutput(
}

private fun emitScalar(value: String, style: ScalarStyle) =
emitter.emit(ScalarEvent(Optional.empty(), Optional.empty(), ImplicitTuple(true, true), value, style))
emitter.emit(ScalarEvent(Optional.empty(), yamlTag, ALL_IMPLICIT, value, style))

companion object {
private val ALL_IMPLICIT = ImplicitTuple(true, true)
}
}
123 changes: 122 additions & 1 deletion src/test/kotlin/com/charleskorn/kaml/YamlReadingTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,17 @@ import ch.tutteli.atrium.api.cc.en_GB.message
import ch.tutteli.atrium.api.cc.en_GB.toBe
import ch.tutteli.atrium.api.cc.en_GB.toThrow
import ch.tutteli.atrium.verbs.assert
import com.charleskorn.kaml.testobjects.InterfaceInt
import com.charleskorn.kaml.testobjects.InterfaceString
import com.charleskorn.kaml.testobjects.InterfaceWrapper
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.TestEnum
import com.charleskorn.kaml.testobjects.TestSealedStructure
import com.charleskorn.kaml.testobjects.interfaceModule
import com.charleskorn.kaml.testobjects.sealedModule
import kotlinx.serialization.ContextualSerialization
import kotlinx.serialization.Decoder
import kotlinx.serialization.Encoder
Expand Down Expand Up @@ -831,6 +838,120 @@ object YamlReadingTest : Spek({
}
}

val sealedYaml = Yaml(context = sealedModule)

context("given some tagged input representing an object where the resulting type should be a sealed class (int)") {
val input = """
element: !<sealedInt>
value: 3
""".trimIndent()

context("parsing that input") {
val result = sealedYaml.parse(SealedWrapper.serializer(), input)
it("deserializes it to a Kotlin object") {
assert(result).toBe(SealedWrapper(TestSealedStructure.SimpleSealedInt(3)))
}
}
}

context("given some tagged input representing an object where the resulting type should be a sealed class (string)") {
val input = """
element: !<sealedString>
value: "asdfg"
""".trimIndent()

context("parsing that input") {
val result = sealedYaml.parse(SealedWrapper.serializer(), input)
it("deserializes it to a Kotlin object") {
assert(result).toBe(SealedWrapper(TestSealedStructure.SimpleSealedString("asdfg")))
}
}
}

context("given some tagged input representing a list of objects where the resulting type should be a sealed class") {
val input = """
- element: !<sealedString>
value: "some"
- element: !<sealedInt>
value: -987
- element: !<sealedInt>
value: 654
- element: !<sealedString>
value: "tests"
""".trimIndent()

context("parsing that input") {
val result = sealedYaml.parse(SealedWrapper.serializer().list, input)
it("deserializes it to a Kotlin object") {
assert(result).toBe(
listOf(
SealedWrapper(TestSealedStructure.SimpleSealedString("some")),
SealedWrapper(TestSealedStructure.SimpleSealedInt(-987)),
SealedWrapper(TestSealedStructure.SimpleSealedInt(654)),
SealedWrapper(TestSealedStructure.SimpleSealedString("tests"))
)
)
}
}
}

val interfaceYaml = Yaml(context = interfaceModule)

context("given some tagged input representing an object where the resulting type should be an interface (int)") {
val input = """
test: !<interfaceInt>
intVal: 55
""".trimIndent()

context("parsing that input") {
val result = interfaceYaml.parse(InterfaceWrapper.serializer(), input)
it("deserializes it to a Kotlin object") {
assert(result).toBe(InterfaceWrapper(InterfaceInt(55)))
}
}
}

context("given some tagged input representing an object where the resulting type should be an interface (string)") {
val input = """
test: !<interfaceString>
stringVal: "kudo"
""".trimIndent()

context("parsing that input") {
val result = interfaceYaml.parse(InterfaceWrapper.serializer(), input)
it("deserializes it to a Kotlin object") {
assert(result).toBe(InterfaceWrapper(InterfaceString("kudo")))
}
}
}

context("given some tagged input representing a list of objects where the resulting type should be an interface") {
val input = """
- test: !<interfaceInt>
intVal: 321
- test: !<interfaceString>
stringVal: "hello"
- test: !<interfaceString>
stringVal: "world"
- test: !<interfaceInt>
intVal: 890
""".trimIndent()

context("parsing that input") {
val result = interfaceYaml.parse(InterfaceWrapper.serializer().list, input)
it("deserializes it to a Kotlin object") {
assert(result).toBe(
listOf(
InterfaceWrapper(InterfaceInt(321)),
InterfaceWrapper(InterfaceString("hello")),
InterfaceWrapper(InterfaceString("world")),
InterfaceWrapper(InterfaceInt(890))
)
)
}
}
}

context("given some input representing an object with an unknown key") {
val input = """
abc123: something
Expand All @@ -857,7 +978,7 @@ object YamlReadingTest : Spek({
context("parsing that input") {
it("throws an appropriate exception") {
assert({ Yaml.default.parse(ComplexStructure.serializer(), input) }).toThrow<MalformedYamlException> {
message { toBe("Property name must not be a list, map or null value. (To use 'null' as a property name, enclose it in quotes.)") }
message { toBe("Property name must not be a list, map, null or tagged value. (To use 'null' as a property name, enclose it in quotes.)") }
line { toBe(1) }
column { toBe(1) }
}
Expand Down
Loading

0 comments on commit 30fc12b

Please sign in to comment.