From 3ea093f5d06fd2979ae8bd631624c792f59aed94 Mon Sep 17 00:00:00 2001 From: Wojciech Liberda Date: Mon, 30 Jun 2025 16:32:09 +0200 Subject: [PATCH 1/2] Enhanced `String.replaceWithArgs` to support non-positional format specifiers (%s, %d) when only one argument is provided --- .../compose/resources/StringResourcesUtils.kt | 20 +++++++-- .../compose/resources/StringFormatTest.kt | 42 ++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) 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..4b622f3e7f1 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,23 @@ 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[index] + } } internal sealed interface StringItem { 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..898ca323865 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 @@ -180,4 +180,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) + } + } +} From fc19b6190ee40e45e17b6b1795787143eccf37ca Mon Sep 17 00:00:00 2001 From: Wojciech Liberda Date: Wed, 1 Oct 2025 11:10:40 +0200 Subject: [PATCH 2/2] Throw IllegalArgumentException with meaningful message for missing format arguments --- .../compose/resources/StringResourcesUtils.kt | 8 +++++-- .../compose/resources/StringFormatTest.kt | 24 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) 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 4b622f3e7f1..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 @@ -19,7 +19,11 @@ internal fun String.replaceWithArgs(args: List): String { ) } } - args[index] + 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\"" + ) + } } } @@ -37,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 898ca323865..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