Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle date/time conversion in runtime via instantiable FormattedResources #390

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ In an Android View:
import app.cash.paraphrase.getString

val orderDescription = resources.getString(
FormattedResources.order_description(
AndroidParaphraseResources.order_description(
count = 12,
name = "Jobu Tupaki",
)
Expand All @@ -86,7 +86,7 @@ In Compose UI:
import app.cash.paraphrase.compose.formattedResource

val orderDescription = formattedResource(
FormattedResources.order_description(
paraphraseResources.order_description(
count = 12,
name = "Jobu Tupaki",
),
Expand Down
144 changes: 64 additions & 80 deletions plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.NOTHING
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.STRING
import com.squareup.kotlinpoet.TypeName
import com.squareup.kotlinpoet.TypeSpec
Expand All @@ -51,16 +52,63 @@ internal fun writeResources(
): FileSpec {
val packageStringsType = ClassName(packageName = packageName, "R", "string")
val maxVisibility = mergedResources.maxOf { it.visibility }
return FileSpec.builder(packageName = packageName, fileName = "FormattedResources")
val genClassName = "ParaphraseResources"
val defaultInstanceName = "Android$genClassName"
return FileSpec.builder(packageName = packageName, fileName = genClassName)
.addFileComment(
"""
This code was generated by the Paraphrase Gradle plugin.
Do not edit this file directly. Instead, edit the string resources in the source file.
""".trimIndent(),
)
.addImport(packageName = packageName, "R")
.addProperty(
PropertySpec.builder(
name = defaultInstanceName,
type = Types.paraphraseResources(packageName),
)
.initializer("$genClassName(%T)", Types.AndroidDateTimeConverter)
.build(),
)
// TODO: Remove deprecated val after a few releases
.addProperty(
PropertySpec.builder(
name = "FormattedResources",
type = Types.paraphraseResources(packageName),
)
.initializer(defaultInstanceName, Types.AndroidDateTimeConverter)
.addAnnotation(
AnnotationSpec.builder(
type = ClassName("kotlin", "Deprecated"),
)
.addMember("message = \"\"\"The `FormattedResources` object has been replaced by the `$genClassName` class and the default `$defaultInstanceName` instance. Use the class to allow testing on the JVM, or use the default instance to maintain previous static-like invocation.\"\"\"")
.addMember("replaceWith = ReplaceWith(\"$defaultInstanceName\")")
.addMember("level = DeprecationLevel.ERROR")
.build(),
)
.build(),
)
.addType(
TypeSpec.objectBuilder("FormattedResources")
TypeSpec.classBuilder(genClassName)
.primaryConstructor(
FunSpec.constructorBuilder()
.addParameter(
ParameterSpec(
name = "dateTimeConverter",
type = Types.DateTimeConverter.parameterizedBy(ANY),
),
)
.build(),
)
.addProperty(
PropertySpec.builder(
name = "dateTimeConverter",
type = Types.DateTimeConverter.parameterizedBy(ANY),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's not a more concise way to share the ParameterSpec and the PropertySpec that have the same name and type, is there?

)
.addModifiers(KModifier.PRIVATE)
.initializer("dateTimeConverter")
.build(),
)
.apply {
mergedResources.forEach { mergedResource ->
val funSpec = mergedResource.toFunSpec(packageStringsType)
Expand Down Expand Up @@ -142,90 +190,25 @@ private fun Argument.toParameterSpec(): ParameterSpec =
},
)

private fun Argument.toParameterCodeBlock(): CodeBlock =
when (type) {
private fun Argument.toParameterCodeBlock(): CodeBlock {
return when (type) {
Duration::class -> CodeBlock.of("%L.inWholeSeconds", name)
LocalDate::class -> buildCodeBlock {
addCalendarInstance {
addStatement("set(%1L.year, %1L.monthValue·-·1, %1L.dayOfMonth)", name)
}
}

LocalTime::class -> buildCodeBlock {
addCalendarInstance {
addStatement("set(%T.HOUR_OF_DAY, %L.hour)", Types.Calendar, name)
addStatement("set(%T.MINUTE, %L.minute)", Types.Calendar, name)
addStatement("set(%T.SECOND, %L.second)", Types.Calendar, name)
addStatement("set(%T.MILLISECOND, %L.nano·/·1_000_000)", Types.Calendar, name)
}
}

LocalDateTime::class -> buildCodeBlock {
addCalendarInstance {
addDateTimeSetStatements(name)
}
}

// `Nothing` arg must be null, but passing null to the formatter replaces the whole format with
// "null". Passing an `Int` allows the formatter to function as expected.
Nothing::class -> CodeBlock.of("-1")

OffsetTime::class -> buildCodeBlock {
addCalendarInstance(timeZoneId = "\"GMT\${%L.offset.id}\"", name) {
addStatement("set(%T.HOUR_OF_DAY, %L.hour)", Types.Calendar, name)
addStatement("set(%T.MINUTE, %L.minute)", Types.Calendar, name)
addStatement("set(%T.SECOND, %L.second)", Types.Calendar, name)
addStatement("set(%T.MILLISECOND, %L.nano·/·1_000_000)", Types.Calendar, name)
}
}

OffsetDateTime::class -> buildCodeBlock {
addCalendarInstance(timeZoneId = "\"GMT\${%L.offset.id}\"", name) {
addDateTimeSetStatements(name)
}
}

ZonedDateTime::class -> buildCodeBlock {
addCalendarInstance(timeZoneId = "%L.zone.id", name) {
addDateTimeSetStatements(name)
}
}

ZoneOffset::class -> buildCodeBlock {
addCalendarInstance(timeZoneId = "\"GMT\${%L.id}\"", name)
}
LocalDate::class,
LocalTime::class,
LocalDateTime::class,
OffsetTime::class,
OffsetDateTime::class,
ZonedDateTime::class,
ZoneOffset::class,
-> CodeBlock.of("dateTimeConverter.convertToCalendar(%L)", name)

else -> CodeBlock.of("%L", name)
}

private fun CodeBlock.Builder.addCalendarInstance(
timeZoneId: String? = null,
vararg timeZoneIdArgs: Any? = emptyArray(),
applyBlock: (() -> Unit)? = null,
) {
val timeZoneReference = if (timeZoneId == null) "GMT_ZONE" else "getTimeZone($timeZoneId)"
add("%T.getInstance(\n⇥", Types.Calendar)
addStatement("%T.$timeZoneReference,", Types.TimeZone, *timeZoneIdArgs)
addStatement("%T.Builder().setExtension('u', \"ca-iso8601\").build(),", Types.ULocale)
add("⇤)")

if (applyBlock != null) {
add(".apply·{\n⇥")
applyBlock.invoke()
add("⇤}")
}
}

private fun CodeBlock.Builder.addDateTimeSetStatements(dateTimeArgName: String) {
add("set(\n⇥")
addStatement("%L.year,", dateTimeArgName)
addStatement("%L.monthValue·-·1,", dateTimeArgName)
addStatement("%L.dayOfMonth,", dateTimeArgName)
addStatement("%L.hour,", dateTimeArgName)
addStatement("%L.minute,", dateTimeArgName)
addStatement("%L.second,", dateTimeArgName)
add("⇤)\n")
addStatement("set(%T.MILLISECOND, %L.nano·/·1_000_000)", Types.Calendar, dateTimeArgName)
}

private fun MergedResource.Visibility.toKModifier(): KModifier {
Expand Down Expand Up @@ -278,9 +261,10 @@ private fun MergedResource.toIntOverloadFunSpec(overloaded: FunSpec): FunSpec {
}

private object Types {
val AndroidDateTimeConverter = ClassName("app.cash.paraphrase", "AndroidDateTimeConverter")
val ArrayMap = ClassName("androidx.collection", "ArrayMap")
val Calendar = ClassName("android.icu.util", "Calendar")
val DateTimeConverter = ClassName("app.cash.paraphrase", "DateTimeConverter")
val FormattedResource = ClassName("app.cash.paraphrase", "FormattedResource")
val TimeZone = ClassName("android.icu.util", "TimeZone")
val ULocale = ClassName("android.icu.util", "ULocale")

fun paraphraseResources(packageName: String) = ClassName(packageName, "ParaphraseResources")
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,11 @@ class ResourceWriterTest {
expectedClassVisibility: KModifier,
vararg expectedFunctionVisibility: Pair<String, KModifier>,
) {
assertOnFormattedResourcesObject { formattedResourcesObject ->
assertThat(formattedResourcesObject.modifiers).contains(expectedClassVisibility)
assertOnParaphraseResourcesClass { paraphraseResourcesClass ->
assertThat(paraphraseResourcesClass.modifiers).contains(expectedClassVisibility)

expectedFunctionVisibility.forEach { (name, expectedVisibility) ->
val function = formattedResourcesObject.funSpecs.find { it.name == name }
val function = paraphraseResourcesClass.funSpecs.find { it.name == name }
if (function == null) {
fail("Function with name <$name> not found")
} else {
Expand All @@ -152,8 +152,8 @@ class ResourceWriterTest {
),
)

result.assertOnFormattedResourcesObject { formattedResourcesObject ->
val testFun = formattedResourcesObject.funSpecs.single { it.name == "testFun" }
result.assertOnParaphraseResourcesClass { paraphraseResourcesClass ->
val testFun = paraphraseResourcesClass.funSpecs.single { it.name == "testFun" }
assertThat(testFun.annotations).contains(
AnnotationSpec.builder(Deprecated::class)
.addMember("%S", "Test message")
Expand All @@ -162,16 +162,16 @@ class ResourceWriterTest {
}
}

private inline fun FileSpec.assertOnFormattedResourcesObject(
block: (formattedResourcesObject: TypeSpec) -> Unit,
private inline fun FileSpec.assertOnParaphraseResourcesClass(
block: (paraphraseResourcesClass: TypeSpec) -> Unit,
) {
val formattedResourcesObject = members
val paraphraseResourcesClass = members
.filterIsInstance<TypeSpec>()
.find { it.name == "FormattedResources" }
if (formattedResourcesObject == null) {
fail("FormattedResources object not found")
.find { it.name == "ParaphraseResources" }
if (paraphraseResourcesClass == null) {
fail("ParaphraseResources class not found")
} else {
block(formattedResourcesObject)
block(paraphraseResourcesClass)
}
}
}
18 changes: 18 additions & 0 deletions runtime-test/api/runtime-test.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
public final class app/cash/paraphrase/runtime/test/JvmDateTimeConverter : app/cash/paraphrase/DateTimeConverter {
public static final field INSTANCE Lapp/cash/paraphrase/runtime/test/JvmDateTimeConverter;
public fun convertToCalendar (Ljava/time/LocalDate;)Lcom/ibm/icu/util/Calendar;
public synthetic fun convertToCalendar (Ljava/time/LocalDate;)Ljava/lang/Object;
public fun convertToCalendar (Ljava/time/LocalDateTime;)Lcom/ibm/icu/util/Calendar;
public synthetic fun convertToCalendar (Ljava/time/LocalDateTime;)Ljava/lang/Object;
public fun convertToCalendar (Ljava/time/LocalTime;)Lcom/ibm/icu/util/Calendar;
public synthetic fun convertToCalendar (Ljava/time/LocalTime;)Ljava/lang/Object;
public fun convertToCalendar (Ljava/time/OffsetDateTime;)Lcom/ibm/icu/util/Calendar;
public synthetic fun convertToCalendar (Ljava/time/OffsetDateTime;)Ljava/lang/Object;
public fun convertToCalendar (Ljava/time/OffsetTime;)Lcom/ibm/icu/util/Calendar;
public synthetic fun convertToCalendar (Ljava/time/OffsetTime;)Ljava/lang/Object;
public fun convertToCalendar (Ljava/time/ZoneOffset;)Lcom/ibm/icu/util/Calendar;
public synthetic fun convertToCalendar (Ljava/time/ZoneOffset;)Ljava/lang/Object;
public fun convertToCalendar (Ljava/time/ZonedDateTime;)Lcom/ibm/icu/util/Calendar;
public synthetic fun convertToCalendar (Ljava/time/ZonedDateTime;)Ljava/lang/Object;
}

26 changes: 26 additions & 0 deletions runtime-test/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlinAndroid)
alias(libs.plugins.mavenPublish)
}

android {
namespace = "app.cash.paraphrase.test"
compileSdk = 34

defaultConfig {
minSdk = 24
}

compileOptions {
isCoreLibraryDesugaringEnabled = true
}
}

dependencies {
api(projects.runtime)

api(libs.icu4j)

coreLibraryDesugaring(libs.coreLibraryDesugaring)
}
3 changes: 3 additions & 0 deletions runtime-test/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Maven
POM_ARTIFACT_ID=paraphrase-runtime-test
POM_NAME=Paraphrase test runtime
Loading