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

Introduce new StringProperties object that can encode and decode properties from a string #2303

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
47 changes: 47 additions & 0 deletions formats/properties/api/kotlinx-serialization-properties.api
Original file line number Diff line number Diff line change
@@ -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 <init> (Lkotlinx/serialization/modules/SerializersModule;Ljava/lang/Void;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand All @@ -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 <init> (Lkotlinx/serialization/properties/PropertiesConf;Lkotlinx/serialization/properties/Properties;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (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;
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <T> encodeToString(serializer: SerializationStrategy<T>, value: T): String {
val map = properties.encodeToMap(serializer, value)
val builder = StringBuilder()
Copy link
Contributor

@hfhbd hfhbd Jan 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would extract the actual output renderer into another function taking a map overload to encode a giving String map to a Properties file

@ExperimentalSerializationApi
public fun encodeToString(map: Map<String, String>): String {

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 <T> decodeFromString(deserializer: DeserializationStrategy<T>, string: String): T {
val result = mutableMapOf<String, String>()
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, I would add an overload to get the input string as Map<String, String too.

}

/**
* 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 <reified T> 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 <reified T> 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<String> {
val commentFilter = "[ \\t\\f]*[#!].*".toRegex()
val lines = lines()
.filterNot { it.isBlank() || commentFilter.matches(it) }
.toMutableList()
val logicalLines = mutableListOf<String>()

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
}