Repository: config4k/config4k (kotlin)

Issue:
`java.util.regex.Pattern` should be serializable

Pull request description:
Fixes #301

Patch:
diff --git a/api/config4k.api b/api/config4k.api
index 2530eea..37c3de9 100644
--- a/api/config4k.api
+++ b/api/config4k.api
@@ -74,6 +74,15 @@ public final class io/github/config4k/serializers/ConfigValueSerializer : kotlin
 	public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
 }
 
+public final class io/github/config4k/serializers/PatternSerializer : kotlinx/serialization/KSerializer {
+	public static final field INSTANCE Lio/github/config4k/serializers/PatternSerializer;
+	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
+	public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/util/regex/Pattern;
+	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;Ljava/util/regex/Pattern;)V
+}
+
 public final class io/github/config4k/serializers/PeriodSerializer : kotlinx/serialization/KSerializer {
 	public static final field INSTANCE Lio/github/config4k/serializers/PeriodSerializer;
 	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 3f44efe..81d2c00 100644
--- a/src/main/kotlin/io/github/config4k/Config4k.kt
+++ b/src/main/kotlin/io/github/config4k/Config4k.kt
@@ -2,6 +2,7 @@ package io.github.config4k
 
 import io.github.config4k.serializers.ConfigSerializer
 import io.github.config4k.serializers.ConfigValueSerializer
+import io.github.config4k.serializers.PatternSerializer
 import io.github.config4k.serializers.PeriodSerializer
 import io.github.config4k.serializers.RegexSerializer
 import io.github.config4k.serializers.TemporalAmountSerializer
@@ -16,6 +17,7 @@ public val Config4kModule: SerializersModule =
         contextual(JavaDurationSerializer)
         contextual(PeriodSerializer)
         contextual(RegexSerializer)
+        contextual(PatternSerializer)
         contextual(TemporalAmountSerializer)
         contextual(ConfigSerializer)
         contextual(ConfigValueSerializer)
diff --git a/src/main/kotlin/io/github/config4k/serializers/PatternSerializer.kt b/src/main/kotlin/io/github/config4k/serializers/PatternSerializer.kt
new file mode 100644
index 0000000..27aad7b
--- /dev/null
+++ b/src/main/kotlin/io/github/config4k/serializers/PatternSerializer.kt
@@ -0,0 +1,97 @@
+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
+import java.util.regex.Pattern
+
+/**
+ * Serializer for [Pattern].
+ *
+ * Usage example:
+ * ```
+ * @Serializable
+ * data class ExamplePattern(
+ *   @Serializable(PatternSerializer::class)
+ *   val pattern: Pattern,
+ * )
+ * val config = ConfigFactory.parseString("pattern = \"[0-9]{10}\"")
+ * val examplePattern: ExamplePattern = Hocon.decodeFromConfig(config)
+ * val newConfig = Hocon.encodeToConfig(ExamplePattern.serializer(), examplePattern)
+ *
+ * val configOps = ConfigFactory.parseString("pattern = {pattern=\"[0-9]{10}\",flags=[LITERAL]}")
+ * val examplePatternOps: ExamplePattern = Hocon.decodeFromConfig(configOps)
+ * val newConfigOps = Hocon.encodeToConfig(ExamplePattern.serializer(), examplePatternOps)
+ * ```
+ */
+public object PatternSerializer : KSerializer<Pattern> {
+    override val descriptor: SerialDescriptor =
+        PrimitiveSerialDescriptor("hocon.java.util.regex.Pattern", PrimitiveKind.STRING)
+
+    private enum class PatternFlag(
+        val mask: Int,
+    ) {
+        CASE_INSENSITIVE(Pattern.CASE_INSENSITIVE),
+        MULTILINE(Pattern.MULTILINE),
+        DOTALL(Pattern.DOTALL),
+        UNICODE_CASE(Pattern.UNICODE_CASE),
+        CANON_EQ(Pattern.CANON_EQ),
+        UNIX_LINES(Pattern.UNIX_LINES),
+        LITERAL(Pattern.LITERAL),
+        UNICODE_CHARACTER_CLASS(Pattern.UNICODE_CHARACTER_CLASS),
+        COMMENTS(Pattern.COMMENTS),
+    }
+
+    @Serializable
+    private data class SerializablePattern(
+        private val pattern: String,
+        private val flags: Set<PatternFlag>,
+    ) {
+        constructor(pattern: Pattern) : this(
+            pattern.pattern(),
+            PatternFlag.entries.filter { pattern.flags() and it.mask == it.mask }.toSet(),
+        )
+
+        fun toPattern(): Pattern = Pattern.compile(pattern, flags.map { it.mask }.reduce(Int::or))
+    }
+
+    override fun deserialize(decoder: Decoder): Pattern =
+        if (decoder is HoconDecoder) {
+            decoder.decodeConfigValue { conf, path ->
+                when (val type = conf.getValue(path).valueType()) {
+                    STRING -> Pattern.compile(conf.getString(path))
+                    OBJECT -> {
+                        decoder.decodeSerializableValue(SerializablePattern.serializer()).toPattern()
+                    }
+
+                    else -> throw SerializationException("java.util.regex.Pattern 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: Pattern,
+    ) {
+        if (encoder is HoconEncoder) {
+            if (value.flags() == 0) {
+                encoder.encodeString(value.pattern())
+            } else {
+                encoder.encodeSerializableValue(SerializablePattern.serializer(), SerializablePattern(value))
+            }
+        } else {
+            throw SerializationException("This class can be encoded only by Hocon format")
+        }
+    }
+}
diff --git a/src/main/kotlin/io/github/config4k/serializers/RegexSerializer.kt b/src/main/kotlin/io/github/config4k/serializers/RegexSerializer.kt
index c5e09ba..a0d2d9d 100644
--- a/src/main/kotlin/io/github/config4k/serializers/RegexSerializer.kt
+++ b/src/main/kotlin/io/github/config4k/serializers/RegexSerializer.kt
@@ -23,11 +23,11 @@ import kotlinx.serialization.hocon.HoconEncoder
  *   @Serializable(RegexSerializer::class)
  *   val regex: Regex,
  * )
- * val config = ConfigFactory.parseString("regex = \"7[0-9]{10}\"")
+ * val config = ConfigFactory.parseString("regex = \"[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 configOps = ConfigFactory.parseString("regex = {pattern=\"[0-9]{10}\",options=[LITERAL]}")
  * val exampleRegexOps: ExampleRegex = Hocon.decodeFromConfig(configOps)
  * val newConfigOps = Hocon.encodeToConfig(ExampleRegex.serializer(), exampleRegexOps)
  * ```


Test patch:
diff --git a/src/test/kotlin/io/github/config4k/serializers/PatternSerializerTest.kt b/src/test/kotlin/io/github/config4k/serializers/PatternSerializerTest.kt
new file mode 100644
index 0000000..09a391d
--- /dev/null
+++ b/src/test/kotlin/io/github/config4k/serializers/PatternSerializerTest.kt
@@ -0,0 +1,269 @@
+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
+import java.util.regex.Pattern
+
+class PatternSerializerTest {
+    private companion object {
+        val simplePattern: Pattern = Pattern.compile("[0-9]{10}")
+        val patternWithFlags: Pattern =
+            Pattern.compile(
+                "[0-9]{10}",
+                Pattern.LITERAL or Pattern.CASE_INSENSITIVE,
+            )
+
+        val patternComparator = nullsLast(compareBy(Pattern::pattern, { it.flags() }))
+    }
+
+    @Nested
+    inner class SimpleTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val p: Pattern,
+            )
+
+            var test: Conf = Config4k.decodeFromConfig("p = \"[0-9]{10}\"".toConfig())
+            assertThat(test.p).usingComparator(patternComparator).isEqualTo(simplePattern)
+
+            test = Config4k.decodeFromConfig("p = {flags=[LITERAL, CASE_INSENSITIVE],pattern=\"[0-9]{10}\"}".toConfig())
+            assertThat(test.p).usingComparator(patternComparator).isEqualTo(patternWithFlags)
+
+            assertThatThrownBy { Config4k.decodeFromConfig<Conf>("p = {flags=[UNKNOWN],pattern=\"[0-9]{10}\"}".toConfig()) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("io.github.config4k.serializers.PatternSerializer.PatternFlag does not contain element with name 'UNKNOWN'")
+
+            assertThatThrownBy { Config4k.decodeFromConfig<Conf>("p = 10".toConfig()) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("java.util.regex.Pattern can't be specified by NUMBER value type")
+
+            var thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("p")
+
+            thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("p = {flags=[]}".toConfig()) }
+            assertThat(thrown.missingFields).contains("pattern")
+        }
+
+        @Test
+        fun checkIncorrectDecoder() {
+            assertThatThrownBy { PatternSerializer.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 p: Pattern? = null,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(test.p).isNull()
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val p: Pattern = simplePattern,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("p = {flags=[LITERAL, CASE_INSENSITIVE],pattern=\"[0-9]{10}\"}".toConfig())
+            assertThat(test.p).usingComparator(patternComparator).isEqualTo(patternWithFlags)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.p).usingComparator(patternComparator).isEqualTo(simplePattern)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val p: Pattern,
+            )
+
+            val simple = Config4k.encodeToConfig(Conf(simplePattern))
+            assertThat(simple.render()).isEqualTo("p=\"[0-9]{10}\"")
+
+            val withOps = Config4k.encodeToConfig(Conf(patternWithFlags))
+            assertThat(withOps.render()).isEqualTo("p{flags=[\"CASE_INSENSITIVE\",LITERAL],pattern=\"[0-9]{10}\"}")
+        }
+
+        @Test
+        fun checkIncorrectEncoder() {
+            assertThatThrownBy { PatternSerializer.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 ps: List<@Contextual Pattern>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ps = []".toConfig())
+            assertThat(emptyTest.ps).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig("ps = [\"[0-9]{10}\", {flags=[LITERAL,CASE_INSENSITIVE],pattern=\"[0-9]{10}\"}]".toConfig())
+            assertThat(test.ps).usingElementComparator(patternComparator).containsExactly(simplePattern, patternWithFlags)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("ps")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val ps: List<@Contextual Pattern?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.ps).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ps = []".toConfig())
+            assertThat(emptyTest.ps).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "ps =  [\"[0-9]{10}\", null, {flags=[LITERAL,CASE_INSENSITIVE],pattern=\"[0-9]{10}\"}]".toConfig(),
+                )
+            assertThat(test.ps).usingElementComparator(patternComparator).containsExactly(simplePattern, null, patternWithFlags)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val ps: List<@Contextual Pattern> = listOf(simplePattern),
+            )
+
+            val test: Conf =
+                Config4k.decodeFromConfig("ps = [\"[0-9]{10}\", {flags=[LITERAL,CASE_INSENSITIVE],pattern=\"[0-9]{10}\"}]".toConfig())
+            assertThat(test.ps).usingElementComparator(patternComparator).containsExactly(simplePattern, patternWithFlags)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.ps).usingElementComparator(patternComparator).containsExactly(simplePattern)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val ps: List<@Contextual Pattern?>,
+            )
+
+            val test: Config = Config4k.encodeToConfig(Conf(listOf(patternWithFlags, null)))
+            assertThat(test.render()).isEqualTo("ps=[{flags=[\"CASE_INSENSITIVE\",LITERAL],pattern=\"[0-9]{10}\"},null]")
+        }
+    }
+
+    @Nested
+    inner class MapTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                val ps: Map<String, @Contextual Pattern>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ps = {}".toConfig())
+            assertThat(emptyTest.ps).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "ps { key1 = {flags=[LITERAL,CASE_INSENSITIVE],pattern=\"[0-9]{10}\"}, key2 = \"[0-9]{10}\" }".toConfig(),
+                )
+            assertThat(test.ps).hasSize(2).containsKeys("key1", "key2")
+            assertThat(test.ps.values).usingElementComparator(patternComparator).containsExactlyInAnyOrder(patternWithFlags, simplePattern)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("ps")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val ps: Map<String, @Contextual Pattern?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.ps).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ps = {}".toConfig())
+            assertThat(emptyTest.ps).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "ps { key1 = {flags=[LITERAL,CASE_INSENSITIVE],pattern=\"[0-9]{10}\"}, key2 = null, key3 = \"[0-9]{10}\" }".toConfig(),
+                )
+            assertThat(test.ps?.values)
+                .usingElementComparator(patternComparator)
+                .containsExactlyInAnyOrder(simplePattern, patternWithFlags, null)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val ps: Map<String, @Contextual Pattern> = mapOf("key" to simplePattern),
+            )
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "ps { key1 = {flags=[LITERAL,CASE_INSENSITIVE],pattern=\"[0-9]{10}\"}, key2 = \"[0-9]{10}\" }".toConfig(),
+                )
+            assertThat(test.ps.values).usingElementComparator(patternComparator).containsExactlyInAnyOrder(simplePattern, patternWithFlags)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.ps.values).usingElementComparator(patternComparator).containsExactly(simplePattern)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val ps: Map<String, @Contextual Pattern?>,
+            )
+
+            val test: Config =
+                Config4k.encodeToConfig(
+                    Conf(
+                        mapOf(
+                            "key1" to patternWithFlags,
+                            "key2" to null,
+                        ),
+                    ),
+                )
+            assertThat(test.render()).isEqualTo("ps{key1{flags=[\"CASE_INSENSITIVE\",LITERAL],pattern=\"[0-9]{10}\"},key2=null}")
+        }
+    }
+}