From 7413a2249e859782dc50f022050034fff645c8cd Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Wed, 20 Mar 2024 20:29:23 +0900 Subject: [PATCH] Determine PluralCategory by current environment --- .../compose/resources/StringResources.kt | 35 +-- .../compose/resources/intl/PluralCategory.kt | 26 ++ .../compose/resources/intl/PluralCondition.kt | 257 ++++++++++++++++++ .../intl/PluralConditionParseException.kt | 8 + .../compose/resources/intl/PluralOperand.kt | 41 +++ .../compose/resources/intl/PluralRule.kt | 14 + .../compose/resources/intl/PluralRuleList.kt | 73 +++++ 7 files changed, 429 insertions(+), 25 deletions(-) create mode 100644 components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralCategory.kt create mode 100644 components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralCondition.kt create mode 100644 components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralConditionParseException.kt create mode 100644 components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralOperand.kt create mode 100644 components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralRule.kt create mode 100644 components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralRuleList.kt diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResources.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResources.kt index b44e0cc9d4..f84fa67a39 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResources.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResources.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.* import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.jetbrains.compose.resources.intl.PluralCategory +import org.jetbrains.compose.resources.intl.PluralRuleList import org.jetbrains.compose.resources.vector.xmldom.Element import org.jetbrains.compose.resources.vector.xmldom.NodeList @@ -39,15 +41,6 @@ class StringResource class QuantityStringResource @InternalResourceApi constructor(id: String, val key: String, items: Set) : Resource(id, items) -internal enum class PluralCategory { - ZERO, - ONE, - TWO, - FEW, - MANY, - OTHER -} - private sealed interface StringItem { data class Value(val text: String) : StringItem data class Plurals(val items: Map) : StringItem @@ -85,9 +78,9 @@ private suspend fun parseStringXml(path: String, resourceReader: ResourceReader) } val plurals = nodes.getElementsWithName("plurals").associate { pluralElement -> val items = pluralElement.childNodes.getElementsWithName("item").mapNotNull { element -> - val pluralCategory = PluralCategory.entries.firstOrNull { - it.name.equals(element.getAttribute("quantity"), true) - } ?: return@mapNotNull null + val pluralCategory = PluralCategory.fromString( + element.getAttribute("quantity"), + ) ?: return@mapNotNull null pluralCategory to element.textContent.orEmpty() } pluralElement.getAttribute("name") to StringItem.Plurals(items.toMap()) @@ -236,7 +229,11 @@ private suspend fun loadQuantityString( val keyToValue = getParsedStrings(path, resourceReader) val item = keyToValue[resource.key] as? StringItem.Plurals ?: error("String ID=`${resource.key}` is not found!") - val pluralCategory = getPluralCategory(environment.language, quantity) + val pluralRuleList = PluralRuleList.getInstance( + environment.language, + environment.region, + ) + val pluralCategory = pluralRuleList.getCategory(quantity) val str = item.items[pluralCategory] ?: error("String ID=`${resource.key}` does not have the pluralization $pluralCategory for quantity $quantity!") return str @@ -371,15 +368,3 @@ internal fun handleSpecialCharacters(string: String): String { }.replace("""\\""", """\""") return handledString } - -/** - * @param languageQualifier - * @param quantity - */ -@OptIn(InternalResourceApi::class) -internal fun getPluralCategory(languageQualifier: LanguageQualifier, quantity: Int): PluralCategory { - return when { - quantity == 1 -> PluralCategory.ONE - else -> PluralCategory.OTHER - } -} diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralCategory.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralCategory.kt new file mode 100644 index 0000000000..8275a1d5e0 --- /dev/null +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralCategory.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2020-2024 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.resources.intl + +/** + * Plural categories defined in the [CLDR Language Plural Rules](https://cldr.unicode.org/index/cldr-spec/plural-rules). + */ +internal enum class PluralCategory { + ZERO, + ONE, + TWO, + FEW, + MANY, + OTHER; + + companion object { + fun fromString(name: String): PluralCategory? { + return entries.firstOrNull { + it.name.equals(name, true) + } + } + } +} diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralCondition.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralCondition.kt new file mode 100644 index 0000000000..8db51aa609 --- /dev/null +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralCondition.kt @@ -0,0 +1,257 @@ +/* + * Copyright 2020-2024 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.resources.intl + +import kotlin.math.absoluteValue + +internal sealed class PluralCondition { + abstract fun isFulfilled(n: Int): Boolean + + // add isFulfilled(n: Double) or isFulfilled(n: Decimal) as needed + + class And( + private val left: PluralCondition, + private val right: PluralCondition, + ) : PluralCondition() { + override fun isFulfilled(n: Int): Boolean = left.isFulfilled(n) and right.isFulfilled(n) + override fun toString(): String = "$left and $right" + } + + class Or( + private val left: PluralCondition, + private val right: PluralCondition, + ) : PluralCondition() { + override fun isFulfilled(n: Int): Boolean = left.isFulfilled(n) or right.isFulfilled(n) + override fun toString(): String = "$left or $right" + } + + class Relation( + private val operand: PluralOperand, + private val operandDivisor: Int?, + private val comparisonIsNegated: Boolean, + private val ranges: Array, + ) : PluralCondition() { + override fun isFulfilled(n: Int): Boolean { + val expressionOperandValue = when (operand) { + PluralOperand.N, PluralOperand.I -> n.absoluteValue + else -> 0 + } + val moduloAppliedValue = if (operandDivisor != null) { + expressionOperandValue % operandDivisor + } else { + expressionOperandValue + } + return ranges.any { moduloAppliedValue in it } != comparisonIsNegated + } + + override fun toString(): String { + return StringBuilder().run { + append(operand.name.lowercase()) + if (operandDivisor != null) { + append(" % ") + append(operand) + } + append(' ') + if (comparisonIsNegated) { + append('!') + } + append("= ") + var first = true + for (range in ranges) { + if (!first) { + append(',') + } + first = false + append(range.first) + if (range.first != range.last) { + append("..") + append(range.last) + } + } + toString() + } + } + + private class Parser(private val description: String) { + private var currentIdx = 0 + + private fun consumeWhitespaces() { + while (currentIdx < description.length && description[currentIdx].isWhitespace()) { + currentIdx += 1 + } + } + + private fun peekNextOrNull(): Char? { + return description.getOrNull(currentIdx) + } + + private fun peekNext(): Char { + return peekNextOrNull() ?: throw PluralConditionParseException(description) + } + + private fun consumeNext(): Char { + val next = peekNext() + currentIdx += 1 + return next + } + + private fun consumeNextInt(): Int { + if (!peekNext().isDigit()) throw PluralConditionParseException(description) + var integerValue = 0 + var integerLastIdx = currentIdx + while (integerLastIdx < description.length && description[integerLastIdx].isDigit()) { + integerValue *= 10 + integerValue += description[integerLastIdx] - '0' + integerLastIdx += 1 + } + currentIdx = integerLastIdx + return integerValue + } + + fun nextOperand(): PluralOperand { + consumeWhitespaces() + return when (consumeNext()) { + 'n' -> PluralOperand.N + 'i' -> PluralOperand.I + 'f' -> PluralOperand.F + 't' -> PluralOperand.T + 'v' -> PluralOperand.V + 'w' -> PluralOperand.W + else -> throw PluralConditionParseException(description) + } + } + + fun nextModulusDivisor(): Int? { + consumeWhitespaces() + if (peekNext() == '%') { + consumeNext() + consumeWhitespaces() + return consumeNextInt() + } + return null + } + + /** + * Returns `true` for `!=`, `false` for `=`. + */ + fun nextComparisonIsNegated(): Boolean { + consumeWhitespaces() + when (peekNext()) { + '!' -> { + consumeNext() + if (consumeNext() != '=') throw PluralConditionParseException(description) + return true + } + + '=' -> { + consumeNext() + return false + } + + else -> throw PluralConditionParseException(description) + } + } + + /** + * Returns `number..number` if the range is actually a value. + */ + fun nextRange(): IntRange { + consumeWhitespaces() + val start = consumeNextInt() + if (peekNextOrNull() != '.') { + return start..start + } + consumeNext() + if (peekNext() != '.') throw PluralConditionParseException(description) + val endInclusive = consumeNextInt() + return start..endInclusive + } + + fun nextCommaOrNull(): Char? { + return when (peekNextOrNull()) { + ',' -> ',' + null -> null + else -> throw PluralConditionParseException(description) + } + } + } + + companion object { + /** + * Syntax: + * ``` + * relation = operand ('%' value)? ('=' | '!=') range_list + * operand = 'n' | 'i' | 'f' | 't' | 'v' | 'w' + * range_list = (range | value) (',' range_list)* + * range = value'..'value + * value = digit+ + * digit = [0-9] + * ``` + */ + fun parse(description: String): Relation { + val parser = Parser(description) + val operand = parser.nextOperand() + val divisor = parser.nextModulusDivisor() + val negated = parser.nextComparisonIsNegated() + val ranges = mutableListOf() + while (true) { + ranges.add(parser.nextRange()) + if (parser.nextCommaOrNull() == null) break + } + // ranges is not empty here + return Relation(operand, divisor, negated, ranges.toTypedArray()) + } + } + } + + companion object { + /** + * Parses [description] as defined in the [Unicode Plural rules syntax](https://unicode.org/reports/tr35/tr35-numbers.html#Plural_rules_syntax). + * For compact implementation, samples and keywords for backward compatibility are also not handled. You can + * find such keywords in the [Relations Examples](https://unicode.org/reports/tr35/tr35-numbers.html#Relations_Examples) section. + * ``` + * condition = and_condition ('or' and_condition)* + * and_condition = relation ('and' relation)* + * relation = operand ('%' value)? ('=' | '!=') range_list + * operand = 'n' | 'i' | 'f' | 't' | 'v' | 'w' + * range_list = (range | value) (',' range_list)* + * range = value'..'value + * value = digit+ + * digit = [0-9] + * ``` + * See [Relation.parse] for the remaining part of the syntax. + */ + fun parse(description: String): PluralCondition { + var condition: PluralCondition? = null + val orConditionDescriptions = OR_PATTERN.split(description.trim()) + if (orConditionDescriptions.isNotEmpty() && orConditionDescriptions[0].isNotEmpty()) { + for (orConditionDescription in orConditionDescriptions) { + if (orConditionDescription.isEmpty()) throw PluralConditionParseException(description) + var andCondition: PluralCondition? = null + val andConditionDescriptions = AND_PATTERN.split(orConditionDescription.trim()) + for (relationDescription in andConditionDescriptions) { + val relation = Relation.parse(relationDescription) + andCondition = if (andCondition == null) + relation + else + And(andCondition, relation) + } + if (andCondition == null) throw PluralConditionParseException(description) + condition = if (condition == null) andCondition else Or(condition, andCondition) + } + } + return condition ?: NoCondition + } + + private val AND_PATTERN = Regex("""\s*and\s*""") + private val OR_PATTERN = Regex("""\s*or\s*""") + } +} + +internal object NoCondition : PluralCondition() { + override fun isFulfilled(n: Int): Boolean = true + override fun toString(): String = "" +} \ No newline at end of file diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralConditionParseException.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralConditionParseException.kt new file mode 100644 index 0000000000..05c4d6ce89 --- /dev/null +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralConditionParseException.kt @@ -0,0 +1,8 @@ +/* + * Copyright 2020-2024 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.resources.intl + +internal class PluralConditionParseException(description: String) : Exception("Invalid LDML: $description") \ No newline at end of file diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralOperand.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralOperand.kt new file mode 100644 index 0000000000..eabeba9297 --- /dev/null +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralOperand.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2020-2024 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.resources.intl + +/** + * Plural operands defined in the [Unicode Locale Data Markup Language](https://unicode.org/reports/tr35/tr35-numbers.html#Plural_Operand_Meanings). + */ +internal enum class PluralOperand { + /** + * The absolute value of the source number. + */ + N, + + /** + * The integer digits of the source number. + */ + I, + + /** + * The number of visible fraction digits in the source number, *with* trailing zeros. + */ + V, + + /** + * The number of visible fraction digits in the source number, *without* trailing zeros. + */ + W, + + /** + * The visible fraction digits in the source number, *with* trailing zeros, expressed as an integer. + */ + F, + + /** + * The visible fraction digits in the source number, *without* trailing zeros, expressed as an integer. + */ + T, +} \ No newline at end of file diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralRule.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralRule.kt new file mode 100644 index 0000000000..b8a63fcb3b --- /dev/null +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralRule.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2020-2024 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.resources.intl + +internal class PluralRule(val category: PluralCategory, private val constraint: PluralCondition) { + fun appliesTo(n: Int): Boolean { + return constraint.isFulfilled(n) + } + + // add appliesTo(n: Double) or appliesTo(n: Decimal) as needed +} \ No newline at end of file diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralRuleList.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralRuleList.kt new file mode 100644 index 0000000000..b5bc8f614e --- /dev/null +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/intl/PluralRuleList.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2020-2024 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.resources.intl + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jetbrains.compose.resources.InternalResourceApi +import org.jetbrains.compose.resources.LanguageQualifier +import org.jetbrains.compose.resources.RegionQualifier + +internal class PluralRuleList private constructor(private val rules: Array) { + fun getCategory(quantity: Int): PluralCategory { + return rules.first { rule -> rule.appliesTo(quantity) }.category + } + + companion object { + private val cacheMutex = Mutex() + private val cache = mutableMapOf>() + private val emptyList = PluralRuleList(emptyArray()) + + @OptIn(InternalResourceApi::class) + suspend fun getInstance( + languageQualifier: LanguageQualifier, + regionQualifier: RegionQualifier, + ): PluralRuleList { + val cldrLocaleName = buildCldrLocaleName(languageQualifier, regionQualifier) ?: return emptyList + return coroutineScope { + val deferred = cacheMutex.withLock { + cache.getOrPut(cldrLocaleName) { + async(start = CoroutineStart.LAZY) { + createInstance(cldrLocaleName) + } + } + } + deferred.await() + } + } + + @OptIn(InternalResourceApi::class) + private fun buildCldrLocaleName( + languageQualifier: LanguageQualifier, + regionQualifier: RegionQualifier, + ): String? { + val localeWithRegion = languageQualifier.language + "_" + regionQualifier.region + if (cldrPluralRuleListIndexByLocale.containsKey(localeWithRegion)) { + return localeWithRegion + } + if (cldrPluralRuleListIndexByLocale.containsKey(languageQualifier.language)) { + return languageQualifier.language + } + return null + } + + private fun createInstance(cldrLocaleName: String): PluralRuleList { + val cldrPluralRuleListIndex = cldrPluralRuleListIndexByLocale[cldrLocaleName]!! + val cldrPluralRuleList = cldrPluralRuleLists[cldrPluralRuleListIndex] + val pluralRules = cldrPluralRuleList.map { + PluralRule( + it.first, + PluralCondition.parse(it.second), + ) + } + return PluralRuleList(pluralRules.toTypedArray()) + } + } +} \ No newline at end of file