diff --git a/i18n4k-core/src/commonMain/kotlin/de/comahe/i18n4k/messages/formatter/MessageFormatterDefault.kt b/i18n4k-core/src/commonMain/kotlin/de/comahe/i18n4k/messages/formatter/MessageFormatterDefault.kt index 91efa52..27e1eab 100644 --- a/i18n4k-core/src/commonMain/kotlin/de/comahe/i18n4k/messages/formatter/MessageFormatterDefault.kt +++ b/i18n4k-core/src/commonMain/kotlin/de/comahe/i18n4k/messages/formatter/MessageFormatterDefault.kt @@ -5,6 +5,7 @@ import de.comahe.i18n4k.messages.formatter.parsing.MessageFormatContext import de.comahe.i18n4k.messages.formatter.parsing.MessageParser import de.comahe.i18n4k.messages.formatter.parsing.MessagePart import de.comahe.i18n4k.messages.formatter.types.MessageNumberFormatters +import de.comahe.i18n4k.messages.formatter.types.MessageSelectFormatter import de.comahe.i18n4k.messages.formatter.types.MessageTransformFormatters import kotlinx.atomicfu.atomic import kotlinx.atomicfu.update @@ -79,7 +80,8 @@ object MessageFormatterDefault : MessageFormatter { private val messageFormatContext = atomic( MessageFormatContext( (MessageNumberFormatters.all - + MessageTransformFormatters.all) + + MessageTransformFormatters.all + + MessageSelectFormatter) .associateBy({ it.typeId }, { it }).toPersistentMap() ) ) diff --git a/i18n4k-core/src/commonMain/kotlin/de/comahe/i18n4k/messages/formatter/types/MessageSelectFormatter.kt b/i18n4k-core/src/commonMain/kotlin/de/comahe/i18n4k/messages/formatter/types/MessageSelectFormatter.kt new file mode 100644 index 0000000..1122b9d --- /dev/null +++ b/i18n4k-core/src/commonMain/kotlin/de/comahe/i18n4k/messages/formatter/types/MessageSelectFormatter.kt @@ -0,0 +1,140 @@ +package de.comahe.i18n4k.messages.formatter.types + +import de.comahe.i18n4k.Locale +import de.comahe.i18n4k.messages.formatter.MessageValueFormatter +import de.comahe.i18n4k.messages.formatter.parsing.MessageFormatContext +import de.comahe.i18n4k.messages.formatter.parsing.MessagePart +import de.comahe.i18n4k.messages.formatter.parsing.StylePart +import de.comahe.i18n4k.messages.formatter.parsing.StylePartList +import de.comahe.i18n4k.messages.formatter.parsing.StylePartNamed +import de.comahe.i18n4k.messages.formatter.parsing.StylePartSimple +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.update +import kotlinx.collections.immutable.persistentMapOf + +/** + * Select a text value based on the value of the parameter. + * + * Format: + * + * { PARAMETER_NUMBER, select, VALUE1: TEXT1 | VALUE2 / VALUE3: TEXT2 | regex#VALUE_REGEX : TEXT_REGEX | OTHERWISE_TEXT} + * - PARAMETER_NUMBER: + * - Number of the parameter which value is matched against the values + * of the select list + * - VALUE*: + * - If a values matches the value of the parameter, the corresponding + * text (TEXT*) is selected + * - List of several values for the same text can be separated by slash + * "/" + * - VALUE_REGEX + * - When the value is prefixed by "regex#", it is seen as regular + * repression, e.g. `regex#([A-Z])\w+` + * - If the regex matches the value of the parameter, the corresponding + * text (TEXT_REGEX) is selected + * - TEXT*: + * - Text that is returned by the pattern if the value matches the value + * of the parameter + * - Texts themselves can also contain patterns. So, the patterns can be + * nested. + * - TEXT_REGEX + * - Like TEXT* but regex-groups can be refereed by + * - OTHERWISE_TEXT + * - If non of the values before matched, this text will be selected + * - If there is no OTHERWISE_TEXT specified and no value matches, an + * empty string is returned + * + * Values and texts are trimmed (leading and tailing whitespaces are + * removed) + * + * Example: + * + * {0} has forgotten {1, select, female: her | his } {3, select, one: bag | {2} bags}. + * + * Usage: + * + * FORGOTTEN_BAG("Peter", "male", 1, "one") + * -> Peter has forgotten his bag. + * FORGOTTEN_BAG("Mary", "female", 2, "few") + * -> Mary has forgotten her 2 bags. + */ +object MessageSelectFormatter : MessageValueFormatter { + + private const val REGEX_PREFIX = "regex#" + + private val regexCache = atomic(persistentMapOf()) + + override val typeId: String + get() = "select" + + override fun format( + result: StringBuilder, + value: Any?, + style: StylePart?, + parameters: List, + locale: Locale, + context: MessageFormatContext + ) { + if (style == null) + return + + val messagePart: MessagePart? = getMessagePartForMatchingStyle(value, style) + + messagePart?.format(result, parameters, locale, context) + } + + private fun getMessagePartForMatchingStyle(value: Any?, style: StylePart): MessagePart? { + if (style is StylePartSimple) + return style.data + + if (style is StylePartNamed) { + if (valueMatches(value, style.names)) + return style.data + } + + if (style is StylePartList) { + for (subStyle in style.list) { + val p = getMessagePartForMatchingStyle(value, subStyle) + if (p != null) + return p + } + } + return null + } + + private fun valueMatches(value: Any?, names: Collection): Boolean { + for (name in names) { + if (valueMatches(value, name)) + return true + } + return false + } + + private fun valueMatches(value: Any?, name: CharSequence): Boolean { + val valueString: CharSequence = value as? CharSequence + ?: value?.toString() + ?: return false + + if (name.startsWith(REGEX_PREFIX)) { + val regex = getRegex(name.subSequence(REGEX_PREFIX.length, name.length)) + return regex.matches(valueString) + } + return valueString == name + } + + private fun getRegex(text: CharSequence): Regex { + var regex = regexCache.value[text] + if (regex != null) + return regex + regexCache.update { + var regexLocal = it[text] + if (regexLocal != null) { + regex = regexLocal + return@update it + } + regexLocal = Regex(text.toString()) + regex = regexLocal + return@update it.put(text, regexLocal) + } + return regex!! + } +} \ No newline at end of file diff --git a/i18n4k-core/src/commonTest/kotlin/da/comahe/i18n4k/MessageSelectFormatterTest.kt b/i18n4k-core/src/commonTest/kotlin/da/comahe/i18n4k/MessageSelectFormatterTest.kt new file mode 100644 index 0000000..8c2e56e --- /dev/null +++ b/i18n4k-core/src/commonTest/kotlin/da/comahe/i18n4k/MessageSelectFormatterTest.kt @@ -0,0 +1,98 @@ +package da.comahe.i18n4k + +import de.comahe.i18n4k.Locale +import de.comahe.i18n4k.config.I18n4kConfigDefault +import de.comahe.i18n4k.i18n4k +import de.comahe.i18n4k.messages.formatter.MessageFormatterDefault.format +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class MessageSelectFormatterTest { + + private var i18n4kConfig = I18n4kConfigDefault() + + @BeforeTest + fun init() { + i18n4k = i18n4kConfig + i18n4kConfig.restoreDefaultSettings() + } + + + @Test + fun test_select_simple() { + val locale = Locale("en") + val pattern = "{0, select, 0:zero|1/2:few|3/4/5/6:many|too much }"; + + assertEquals("zero", format(pattern, listOf(0), locale)) + assertEquals("few", format(pattern, listOf(1), locale)) + assertEquals("few", format(pattern, listOf("2"), locale)) + assertEquals("many", format(pattern, listOf(3), locale)) + assertEquals("many", format(pattern, listOf(4), locale)) + assertEquals("many", format(pattern, listOf("5"), locale)) + assertEquals("many", format(pattern, listOf("6"), locale)) + assertEquals("too much", format(pattern, listOf(7), locale)) + assertEquals("too much", format(pattern, listOf("some thing"), locale)) + } + + @Test + fun test_select_nested() { + val locale = Locale("en") + val pattern = "{0, select, 0:{1}|1/2:{2}|3/4/5/6:{3}|{4} }"; + val extraParams = listOf("zero", "few", "many", "too much") + + assertEquals("zero", format(pattern, listOf(0) + extraParams, locale)) + assertEquals("few", format(pattern, listOf(1) + extraParams, locale)) + assertEquals("few", format(pattern, listOf("2") + extraParams, locale)) + assertEquals("many", format(pattern, listOf(3) + extraParams, locale)) + assertEquals("many", format(pattern, listOf(4) + extraParams, locale)) + assertEquals("many", format(pattern, listOf("5") + extraParams, locale)) + assertEquals("many", format(pattern, listOf("6") + extraParams, locale)) + assertEquals("too much", format(pattern, listOf(7) + extraParams, locale)) + assertEquals("too much", format(pattern, listOf("some thing") + extraParams, locale)) + } + + @Test + fun test_select_regex() { + val locale = Locale("en") + val pattern = + "{0, select, 0:zero | regex#\\d+ : digits | regex#\\w+ : word | regex#[abc<>-]+ : mix | else }" + + assertEquals("zero", format(pattern, listOf(0), locale)) + assertEquals("digits", format(pattern, listOf(1), locale)) + assertEquals("digits", format(pattern, listOf(12), locale)) + assertEquals("digits", format(pattern, listOf("001200"), locale)) + assertEquals("word", format(pattern, listOf('a'), locale)) + assertEquals("word", format(pattern, listOf("b"), locale)) + assertEquals("word", format(pattern, listOf("abc"), locale)) + assertEquals("word", format(pattern, listOf("abc".subSequence(0, 1)), locale)) + assertEquals("word", format(pattern, listOf("a1"), locale)) + assertEquals("word", format(pattern, listOf("b2"), locale)) + assertEquals("word", format(pattern, listOf("1a"), locale)) + assertEquals("word", format(pattern, listOf("2b"), locale)) + assertEquals("mix", format(pattern, listOf(""), locale)) + assertEquals("mix", format(pattern, listOf(">"), locale)) + assertEquals("mix", format(pattern, listOf("<"), locale)) + assertEquals("mix", format(pattern, listOf("----"), locale)) + assertEquals("else", format(pattern, listOf("#"), locale)) + assertEquals("else", format(pattern, listOf(";;;"), locale)) + } + + @Test + fun test_invalid() { + val locale = Locale("en") + + assertEquals("!", format("{~, select }!", listOf('a'), locale)) + assertEquals("!", format("{~, select , }!", listOf('a'), locale)) + assertEquals("!", format("{~, select , a: x }!", listOf('a'), locale)) + + assertEquals("!", format("{0, select }!", listOf('a'), locale)) + assertEquals("!", format("{0, select , }!", listOf('a'), locale)) + assertEquals("!", format("{0, select , b: y }!", listOf('a'), locale)) + assertEquals("!", format("{0, select , | x }!", listOf('a'), locale)) + assertEquals("!", format("{0, select , | a: x }!", listOf('a'), locale)) + + assertEquals("{1}!", format("{1, select , x }!", listOf('a'), locale)) + assertEquals("{1}!", format("{1, select , a: x }!", listOf('a'), locale)) + } +} \ No newline at end of file