From 2e23917ecb1aaf647312a9308ec829036b7a1cd4 Mon Sep 17 00:00:00 2001 From: Andrey Kuleshov Date: Sat, 17 Jun 2023 23:33:49 +0300 Subject: [PATCH 1/2] Levenshtein distance calculation for invalid enum values ### What's done: - Inspired by diktat: in mostly each and every configuration files we have enums and authors of libraries that work with this configuration file would like to help users to find a misprint and suggest the closest value - we think that this should be done on the side of all decoding libraries --- .../ktoml/exceptions/TomlDecodingException.kt | 7 ++-- .../com/akuleshov7/ktoml/utils/Utils.kt | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt index 4418a5c..664353d 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt @@ -2,6 +2,7 @@ package com.akuleshov7.ktoml.exceptions +import com.akuleshov7.ktoml.utils.closestEnumName import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException import kotlinx.serialization.descriptors.SerialDescriptor @@ -28,14 +29,16 @@ internal class UnknownNameException(key: String, parent: String?) : TomlDecoding " to true if you would like to skip unknown keys" ) + @OptIn(ExperimentalSerializationApi::class) internal class InvalidEnumValueException( value: String, enumSerialDescriptor: SerialDescriptor, lineNo: Int ) : TomlDecodingException( - "Value <$value> is not a valid enum option." + - " Permitted choices are: ${enumSerialDescriptor.elementNames.sorted().joinToString(", ")}" + "Line $lineNo: value <$value> is not a valid enum option." + + " Permitted choices are: ${enumSerialDescriptor.elementNames.sorted().joinToString(", ")}." + + " Did you mean <${enumSerialDescriptor.elementNames.closestEnumName(value)}>?" ) internal class NullValueException(propertyName: String, lineNo: Int) : TomlDecodingException( diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/Utils.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/Utils.kt index e3af643..8aba3ff 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/Utils.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/Utils.kt @@ -35,3 +35,36 @@ public fun findPrimitiveTableInAstByName(children: List, fullTableName return findPrimitiveTableInAstByName(children.map { it.children }.flatten(), fullTableName) } + +/** + * Unfortunately Levenshtein method is implemented in jline and not ported to Kotlin Native. + * So we need to implement it (inspired by: https://pl.kotl.in/ifo0z0vMC) + */ +public fun levenshteinDistance(first: String, second: String): Int { + when { + first == second -> return 0 + first.isEmpty() -> return second.length + second.isEmpty() -> return first.length + } + + val firstLen = first.length + 1 + val secondLen = second.length + 1 + var distance = IntArray(firstLen) { it } + var newDistance = IntArray(firstLen) { 0 } + + for (i in 1 until secondLen) { + newDistance[0] = i + for (j in 1 until firstLen) { + val costReplace = distance[j - 1] + (if (first[j - 1] == second[i - 1]) 0 else 1) + val costInsert = distance[j] + 1 + val costDelete = newDistance[j - 1] + 1 + + newDistance[j] = minOf(costInsert, costDelete, costReplace) + } + distance = newDistance.also { newDistance = distance } + } + return distance[firstLen - 1] +} + +public fun Iterable.closestEnumName(enumValue: String): String? = + this.minByOrNull { levenshteinDistance(it, enumValue) } From a74e622ffb7ba42f681346ae4ff05e19d3d4831b Mon Sep 17 00:00:00 2001 From: Andrey Kuleshov Date: Sun, 18 Jun 2023 22:46:54 +0300 Subject: [PATCH 2/2] Levenshtein distance calculation for invalid enum values ### What's done: - Inspired by diktat: in mostly each and every configuration files we have enums and authors of libraries that work with this configuration file would like to help users to find a misprint and suggest the closest value - we think that this should be done on the side of all decoding libraries --- .../ktoml/exceptions/TomlDecodingException.kt | 5 +- .../com/akuleshov7/ktoml/utils/Utils.kt | 17 +++++- .../ktoml/decoders/EnumValidationTest.kt | 57 +++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/EnumValidationTest.kt diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt index 664353d..20c4ad8 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt @@ -29,7 +29,6 @@ internal class UnknownNameException(key: String, parent: String?) : TomlDecoding " to true if you would like to skip unknown keys" ) - @OptIn(ExperimentalSerializationApi::class) internal class InvalidEnumValueException( value: String, @@ -37,8 +36,8 @@ internal class InvalidEnumValueException( lineNo: Int ) : TomlDecodingException( "Line $lineNo: value <$value> is not a valid enum option." + - " Permitted choices are: ${enumSerialDescriptor.elementNames.sorted().joinToString(", ")}." + - " Did you mean <${enumSerialDescriptor.elementNames.closestEnumName(value)}>?" + " Did you mean <${enumSerialDescriptor.elementNames.closestEnumName(value)}>?" + + " Permitted choices are: ${enumSerialDescriptor.elementNames.sorted().joinToString(", ")}." ) internal class NullValueException(propertyName: String, lineNo: Int) : TomlDecodingException( diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/Utils.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/Utils.kt index 8aba3ff..ba32881 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/Utils.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/utils/Utils.kt @@ -8,6 +8,13 @@ import com.akuleshov7.ktoml.tree.nodes.TableType import com.akuleshov7.ktoml.tree.nodes.TomlNode import com.akuleshov7.ktoml.tree.nodes.TomlTable +/** + * @param enumValue input value that we need to compare with elements of enum + * @return nearest enum value (using levenshtein distance algorithm) + */ +public fun Iterable.closestEnumName(enumValue: String): String? = + this.minByOrNull { levenshteinDistance(it, enumValue) } + /** * Append a code point to a [StringBuilder] * @@ -39,12 +46,19 @@ public fun findPrimitiveTableInAstByName(children: List, fullTableName /** * Unfortunately Levenshtein method is implemented in jline and not ported to Kotlin Native. * So we need to implement it (inspired by: https://pl.kotl.in/ifo0z0vMC) + * + * @param first string to compare + * @param second string for comparison + * @return the distance between compared strings */ public fun levenshteinDistance(first: String, second: String): Int { when { first == second -> return 0 first.isEmpty() -> return second.length second.isEmpty() -> return first.length + else -> { + // this is a generated else block + } } val firstLen = first.length + 1 @@ -65,6 +79,3 @@ public fun levenshteinDistance(first: String, second: String): Int { } return distance[firstLen - 1] } - -public fun Iterable.closestEnumName(enumValue: String): String? = - this.minByOrNull { levenshteinDistance(it, enumValue) } diff --git a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/EnumValidationTest.kt b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/EnumValidationTest.kt new file mode 100644 index 0000000..3238236 --- /dev/null +++ b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/EnumValidationTest.kt @@ -0,0 +1,57 @@ +package com.akuleshov7.ktoml.decoders + +import com.akuleshov7.ktoml.Toml +import com.akuleshov7.ktoml.exceptions.IllegalTypeException +import com.akuleshov7.ktoml.exceptions.InvalidEnumValueException +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +@Serializable +data class Color(val myEnum: EnumExample) + +enum class EnumExample { + CANAPA, + KANAPA, + KANADA, + USA, + MEXICO, +} + +class EnumValidationTest { + @Test + fun testRegressions() { + var exception = assertFailsWith { + Toml.decodeFromString("myEnum = \"KANATA\"") + } + + exception.exceptionValidation( + "Line 1: value is not a valid enum option. Did you mean ? " + + "Permitted choices are: CANAPA, KANADA, KANAPA, MEXICO, USA." + ) + + exception = assertFailsWith { + Toml.decodeFromString("myEnum = \"TEST\"") + } + + exception.exceptionValidation( + "Line 1: value is not a valid enum option. Did you mean ? " + + "Permitted choices are: CANAPA, KANADA, KANAPA, MEXICO, USA." + ) + + exception = assertFailsWith { + Toml.decodeFromString("myEnum = \"MEKSICA\"") + } + + exception.exceptionValidation( + "Line 1: value is not a valid enum option. Did you mean ? " + + "Permitted choices are: CANAPA, KANADA, KANAPA, MEXICO, USA." + ) + } +} + +private fun InvalidEnumValueException.exceptionValidation(expected: String) { + assertEquals(expected, this.message) +}