Repository: config4k/config4k (kotlin)

Issue:
`kotlin.text.Regex` should be serializable
Inspired by a great work of @alexmihailov in https://github.com/Kotlin/kotlinx.serialization/pull/2137

Pull request:
Fixes #292 
Implementaion of `kotlin.text.Regex` in HOCON config

Patch:
diff --git a/api/config4k.api b/api/config4k.api
index e09bd28..2530eea 100644
--- a/api/config4k.api
+++ b/api/config4k.api
@@ -83,6 +83,15 @@ public final class io/github/config4k/serializers/PeriodSerializer : kotlinx/ser
 	public fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/time/Period;)V
 }
 
+public final class io/github/config4k/serializers/RegexSerializer : kotlinx/serialization/KSerializer {
+	public static final field INSTANCE Lio/github/config4k/serializers/RegexSerializer;
+	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
+	public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lkotlin/text/Regex;
+	public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
+	public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
+	public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lkotlin/text/Regex;)V
+}
+
 public final class io/github/config4k/serializers/TemporalAmountSerializer : kotlinx/serialization/KSerializer {
 	public static final field INSTANCE Lio/github/config4k/serializers/TemporalAmountSerializer;
 	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
diff --git a/src/main/kotlin/io/github/config4k/Config4k.kt b/src/main/kotlin/io/github/config4k/Config4k.kt
index 91ec769..3f44efe 100644
--- a/src/main/kotlin/io/github/config4k/Config4k.kt
+++ b/src/main/kotlin/io/github/config4k/Config4k.kt
@@ -3,18 +3,19 @@ package io.github.config4k
 import io.github.config4k.serializers.ConfigSerializer
 import io.github.config4k.serializers.ConfigValueSerializer
 import io.github.config4k.serializers.PeriodSerializer
+import io.github.config4k.serializers.RegexSerializer
 import io.github.config4k.serializers.TemporalAmountSerializer
 import kotlinx.serialization.hocon.Hocon
 import kotlinx.serialization.hocon.serializers.ConfigMemorySizeSerializer
 import kotlinx.serialization.hocon.serializers.JavaDurationSerializer
 import kotlinx.serialization.modules.SerializersModule
 import kotlinx.serialization.modules.contextual
-import kotlinx.serialization.modules.plus
 
 public val Config4kModule: SerializersModule =
     SerializersModule {
         contextual(JavaDurationSerializer)
         contextual(PeriodSerializer)
+        contextual(RegexSerializer)
         contextual(TemporalAmountSerializer)
         contextual(ConfigSerializer)
         contextual(ConfigValueSerializer)
diff --git a/src/main/kotlin/io/github/config4k/serializers/RegexSerializer.kt b/src/main/kotlin/io/github/config4k/serializers/RegexSerializer.kt
new file mode 100644
index 0000000..c5e09ba
--- /dev/null
+++ b/src/main/kotlin/io/github/config4k/serializers/RegexSerializer.kt
@@ -0,0 +1,79 @@
+package io.github.config4k.serializers
+
+import com.typesafe.config.ConfigValueType.OBJECT
+import com.typesafe.config.ConfigValueType.STRING
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.hocon.HoconDecoder
+import kotlinx.serialization.hocon.HoconEncoder
+
+/**
+ * Serializer for [Regex].
+ *
+ * Usage example:
+ * ```
+ * @Serializable
+ * data class ExampleRegex(
+ *   @Serializable(RegexSerializer::class)
+ *   val regex: Regex,
+ * )
+ * val config = ConfigFactory.parseString("regex = \"7[0-9]{10}\"")
+ * val exampleRegex: ExampleRegex = Hocon.decodeFromConfig(config)
+ * val newConfig = Hocon.encodeToConfig(ExampleRegex.serializer(), exampleRegex)
+ *
+ * val configOps = ConfigFactory.parseString("regex = {pattern=\"7[0-9]{10}\",options=[LITERAL]}")
+ * val exampleRegexOps: ExampleRegex = Hocon.decodeFromConfig(configOps)
+ * val newConfigOps = Hocon.encodeToConfig(ExampleRegex.serializer(), exampleRegexOps)
+ * ```
+ */
+public object RegexSerializer : KSerializer<Regex> {
+    @Serializable
+    private data class SerializableRegex(
+        private val pattern: String,
+        private val options: Set<RegexOption>,
+    ) {
+        constructor(regex: Regex) : this(regex.pattern, regex.options)
+
+        fun toRegex() = Regex(pattern, options)
+    }
+
+    override val descriptor: SerialDescriptor =
+        PrimitiveSerialDescriptor("hocon.kotlin.text.Regex", PrimitiveKind.STRING)
+
+    override fun deserialize(decoder: Decoder): Regex =
+        if (decoder is HoconDecoder) {
+            decoder.decodeConfigValue { conf, path ->
+                when (val type = conf.getValue(path).valueType()) {
+                    STRING -> conf.getString(path).toRegex()
+                    OBJECT -> {
+                        decoder.decodeSerializableValue(SerializableRegex.serializer()).toRegex()
+                    }
+
+                    else -> throw SerializationException("kotlin.text.Regex can't be specified by $type value type")
+                }
+            }
+        } else {
+            throw SerializationException("This class can be decoded only by Hocon format")
+        }
+
+    override fun serialize(
+        encoder: Encoder,
+        value: Regex,
+    ) {
+        if (encoder is HoconEncoder) {
+            if (value.options.isEmpty()) {
+                encoder.encodeString(value.pattern)
+            } else {
+                encoder.encodeSerializableValue(SerializableRegex.serializer(), SerializableRegex(value))
+            }
+        } else {
+            throw SerializationException("This class can be encoded only by Hocon format")
+        }
+    }
+}


Test patch:
diff --git a/src/test/kotlin/io/github/config4k/serializers/RegexSerializerTest.kt b/src/test/kotlin/io/github/config4k/serializers/RegexSerializerTest.kt
new file mode 100644
index 0000000..7745cfa
--- /dev/null
+++ b/src/test/kotlin/io/github/config4k/serializers/RegexSerializerTest.kt
@@ -0,0 +1,260 @@
+package io.github.config4k.serializers
+
+import com.typesafe.config.Config
+import io.github.config4k.Config4k
+import io.github.config4k.render
+import io.github.config4k.toConfig
+import io.mockk.mockk
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.MissingFieldException
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.hocon.decodeFromConfig
+import kotlinx.serialization.hocon.encodeToConfig
+import org.assertj.core.api.Assertions.assertThat
+import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.assertj.core.api.Assertions.catchThrowableOfType
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+
+class RegexSerializerTest {
+    private companion object {
+        val simpleRegex: Regex = Regex("[0-9]{10}")
+        val regexWithOps: Regex = Regex("[0-9]{10}", setOf(RegexOption.LITERAL, RegexOption.IGNORE_CASE))
+
+        val regexComparator = nullsLast(compareBy(Regex::pattern, { it.toPattern().flags() }))
+    }
+
+    @Nested
+    inner class SimpleTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val r: Regex,
+            )
+
+            var test: Conf = Config4k.decodeFromConfig("r = \"[0-9]{10}\"".toConfig())
+            assertThat(test.r).usingComparator(regexComparator).isEqualTo(simpleRegex)
+
+            test = Config4k.decodeFromConfig("r = {options=[LITERAL, IGNORE_CASE],pattern=\"[0-9]{10}\"}".toConfig())
+            assertThat(test.r).usingComparator(regexComparator).isEqualTo(regexWithOps)
+
+            assertThatThrownBy { Config4k.decodeFromConfig<Conf>("r = {options=[UNKNOWN],pattern=\"[0-9]{10}\"}".toConfig()) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("kotlin.text.RegexOption does not contain element with name 'UNKNOWN'")
+
+            assertThatThrownBy { Config4k.decodeFromConfig<Conf>("r = 10".toConfig()) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("kotlin.text.Regex can't be specified by NUMBER value type")
+
+            var thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("r")
+
+            thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("r = {options=[]}".toConfig()) }
+            assertThat(thrown.missingFields).contains("pattern")
+        }
+
+        @Test
+        fun checkIncorrectDecoder() {
+            assertThatThrownBy { RegexSerializer.deserialize(mockk()) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("This class can be decoded only by Hocon format")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val r: Regex? = null,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(test.r).isNull()
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val r: Regex = simpleRegex,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("r = {options=[LITERAL, IGNORE_CASE],pattern=\"[0-9]{10}\"}".toConfig())
+            assertThat(test.r).usingComparator(regexComparator).isEqualTo(regexWithOps)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.r).usingComparator(regexComparator).isEqualTo(simpleRegex)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val r: Regex,
+            )
+
+            val simple = Config4k.encodeToConfig(Conf(simpleRegex))
+            assertThat(simple.render()).isEqualTo("r=\"[0-9]{10}\"")
+
+            val withOps = Config4k.encodeToConfig(Conf(regexWithOps))
+            assertThat(withOps.render()).isEqualTo("r{options=[\"IGNORE_CASE\",LITERAL],pattern=\"[0-9]{10}\"}")
+        }
+
+        @Test
+        fun checkIncorrectEncoder() {
+            assertThatThrownBy { RegexSerializer.serialize(mockk(), mockk()) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("This class can be encoded only by Hocon format")
+        }
+    }
+
+    @Nested
+    inner class ListTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                val rs: List<@Contextual Regex>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("rs = []".toConfig())
+            assertThat(emptyTest.rs).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig("rs = [\"[0-9]{10}\", {options=[LITERAL,IGNORE_CASE],pattern=\"[0-9]{10}\"}]".toConfig())
+            assertThat(test.rs).usingElementComparator(regexComparator).containsExactly(simpleRegex, regexWithOps)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("rs")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val rs: List<@Contextual Regex?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.rs).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("rs = []".toConfig())
+            assertThat(emptyTest.rs).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig("rs =  [\"[0-9]{10}\", null, {options=[LITERAL,IGNORE_CASE],pattern=\"[0-9]{10}\"}]".toConfig())
+            assertThat(test.rs).usingElementComparator(regexComparator).containsExactly(simpleRegex, null, regexWithOps)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val rs: List<@Contextual Regex> = listOf(simpleRegex),
+            )
+
+            val test: Conf =
+                Config4k.decodeFromConfig("rs = [\"[0-9]{10}\", {options=[LITERAL,IGNORE_CASE],pattern=\"[0-9]{10}\"}]".toConfig())
+            assertThat(test.rs).usingElementComparator(regexComparator).containsExactly(simpleRegex, regexWithOps)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.rs).usingElementComparator(regexComparator).containsExactly(simpleRegex)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val rs: List<@Contextual Regex?>,
+            )
+
+            val test: Config = Config4k.encodeToConfig(Conf(listOf(regexWithOps, null)))
+            assertThat(test.render()).isEqualTo("rs=[{options=[\"IGNORE_CASE\",LITERAL],pattern=\"[0-9]{10}\"},null]")
+        }
+    }
+
+    @Nested
+    inner class MapTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                val rs: Map<String, @Contextual Regex>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("rs = {}".toConfig())
+            assertThat(emptyTest.rs).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "rs { key1 = {options=[LITERAL,IGNORE_CASE],pattern=\"[0-9]{10}\"}, key2 = \"[0-9]{10}\" }".toConfig(),
+                )
+            assertThat(test.rs).hasSize(2).containsKeys("key1", "key2")
+            assertThat(test.rs.values).usingElementComparator(regexComparator).containsExactlyInAnyOrder(regexWithOps, simpleRegex)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("rs")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val rs: Map<String, @Contextual Regex?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.rs).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("rs = {}".toConfig())
+            assertThat(emptyTest.rs).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "rs { key1 = {options=[LITERAL,IGNORE_CASE],pattern=\"[0-9]{10}\"}, key2 = null, key3 = \"[0-9]{10}\" }".toConfig(),
+                )
+            assertThat(test.rs?.values).usingElementComparator(regexComparator).containsExactlyInAnyOrder(simpleRegex, regexWithOps, null)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val rs: Map<String, @Contextual Regex> = mapOf("key" to simpleRegex),
+            )
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "rs { key1 = {options=[LITERAL,IGNORE_CASE],pattern=\"[0-9]{10}\"}, key2 = \"[0-9]{10}\" }".toConfig(),
+                )
+            assertThat(test.rs.values).usingElementComparator(regexComparator).containsExactlyInAnyOrder(simpleRegex, regexWithOps)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.rs.values).usingElementComparator(regexComparator).containsExactly(simpleRegex)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val ps: Map<String, @Contextual Regex?>,
+            )
+
+            val test: Config =
+                Config4k.encodeToConfig(
+                    Conf(
+                        mapOf(
+                            "key1" to regexWithOps,
+                            "key2" to null,
+                        ),
+                    ),
+                )
+            assertThat(test.render()).isEqualTo("ps{key1{options=[\"IGNORE_CASE\",LITERAL],pattern=\"[0-9]{10}\"},key2=null}")
+        }
+    }
+}