Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Make Yaml nodes serializable #642

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
charleskorn marked this conversation as resolved.
Show resolved Hide resolved
else -> throw IncorrectTypeException("Expected ${descriptor.kind.friendlyDescription}, but got a list", node.path)
}

Expand Down
40 changes: 33 additions & 7 deletions src/commonMain/kotlin/com/charleskorn/kaml/YamlNode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'"
Expand All @@ -38,18 +42,24 @@ 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 <T> 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 <T : Any> 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)
content.startsWith("-0o") -> converter("-" + content.substring(3), 8)
else -> converter(content, 10)
}
} catch (e: NumberFormatException) {
throw YamlScalarFormatException("Value '$content' is not a valid $description value.", path, content)
null
}
}

Expand All @@ -72,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
Expand All @@ -80,37 +95,46 @@ 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)

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"
override fun withPath(newPath: YamlPath): YamlNull = YamlNull(newPath)
override fun toString(): String = "null @ $path"
}

@Serializable(with = YamlListSerializer::class)
public data class YamlList(val items: List<YamlNode>, override val path: YamlPath) : YamlNode(path) {
override fun equivalentContentTo(other: YamlNode): Boolean {
if (other !is YamlList) {
Expand Down Expand Up @@ -152,6 +176,7 @@ public data class YamlList(val items: List<YamlNode>, override val path: YamlPat
}
}

@Serializable(with = YamlMapSerializer::class)
public data class YamlMap(val entries: Map<YamlScalar, YamlNode>, override val path: YamlPath) : YamlNode(path) {
init {
val keys = entries.keys.sortedWith { a, b ->
Expand Down Expand Up @@ -240,6 +265,7 @@ public data class YamlMap(val entries: Map<YamlScalar, YamlNode>, 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) {
Expand Down
162 changes: 162 additions & 0 deletions src/commonMain/kotlin/com/charleskorn/kaml/YamlNodeSerializer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*

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.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.KSerializer
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 : KSerializer<YamlNode> {
override val descriptor: SerialDescriptor =
buildSerialDescriptor("com.charleskorn.kaml.YamlNode", PolymorphicKind.SEALED) {
annotations += YamlContentPolymorphicSerializer.Marker()
}.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<YamlInput>()
return if (input is YamlPolymorphicInput) YamlTaggedNode(input.typeName, input.node) else input.node
}
}

internal object YamlScalarSerializer : KSerializer<YamlScalar> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("com.charleskorn.kaml.YamlScalar", PrimitiveKind.STRING)

override fun serialize(encoder: Encoder, value: YamlScalar) {
encoder.asYamlOutput()
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)
}

override fun deserialize(decoder: Decoder): YamlScalar {
val result = decoder.asYamlInput<YamlScalarInput>()
return result.scalar
}
}

@OptIn(ExperimentalSerializationApi::class)
internal object YamlNullSerializer : KSerializer<YamlNull> {
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<YamlNullInput>()
return input.nullValue
}
}

internal object YamlTaggedNodeSerializer : KSerializer<YamlTaggedNode> {

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<YamlPolymorphicInput>()
return YamlTaggedNode(input.typeName, input.contentNode)
}
}

internal object YamlMapSerializer : KSerializer<YamlMap> {

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<YamlMapInput>()
return input.node as YamlMap
}
}

internal object YamlListSerializer : KSerializer<YamlList> {

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<YamlListInput>()
return input.list
}
}

private inline fun <reified I : YamlInput> 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}"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions src/commonTest/kotlin/com/charleskorn/kaml/YamlNullReadingTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<YamlNull>()
}
}

context("parsing that input as a node") {
val result = Yaml.default.decodeFromString(TestClassWithNestedNode.serializer(), input)

test("deserializes node to null") {
result.node.shouldBeInstanceOf<YamlNull>()
}
}
}
}
})
Loading
Loading