Skip to content

Commit

Permalink
Add basic serialization config.
Browse files Browse the repository at this point in the history
  • Loading branch information
valderman committed Jun 25, 2022
1 parent 93d88a8 commit c2678aa
Show file tree
Hide file tree
Showing 11 changed files with 740 additions and 205 deletions.
45 changes: 45 additions & 0 deletions src/main/kotlin/cc/ekblad/toml/TomlSerializer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package cc.ekblad.toml

import cc.ekblad.toml.configuration.TomlSerializerConfigurator
import cc.ekblad.toml.model.TomlDocument
import cc.ekblad.toml.serialization.TomlSerializerConfig
import cc.ekblad.toml.serialization.TomlSerializerState
import cc.ekblad.toml.serialization.writePath
import cc.ekblad.toml.util.JacocoIgnore
import java.io.OutputStream
import java.io.PrintStream
import java.nio.file.Path
import kotlin.io.path.outputStream

class TomlSerializer internal constructor(private val config: TomlSerializerConfig) {
/**
* Serializes the given [TomlDocument] into a valid TOML document using the receiver [TomlSerializer]
* and writes it to the given [Appendable].
*/
fun write(tomlDocument: TomlDocument, output: Appendable) {
TomlSerializerState(config, output).writePath(tomlDocument, emptyList())
}

/**
* Serializes the given [TomlDocument] into a valid TOML document using the receiver [TomlSerializer]
* and writes it to the given [Appendable].
*/
fun write(tomlDocument: TomlDocument, outputStream: OutputStream) {
write(tomlDocument, PrintStream(outputStream) as Appendable)
}

/**
* Serializes the given [TomlDocument] into a valid TOML document using the receiver [TomlSerializer]
* and writes it to the given [Appendable].
*/
@JacocoIgnore("JaCoCo thinks use isn't being called, even though it also thinks use's argument IS called")
fun write(tomlDocument: TomlDocument, path: Path) {
path.outputStream().use { write(tomlDocument, it) }
}
}

fun tomlSerializer(configuration: TomlSerializerConfigurator.() -> Unit): TomlSerializer {
val configurator = TomlSerializerConfigurator()
configurator.configuration()
return TomlSerializer(configurator.buildConfig())
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package cc.ekblad.toml.configuration
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.transcoding.TomlDecoder
import cc.ekblad.toml.transcoding.TomlEncoder
import cc.ekblad.toml.util.Generated
import cc.ekblad.toml.util.InternalAPI
import cc.ekblad.toml.util.JacocoIgnore
import cc.ekblad.toml.util.KotlinName
import cc.ekblad.toml.util.TomlName
import kotlin.reflect.KClass
Expand Down Expand Up @@ -86,7 +86,7 @@ class TomlMapperConfigurator internal constructor(
*
*/
inline fun <reified T : Any> encoder(noinline encoder: TomlEncoder.(kotlinValue: T) -> TomlValue) {
encoder(T::class) @Generated { value ->
encoder(T::class) @JacocoIgnore("inlined") { value ->
if (value !is T) {
pass()
}
Expand Down Expand Up @@ -146,7 +146,7 @@ class TomlMapperConfigurator internal constructor(
inline fun <reified T : TomlValue, reified R : Any> decoder(
noinline decoder: TomlDecoder.(targetType: KType, tomlValue: T) -> R?
) {
decoder(R::class) @Generated { kType, value ->
decoder(R::class) @JacocoIgnore("inlined") { kType, value ->
if (value !is T) {
pass()
}
Expand Down Expand Up @@ -175,7 +175,7 @@ class TomlMapperConfigurator internal constructor(
* Convenience overload for [decoder], for when you don't need to consider the full target KType.
*/
inline fun <reified T : TomlValue, reified R : Any> decoder(crossinline decoder: TomlDecoder.(tomlValue: T) -> R?) =
decoder<T, R> @Generated { _, it -> decoder(it) }
decoder<T, R> @JacocoIgnore("inlined") { _, it -> decoder(it) }

@InternalAPI
fun <T : Any> mapping(kClass: KClass<T>, mappings: List<Pair<TomlName, KotlinName>>) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cc.ekblad.toml.configuration

import cc.ekblad.toml.serialization.CollectionSyntax
import cc.ekblad.toml.serialization.InlineListMode
import cc.ekblad.toml.serialization.TomlSerializerConfig

class TomlSerializerConfigurator internal constructor() {
private var indentStep: Int = TomlSerializerConfig.default.indentStep
private var inlineListMode: ListMode = TomlSerializerConfig.default.inlineListMode
private var preferredTableSyntax: CollectionSyntax = TomlSerializerConfig.default.preferredTableSyntax
private var preferredListSyntax: CollectionSyntax = TomlSerializerConfig.default.preferredListSyntax

/**
* Indent each nesting level of a multi-line lists by this many spaces.
*
* Default: 4
*/
fun indentStep(spaces: Int) {
indentStep = spaces
}

/**
* How should inline lists be serialized?
*
* - If [ListMode.SingleLine], inline lists are always serialized on a single line.
* - If [ListMode.MultiLine], inline lists are always serialized with one element per line, as long as the TOML
* spec allows it.
* - If [ListMode.Adaptive], inline lists are serialized with one element per line only if they contain one or more
* list or table, as long as the TOML spec allows it.
*
* Default: ListMode.Adaptive
*/
fun inlineListMode(listMode: ListMode) {
inlineListMode = listMode
}

/**
* Should tables be generated using table syntax or inline table syntax (i.e. curly brackets)?
*
* Default: [CollectionSyntax.Table]
*/
fun preferTableSyntax(syntax: CollectionSyntax) {
preferredTableSyntax = syntax
}

/**
* Should tables be generated using table syntax or inline table syntax (i.e. square brackets)?
* If [CollectionSyntax.Table], lists will still be serialized using inline syntax when required to produce
* correct TOML.
*
* Default: [CollectionSyntax.Table]
*/
fun preferListSyntax(syntax: CollectionSyntax) {
preferredListSyntax = syntax
}

internal fun buildConfig(): TomlSerializerConfig = TomlSerializerConfig(
indentStep = indentStep,
inlineListMode = inlineListMode,
preferredTableSyntax = preferredTableSyntax,
preferredListSyntax = preferredListSyntax
)
}

/**
* When should list elements be separated by line breaks instead of spaces?
*/
typealias ListMode = InlineListMode

/**
* Preferred syntax for lists and tables: inline or table?
*/
typealias CollectionSyntax = CollectionSyntax
4 changes: 2 additions & 2 deletions src/main/kotlin/cc/ekblad/toml/serialization/TomlBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package cc.ekblad.toml.serialization

import cc.ekblad.toml.model.TomlException
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.util.Generated
import cc.ekblad.toml.util.JacocoIgnore

internal class TomlBuilder private constructor() {
sealed interface Context {
Expand Down Expand Up @@ -41,7 +41,7 @@ internal class TomlBuilder private constructor() {
}

@JvmInline
@Generated
@JacocoIgnore("Value classes confuse JaCoCo")
private value class ContextImpl(val properties: MutableMap<String, MutableTomlValue>) : Context

fun resetContext() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package cc.ekblad.toml.serialization

import cc.ekblad.toml.model.TomlDocument
import cc.ekblad.toml.model.TomlException
import cc.ekblad.toml.model.TomlValue
import java.io.OutputStream
import java.io.PrintStream
import java.nio.file.Path
import java.time.format.DateTimeFormatter
import kotlin.io.path.outputStream

/**
* Serializes the receiver [TomlDocument] into a valid TOML document using the default serializer
* and writes it to the given [Appendable].
*/
fun TomlDocument.write(output: Appendable) {
TomlSerializerState(TomlSerializerConfig.default, output).writePath(this, emptyList())
}

/**
* Serializes the receiver [TomlDocument] into a valid TOML document using the default serializer
* and writes it to the given [OutputStream].
*/
fun TomlDocument.write(outputStream: OutputStream) {
write(PrintStream(outputStream) as Appendable)
}

/**
* Serializes the receiver [TomlDocument] into a valid TOML document using the default serializer
* and writes it to the file represented by the given [Path].
*/
fun TomlDocument.write(path: Path) {
path.outputStream().use { write(it) }
}

internal fun TomlSerializerState.writePath(value: TomlValue.Map, path: List<String>) {
if (path.isEmpty()) {
writeTopLevel(value)
} else {
value.properties.forEach {
writePath(it.value, path + it.key)
}
}
}

private fun TomlSerializerState.writeTopLevel(map: TomlValue.Map) {
val (inline, table) = map.properties.entries.partition { (_, element) -> shouldBeInline(element) }

inline.forEach { (key, value) ->
writeKeyValue(value, listOf(".", key))
}

if (inline.isNotEmpty() && table.isNotEmpty()) {
appendLine()
}

table.fold(true) { first, it ->
when (val value = it.value) {
is TomlValue.List -> {
value.elements.fold(first) { firstInner, element ->
if (!firstInner) {
appendLine()
}
appendLine("[[${encodeKey(it.key)}]]")
writePath(element, listOf(it.key))
false
}
}
else -> {
if (!first) {
appendLine()
}
appendLine("[${encodeKey(it.key)}]")
writePath(value, listOf(it.key))
}
}
false
}
}

private fun TomlSerializerState.writePath(value: TomlValue, path: List<String>) {
when (value) {
is TomlValue.Map -> writePath(value, path)
is TomlValue.List -> writeKeyValue(value, path)
is TomlValue.Primitive -> writeKeyValue(value, path)
}
}

private fun TomlSerializerState.writeKeyValue(value: TomlValue, path: List<String>) {
append(encodePath(path.drop(1)), " = ")
writeValue(value)
appendLine()
}

private fun TomlSerializerState.writeValue(value: TomlValue) {
when (value) {
is TomlValue.List -> writeValue(value)
is TomlValue.Map -> writeValue(value)
is TomlValue.Bool -> append(value.value.toString())
is TomlValue.Double -> append(value.value.toString())
is TomlValue.Integer -> append(value.value.toString())
is TomlValue.LocalDate -> append(value.value.format(DateTimeFormatter.ISO_LOCAL_DATE))
is TomlValue.LocalDateTime -> append(value.value.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
is TomlValue.LocalTime -> append(value.value.format(DateTimeFormatter.ISO_LOCAL_TIME))
is TomlValue.OffsetDateTime -> append(value.value.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
is TomlValue.String -> writeValue(value)
}
}

private fun TomlSerializerState.writeValue(value: TomlValue.List) {
list(value) {
value.elements.fold(true) { first, element ->
if (!first) {
appendListSeparator()
}
writeValue(element)
false
}
}
}

private fun TomlSerializerState.writeValue(value: TomlValue.Map) {
table {
value.properties.entries.fold(true) { first, it ->
if (!first) {
append(", ")
}
append(encodeKey(it.key), " = ")
writeValue(it.value)
false
}
}
}

private fun encodePath(path: List<String>) =
path.joinToString(".", transform = ::encodeKey)

private fun encodeKey(key: String): String = when {
!key.contains(quotesRequiredRegex) -> key
!key.contains('\n') -> "'$key'"
else -> {
val escapedKey = key.replace("\n", "\\n").replace("\"", "\\\"")
"\"$escapedKey\""
}
}

private val quotesRequiredRegex = Regex("[^a-zA-Z0-9]")

private enum class QuoteType(val quotes: String) {
Plain("\""),
Literal("'"),
Multiline("\"\"\""),
MultilineLiteral("'''")
}

private fun <T> List<T>.removeIf(condition: Boolean, vararg remove: T) = if (condition) {
filter { it !in remove }
} else {
this
}

private fun TomlSerializerState.writeValue(value: TomlValue.String) {
val invalidChars = value.value.filter { it in invalidTomlChars }.toSet()
if (invalidChars.isNotEmpty()) {
val invalidCharString = invalidChars.joinToString(", ") { "\\u${it.code.toString(16)}" }
throw TomlException.SerializationError(
"string contains characters which are not allowed in a toml document: $invalidCharString",
null
)
}
val eligibleQuoteTypes = QuoteType.values().toList()
.removeIf(value.value.contains("''"), QuoteType.Literal, QuoteType.MultilineLiteral)
.removeIf(value.value.contains("\"\""), QuoteType.Plain, QuoteType.Multiline)
.removeIf(value.value.contains("'"), QuoteType.Literal)
.removeIf(value.value.contains("\""), QuoteType.Plain)
.removeIf(value.value.contains("\n"), QuoteType.Literal, QuoteType.Plain)
val quoteType = eligibleQuoteTypes.firstOrNull() ?: QuoteType.Plain
val text = when (quoteType) {
QuoteType.Plain -> value.value.replace("\"", "\\\"").replace("\n", "\\n")
QuoteType.Multiline -> "\n${value.value}"
QuoteType.Literal -> value.value
QuoteType.MultilineLiteral -> "\n${value.value}"
}
append(quoteType.quotes, text, quoteType.quotes)
}

0 comments on commit c2678aa

Please sign in to comment.