diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt index b4304c4e59b..7c5e150b5a5 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt @@ -4,9 +4,27 @@ import org.jetbrains.compose.resources.plural.PluralCategory import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi -private val SimpleStringFormatRegex = Regex("""%(\d+)\$[ds]""") -internal fun String.replaceWithArgs(args: List) = SimpleStringFormatRegex.replace(this) { matchResult -> - args[matchResult.groupValues[1].toInt() - 1] +private val SimpleStringFormatRegex = Regex("""%(?:([1-9]\d*)\$)?[ds]""") +internal fun String.replaceWithArgs(args: List): String { + if (!SimpleStringFormatRegex.containsMatchIn(this)) return this + + return SimpleStringFormatRegex.replace(this) { match -> + val placeholderNumber = match.groups[1]?.value?.toIntOrNull() + val index = when { + placeholderNumber != null -> placeholderNumber - 1 + args.size == 1 -> 0 + else -> { + throw IllegalArgumentException( + "Formatting failed: Non-positional placeholder '${match.value}' is ambiguous when multiple arguments are provided in \"$this\"" + ) + } + } + args.getOrElse(index) { + throw IllegalArgumentException( + "Formatting failed: Placeholder '${match.value}' at position ${index + 1} is out of bounds. Only ${args.size} argument(s) provided for format string \"$this\"" + ) + } + } } internal sealed interface StringItem { @@ -23,7 +41,7 @@ internal fun dropStringItemsCache() { internal suspend fun getStringItem( resourceItem: ResourceItem, - resourceReader: ResourceReader + resourceReader: ResourceReader, ): StringItem = stringItemsCache.getOrLoad( key = "${resourceItem.path}/${resourceItem.offset}-${resourceItem.size}" ) { diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/StringFormatTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/StringFormatTest.kt index a326e84e7b8..a98145e06ec 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/StringFormatTest.kt +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/StringFormatTest.kt @@ -52,9 +52,13 @@ class StringFormatTest { val template = "Hello %1\$s, %2\$s!" val args = listOf("Alice") - assertFailsWith { + val exception = assertFailsWith { template.replaceWithArgs(args) } + assertEquals( + "Formatting failed: Placeholder '%2\$s' at position 2 is out of bounds. Only 1 argument(s) provided for format string \"Hello %1\$s, %2\$s!\"", + exception.message + ) } @Test @@ -72,9 +76,13 @@ class StringFormatTest { val template = "Hello %1\$s, you have %3\$s messages" val args = listOf("Alice") - assertFailsWith { + val exception = assertFailsWith { template.replaceWithArgs(args) } + assertEquals( + "Formatting failed: Placeholder '%3\$s' at position 3 is out of bounds. Only 1 argument(s) provided for format string \"Hello %1\$s, you have %3\$s messages\"", + exception.message + ) } @Test @@ -142,9 +150,13 @@ class StringFormatTest { val args = listOf("Alice") // An exception should be thrown because the second argument (%2$d) is missing - assertFailsWith { + val exception = assertFailsWith { template.replaceWithArgs(args) } + assertEquals( + "Formatting failed: Placeholder '%2\$d' at position 2 is out of bounds. Only 1 argument(s) provided for format string \"Hello %1\$s, you have %2\$d messages!\"", + exception.message + ) } @Test @@ -154,9 +166,13 @@ class StringFormatTest { val args = listOf("Alice", "1") // The template has a %3$s placeholder, but there is no third argument - assertFailsWith { + val exception = assertFailsWith { template.replaceWithArgs(args) } + assertEquals( + "Formatting failed: Placeholder '%3\$s' at position 3 is out of bounds. Only 2 argument(s) provided for format string \"Hello %1\$s, your rank is %3\$s\"", + exception.message + ) } @Test @@ -180,4 +196,44 @@ class StringFormatTest { // Only the first argument should be used, ignoring the rest assertEquals("Hello Alice!", result) } -} \ No newline at end of file + + @Test + fun `replaceWithArgs handle single argument format`() { + val template = "Hello %s!" + val args = listOf("Alice") + + val result = template.replaceWithArgs(args) + + assertEquals("Hello Alice!", result) + } + + @Test + fun `replaceWithArgs handle multiple placeholders for single argument`() { + val template = "%s and %s are best friends!" + val args = listOf("Alice") + + val result = template.replaceWithArgs(args) + + assertEquals("Alice and Alice are best friends!", result) + } + + @Test + fun `replaceWithArgs throw exception when multiple different arguments with single placeholder format`() { + val template = "Hello %s, you have %d new messages!" + val args = listOf("Alice", "15") + + assertFailsWith { + template.replaceWithArgs(args) + } + } + + @Test + fun `replaceWithArgs throw exception when mixing single and multiple placeholders format`() { + val template = "Hello %1\$s, you have %s new messages!" + val args = listOf("Alice", "15") + + assertFailsWith { + template.replaceWithArgs(args) + } + } +}