Repository: cashapp/paraphrase Branch: main Commit: ccf406337c10 Files: 87 Total size: 195.7 KB Directory structure: gitextract_8udyv6_s/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── renovate.json5 │ └── workflows/ │ ├── .java-version │ ├── build.yaml │ └── release.yaml ├── .gitignore ├── .idea/ │ └── copyright/ │ ├── Square.xml │ └── profiles_settings.xml ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── RELEASING.md ├── build-logic/ │ ├── build.gradle.kts │ └── settings.gradle.kts ├── build.gradle.kts ├── gradle/ │ ├── libs.versions.toml │ ├── license-header.txt │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── plugin/ │ ├── api/ │ │ └── plugin.api │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── app/ │ │ └── cash/ │ │ └── paraphrase/ │ │ └── plugin/ │ │ ├── ArgumentTypeResolver.kt │ │ ├── GenerateFormattedResources.kt │ │ ├── NodeListIterator.kt │ │ ├── ParaphrasePlugin.kt │ │ ├── PublicResourceParser.kt │ │ ├── ResourceMerger.kt │ │ ├── ResourceParser.kt │ │ ├── ResourceTokenizer.kt │ │ ├── ResourceWriter.kt │ │ └── model/ │ │ ├── MergedResource.kt │ │ ├── PublicResource.kt │ │ ├── ResourceFolder.kt │ │ ├── ResourceName.kt │ │ ├── StringResource.kt │ │ └── TokenizedResource.kt │ └── test/ │ └── java/ │ └── app/ │ └── cash/ │ └── paraphrase/ │ └── plugin/ │ ├── ArgumentTypeResolverTest.kt │ ├── PublicResourceParserTest.kt │ ├── ResourceMergerTest.kt │ ├── ResourceParserTest.kt │ ├── ResourceTokenizerTest.kt │ └── ResourceWriterTest.kt ├── runtime/ │ ├── api/ │ │ └── runtime.api │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── app/ │ │ └── cash/ │ │ └── paraphrase/ │ │ ├── FormattedResource.kt │ │ └── FormattedResources.kt │ └── test/ │ └── java/ │ └── app/ │ └── cash/ │ └── paraphrase/ │ └── FormattedResourceTest.kt ├── runtime-compose-ui/ │ ├── api/ │ │ └── runtime-compose-ui.api │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ └── main/ │ └── java/ │ └── app/ │ └── cash/ │ └── paraphrase/ │ └── compose/ │ └── ComposableFormattedResources.kt ├── sample/ │ ├── app/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidTest/ │ │ │ ├── kotlin/ │ │ │ │ └── app/ │ │ │ │ └── cash/ │ │ │ │ └── paraphrase/ │ │ │ │ └── sample/ │ │ │ │ └── app/ │ │ │ │ └── ParaphraseTest.kt │ │ │ └── res/ │ │ │ └── values/ │ │ │ └── strings.xml │ │ ├── debug/ │ │ │ └── res/ │ │ │ └── values/ │ │ │ └── strings.xml │ │ ├── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── java/ │ │ │ │ └── app/ │ │ │ │ └── cash/ │ │ │ │ └── paraphrase/ │ │ │ │ └── sample/ │ │ │ │ └── app/ │ │ │ │ └── MainActivity.kt │ │ │ └── res/ │ │ │ ├── drawable/ │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── drawable-v24/ │ │ │ │ └── ic_launcher_foreground.xml │ │ │ ├── mipmap-anydpi-v26/ │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values/ │ │ │ │ ├── colors.xml │ │ │ │ ├── strings.xml │ │ │ │ └── themes.xml │ │ │ └── values-night/ │ │ │ └── themes.xml │ │ └── release/ │ │ └── res/ │ │ └── values/ │ │ └── strings.xml │ └── library/ │ ├── build.gradle.kts │ └── src/ │ ├── androidTest/ │ │ ├── kotlin/ │ │ │ └── app/ │ │ │ └── cash/ │ │ │ └── paraphrase/ │ │ │ └── sample/ │ │ │ └── library/ │ │ │ └── ParaphraseTest.kt │ │ └── res/ │ │ └── values/ │ │ └── strings.xml │ └── main/ │ └── res/ │ └── values/ │ └── strings.xml ├── settings.gradle.kts └── tests/ ├── build.gradle.kts └── src/ └── main/ ├── kotlin/ │ └── app/ │ └── cash/ │ └── paraphrase/ │ └── tests/ │ ├── LocaleAndTimeZoneRule.kt │ ├── LocalesTest.kt │ ├── NamedTest.kt │ ├── NumberedTest.kt │ └── TypesTest.kt └── res/ └── values/ ├── locales.xml ├── named.xml ├── numbered.xml └── types.xml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_size = 2 charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.{kt, kts}] ij_kotlin_allow_trailing_comma = true ij_kotlin_allow_trailing_comma_on_call_site = true ij_kotlin_imports_layout = * ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf *.bat text eol=crlf *.jar binary ================================================ FILE: .github/renovate.json5 ================================================ { $schema: 'https://docs.renovatebot.com/renovate-schema.json', extends: [ 'config:recommended', ], packageRules: [ // Compiler plugins are tightly coupled to Kotlin version. { groupName: 'Kotlin', matchPackageNames: [ 'androidx.compose.compiler{/,}**', 'dev.drewhamilton.poko{/,}**', 'org.jetbrains.kotlin{/,}**', ], }, ], ignorePresets: [ // Ensure we get the latest version and are not pinned to old versions. 'workarounds:javaLTSVersions', ], customManagers: [ // Update .java-version file with the latest JDK version. { customType: 'regex', fileMatch: [ '\\.java-version$', ], matchStrings: [ '(?.*)\\n', ], datasourceTemplate: 'java-version', depNameTemplate: 'java', // Only write the major version. extractVersionTemplate: '^(?\\d+)', }, ] } ================================================ FILE: .github/workflows/.java-version ================================================ 25 ================================================ FILE: .github/workflows/build.yaml ================================================ name: build on: pull_request: {} workflow_dispatch: {} push: branches: - 'main' tags-ignore: - '**' env: GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx6g -Dorg.gradle.daemon=false -Dkotlin.incremental=false" jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-java@v5 with: distribution: 'zulu' java-version-file: .github/workflows/.java-version - uses: gradle/actions/setup-gradle@v6 - run: ./gradlew build emulator: runs-on: ubuntu-latest steps: - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - uses: actions/checkout@v6 - uses: actions/setup-java@v5 with: distribution: 'zulu' java-version-file: .github/workflows/.java-version - uses: gradle/actions/setup-gradle@v6 - name: Run integration tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 24 script: ./gradlew :tests:connectedCheck publish: runs-on: ubuntu-latest if: ${{ github.ref == 'refs/heads/main' && github.repository == 'cashapp/paraphrase' }} needs: - build - emulator steps: - uses: actions/checkout@v6 - uses: actions/setup-java@v5 with: distribution: 'zulu' java-version-file: .github/workflows/.java-version - uses: gradle/actions/setup-gradle@v6 - run: ./gradlew dokkaGenerate publish env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_CENTRAL_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_CENTRAL_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SECRET_PASSPHRASE }} - name: Deploy docs to website uses: JamesIves/github-pages-deploy-action@releases/v3 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: site FOLDER: build/dokka/html/ TARGET_FOLDER: docs/latest/ CLEAN: true ================================================ FILE: .github/workflows/release.yaml ================================================ name: release on: push: tags: - '**' env: GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dkotlin.incremental=false" jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-java@v5 with: distribution: 'zulu' java-version-file: .github/workflows/.java-version - name: Build and publish artifacts env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_CENTRAL_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_CENTRAL_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SECRET_PASSPHRASE }} run: ./gradlew publish - name: Extract release notes id: release_notes uses: ffurrer2/extract-release-notes@v3 - name: Create release uses: ncipollo/release-action@v1 with: body: ${{ steps.release_notes.outputs.release_notes }} discussionCategory: Announcements - run: ./gradlew dokkaGenerate - name: Deploy docs to website uses: JamesIves/github-pages-deploy-action@releases/v3 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: site FOLDER: build/dokka/html/ TARGET_FOLDER: 0.x/docs/ CLEAN: true ================================================ FILE: .gitignore ================================================ # IntelliJ IDEA .idea/* !.idea/copyright # Gradle .gradle build /reports # Android local.properties # Kotlin .kotlin ================================================ FILE: .idea/copyright/Square.xml ================================================ ================================================ FILE: .idea/copyright/profiles_settings.xml ================================================ ================================================ FILE: CHANGELOG.md ================================================ # Change Log ## [Unreleased] [Unreleased]: https://github.com/cashapp/paraphrase/compare/0.6.1...HEAD ## [0.6.1] - 2026-03-04 [0.6.1]: https://github.com/cashapp/paraphrase/releases/tag/0.6.1 Fixed: - Fix crash when generating KDocs that contain URL encoded characters. ## [0.6.0] - 2026-03-03 [0.6.0]: https://github.com/cashapp/paraphrase/releases/tag/0.6.0 New: - Paraphrase now generates `FormattedResources` properties for no-arg resources. ## [0.5.0] - 2025-11-10 [0.5.0]: https://github.com/cashapp/paraphrase/releases/tag/0.5.0 New: - `FormattedResource` is now `Parcelable`. Changed: - The minimum-supported Gradle version is now 9.0. ## [0.4.1] - 2025-09-12 [0.4.1]: https://github.com/cashapp/paraphrase/releases/tag/0.4.1 New: - Support for AGP 9.0.0 Changed: - In-development snapshots are now published to the Central Portal Snapshots repository at https://central.sonatype.com/repository/maven-snapshots/. ## [0.4.0] - 2024-06-11 [0.4.0]: https://github.com/cashapp/paraphrase/releases/tag/0.4.0 New: - Support for Kotlin 2.0.0 Changed: - Detect usage of `org.jetbrains.kotlin.plugin.compose` plugin and automatically add the `runtime-compose-ui` dependency. ## [0.3.1] - 2023-12-14 [0.3.1]: https://github.com/cashapp/paraphrase/releases/tag/0.3.1 Fixed: - Include file names in read/parse failures to help with debugging - Fix crash when processing non-XML resource files ## [0.3.0] - 2023-10-12 [0.3.0]: https://github.com/cashapp/paraphrase/releases/tag/0.3.0 Changed: - Generate `Number` parameter instead of `Int` for plural and choice arguments - Generate `Long` parameter along with `Int` overload for ordinal and selectordinal arguments - Deprecate generated function using choice arguments, which are discouraged in ICU documentation in favor of plural and select arguments. ## [0.2.2] - 2023-07-20 [0.2.2]: https://github.com/cashapp/paraphrase/releases/tag/0.2.2 Fixed: - Fix build cache issue by deleting the generated class when all string resources are removed ## [0.2.1] - 2023-07-19 [0.2.1]: https://github.com/cashapp/paraphrase/releases/tag/0.2.1 Changed: - Automatically add the `runtime-compose-ui` dependency if buildFeatures.compose is true - Add missing `)` to `FormattedResource.toString` - Optimize insertion performance of map arguments ## [0.2.0] - 2023-04-25 [0.2.0]: https://github.com/cashapp/paraphrase/releases/tag/0.2.0 New: - Add runtime support for Compose UI ## [0.1.2] - 2023-04-17 [0.1.2]: https://github.com/cashapp/paraphrase/releases/tag/0.1.2 Changed: - Use `androidx.collection.ArrayMap` instead of `android.util.ArrayMap` to hold named arguments. ## [0.1.1] - 2023-04-07 [0.1.1]: https://github.com/cashapp/paraphrase/releases/tag/0.1.1 Fixed: - Fix crash when processing modules with no merged resources. ## [0.1.0] - 2023-04-06 [0.1.0]: https://github.com/cashapp/paraphrase/releases/tag/0.1.0 Initial release. ================================================ FILE: LICENSE.txt ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # Paraphrase A Gradle plugin that generates type-safe formatters for Android string resources in the ICU message format. It integrates easily with Android Views and Compose UI. ## Usage ### Step 1: Add the Paraphrase Plugin In the `build.gradle.kts` file of an Android application or library module: ```kotlin plugins { id("app.cash.paraphrase") version "0.6.1" } ``` ### Step 2: Add an ICU String Resource In the `strings.xml` file within the module: ```xml {count, plural, =0 {{name} does not order any bagels} =1 {{name} orders an everything bagel} other {{name} orders # everything bagels} } ``` For more information on the ICU message format, see the [ICU docs](https://unicode-org.github.io/icu/userguide/format_parse/messages). ### Step 3: Generate the Formatted Resources Build the module: ```shell ./gradlew my-module:build ``` Or run the Paraphrase gradle task for the relevant variant: ```shell ./gradlew my-module:generateFormattedResourcesDebug ./gradlew my-module:generateFormattedResourcesRelease ``` That generates a formatted resource function that looks something like this: ```kotlin /** * Describes an order placed at the deli. */ public fun order_description(count: Int, name: Any): FormattedResource { val arguments = mapOf("count" to count, "name" to name) return FormattedResource( id = R.string.order_description, arguments = arguments, ) } ``` ### Step 4: Use the Formatted Resources In an Android View: ```kotlin import app.cash.paraphrase.getString val orderDescription = resources.getString( FormattedResources.order_description( count = 12, name = "Jobu Tupaki", ) ) // Jobu Tupaki orders 12 everything bagels ``` In Compose UI: ```kotlin import app.cash.paraphrase.compose.formattedResource val orderDescription = formattedResource( FormattedResources.order_description( count = 12, name = "Jobu Tupaki", ), ) // Jobu Tupaki orders 12 everything bagels ``` ## Modules * [plugin](plugin): The Gradle plugin, with logic to parse string resources and generate formatter methods. * [runtime](runtime): The data types and Android extensions that Paraphrase requires to work at runtime. * [runtime-compose-ui](runtime-compose-ui): The extensions that Paraphrase requires to work with Compose UI at runtime. * [sample](sample): A sample Android project that demonstrates usage of Paraphrase. ## License Copyright 2023 Cash App Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: RELEASING.md ================================================ # Releasing 1. Update the `VERSION_NAME` in `gradle.properties` to the release version (i.e., no "-SNAPSHOT" suffix). 2. Update the `CHANGELOG.md`: 1. Change the `Unreleased` header to the release version. 2. Add a link URL to the bottom of the page, to ensure the header link works. 3. Add a new `Unreleased` section to the top. 3. Update the `README.md` so the "Download" section reflects the new release version and the snapshot section reflects the next "SNAPSHOT" version. 4. Commit ``` $ git commit -am "Prepare version X.Y.Z" ``` 5. Tag ``` $ git tag -am "Version X.Y.Z" X.Y.Z ``` 6. Update the `VERSION_NAME` in `gradle.properties` to what is likely the next version and re-append the "-SNAPSHOT" suffix' 7. Commit ``` $ git commit -am "Prepare next development version" ``` 8. Push! ``` $ git push && git push --tags ``` This will trigger a GitHub Action workflow which will create a GitHub release, upload the release artifacts to Sonatype Nexus, and trigger synchronization to Maven Central. You're done! ================================================ FILE: build-logic/build.gradle.kts ================================================ ================================================ FILE: build-logic/settings.gradle.kts ================================================ pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } @Suppress("UnstableApiUsage") dependencyResolutionManagement { versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } } repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } rootProject.name = "build-logic" include( ":", ":plugin" ) project(":plugin").projectDir = File("../plugin") ================================================ FILE: build.gradle.kts ================================================ import com.android.build.api.dsl.CommonExtension import com.diffplug.gradle.spotless.SpotlessExtension import com.vanniktech.maven.publish.JavadocJar import com.vanniktech.maven.publish.MavenPublishBaseExtension import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile buildscript { dependencies { classpath("app.cash.paraphrase:plugin") } } plugins { alias(libs.plugins.androidApplication) apply false alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.androidTest) apply false alias(libs.plugins.kotlinJvm) apply false alias(libs.plugins.kotlinParcelize) apply false alias(libs.plugins.poko) apply false alias(libs.plugins.kotlinApiDump) apply false alias(libs.plugins.dokka) alias(libs.plugins.mavenPublish) alias(libs.plugins.spotless) } dependencies { dokka(projects.runtime) dokka(projects.runtimeComposeUi) } configure { kotlin { target("**/*.kt") ktfmt(libs.ktfmt.get().version).googleStyle() licenseHeaderFile(file("gradle/license-header.txt")) } kotlinGradle { ktfmt(libs.ktfmt.get().version).googleStyle() } } subprojects { version = extra["VERSION_NAME"]!! plugins.withId("com.vanniktech.maven.publish") { // Disable Javadoc jars. They're basically useless relics, but enabling this will also cause // AGP to use an old version of Dokka which fails to run on the latest Java versions. extensions .getByType(MavenPublishBaseExtension::class) .configureBasedOnAppliedPlugins(javadocJar = JavadocJar.Empty()) // All published libraries must use API tracking to help maintain compatibility. plugins.apply(libs.plugins.kotlinApiDump.get().pluginId) val kotlin = extensions.getByName("kotlin") as KotlinBaseExtension kotlin.explicitApi() publishing { repositories { /** * Want to push to an internal repository for testing? Set the following properties in * `~/.gradle/gradle.properties`. * * ``` * internalUrl=YOUR_INTERNAL_URL * internalUsername=YOUR_USERNAME * internalPassword=YOUR_PASSWORD * ``` */ val internalUrl = providers.gradleProperty("internalUrl") if (internalUrl.isPresent()) { maven { name = "internal" setUrl(internalUrl) credentials(PasswordCredentials::class) } } } } } val javaVersion = JavaVersion.VERSION_1_8 tasks.withType { compilerOptions { jvmTarget = JvmTarget.fromTarget(javaVersion.toString()) } } val configureAndroid = Action> { with(extensions.getByType()) { compileSdk = 36 defaultConfig.minSdk = 24 compileOptions.apply { sourceCompatibility = javaVersion targetCompatibility = javaVersion } } } plugins.withId("com.android.application", configureAndroid) plugins.withId("com.android.library", configureAndroid) plugins.withId("com.android.test", configureAndroid) } ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] agp = "9.2.1" kotlin = "2.3.21" [libraries] agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } androidActivityCompose = "androidx.activity:activity-compose:1.13.0" androidAnnotation = "androidx.annotation:annotation:1.10.0" androidCollection = "androidx.collection:collection:1.6.0" androidTestRunner = "androidx.test:runner:1.7.0" composeMaterial = "androidx.compose.material:material:1.11.1" composeUi = "androidx.compose.ui:ui:1.11.1" googleMaterial = "com.google.android.material:material:1.13.0" icu4j = "com.ibm.icu:icu4j:78.3" junit = "junit:junit:4.13.2" kotlinPoet = "com.squareup:kotlinpoet:2.3.0" robolectric = "org.robolectric:robolectric:4.16.1" testParameterInjector = "com.google.testparameterinjector:test-parameter-injector:1.22" assertk = "com.willowtreeapps.assertk:assertk:0.28.1" coreLibraryDesugaring = "com.android.tools:desugar_jdk_libs:2.1.5" ktfmt = "com.facebook:ktfmt:0.62" [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } androidTest = { id = "com.android.test", version.ref = "agp" } buildConfig = { id = "com.github.gmazzo.buildconfig", version = "6.0.9" } kotlinCompose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinParcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } dokka = { id = "org.jetbrains.dokka", version = "2.2.0" } mavenPublish = { id = "com.vanniktech.maven.publish", version = "0.36.0" } poko = { id = "dev.drewhamilton.poko", version = "0.22.1" } spotless = { id = "com.diffplug.spotless", version = "8.4.0" } kotlinApiDump = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.18.1" } ================================================ FILE: gradle/license-header.txt ================================================ /* * Copyright (C) $YEAR Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # Maven GROUP=app.cash.paraphrase # HEY! If you change major version update release.yaml doc folder. VERSION_NAME=0.7.0-SNAPSHOT POM_DESCRIPTION=Type-checked formatters for patterned Android string resources POM_URL=https://github.com/cashapp/paraphrase/ POM_SCM_URL=https://github.com/cashapp/paraphrase/ POM_SCM_CONNECTION=scm:git:git://github.com/cashapp/paraphrase.git POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/cashapp/paraphrase.git POM_LICENCE_NAME=Apache-2.0 POM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0 POM_LICENCE_DIST=repo POM_DEVELOPER_ID=cashapp POM_DEVELOPER_NAME=CashApp POM_DEVELOPER_URL=https://github.com/cashapp mavenCentralPublishing=true mavenCentralAutomaticPublishing=true signAllPublications=true # Signals to our own plugin that we are building within the repo. app.cash.paraphrase.internal=true android.defaults.buildfeatures.resvalues=false android.defaults.buildfeatures.shaders=false kotlin.code.style=official org.gradle.jvmargs=-Xmx2048m -Dfile.encoding\=UTF-8 ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 "%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 "%COMSPEC%" /c exit 1 :execute @rem Setup the command line @rem Execute Gradle @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% ================================================ FILE: plugin/api/plugin.api ================================================ public final class app/cash/paraphrase/plugin/ParaphrasePlugin : org/gradle/api/Plugin { public fun ()V public synthetic fun apply (Ljava/lang/Object;)V public fun apply (Lorg/gradle/api/Project;)V } ================================================ FILE: plugin/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile @Suppress("DSL_SCOPE_VIOLATION") plugins { `java-gradle-plugin` alias(libs.plugins.kotlinJvm) alias(libs.plugins.buildConfig) alias(libs.plugins.mavenPublish) } buildConfig { useKotlinOutput { internalVisibility = true } packageName("app.cash.paraphrase.plugin") buildConfigField("String", "VERSION", "\"${project.version}\"") buildConfigField("String", "LIB_ANDROID_COLLECTION", "\"${libs.androidCollection.get()}\"") } gradlePlugin { plugins { create("paraphrase") { id = "app.cash.paraphrase" implementationClass = "app.cash.paraphrase.plugin.ParaphrasePlugin" } } } tasks.named("validatePlugins", ValidatePlugins::class) { enableStricterValidation = true } tasks.withType { compilerOptions { jvmTarget = JvmTarget.JVM_17 // Ensure compatibility with older Gradle versions. Keep in sync with ParaphrasePlugin.kt. apiVersion = KotlinVersion.KOTLIN_2_2 languageVersion = KotlinVersion.KOTLIN_2_2 } } java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } dependencies { compileOnly(libs.agp) implementation(libs.icu4j) implementation(libs.kotlinPoet) testImplementation(libs.junit) testImplementation(libs.assertk) } ================================================ FILE: plugin/gradle.properties ================================================ # Maven POM_ARTIFACT_ID=paraphrase-plugin POM_NAME=Paraphrase Gradle plugin # Omit automatic compile dependency on kotlin-stdlib. We inherit what Gradle provides. kotlin.stdlib.default.dependency=false ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/ArgumentTypeResolver.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.TokenType.Choice import app.cash.paraphrase.plugin.TokenType.Date import app.cash.paraphrase.plugin.TokenType.DateTime import app.cash.paraphrase.plugin.TokenType.DateTimeWithOffset import app.cash.paraphrase.plugin.TokenType.DateTimeWithZone import app.cash.paraphrase.plugin.TokenType.Duration import app.cash.paraphrase.plugin.TokenType.NoArg import app.cash.paraphrase.plugin.TokenType.None import app.cash.paraphrase.plugin.TokenType.Number import app.cash.paraphrase.plugin.TokenType.Offset import app.cash.paraphrase.plugin.TokenType.Ordinal import app.cash.paraphrase.plugin.TokenType.Plural import app.cash.paraphrase.plugin.TokenType.Select import app.cash.paraphrase.plugin.TokenType.SelectOrdinal import app.cash.paraphrase.plugin.TokenType.SpellOut import app.cash.paraphrase.plugin.TokenType.Time import app.cash.paraphrase.plugin.TokenType.TimeWithOffset import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.OffsetDateTime import java.time.OffsetTime import java.time.ZoneOffset import java.time.ZonedDateTime import kotlin.Number as KotlinNumber import kotlin.reflect.KClass import kotlin.time.Duration as KotlinDuration /** * Returns the final argument type for the given list of token types, or null if there is no * suitable argument type for the given combination of token types. * * For example: * - [Date] -> [LocalDate] * - [Date] + [Time] -> [LocalDateTime] * - [Date] + [Plural] -> null */ internal fun resolveArgumentType(tokenTypes: List): KClass<*>? = when (resolveCompatibleTokenType(tokenTypes)) { null -> null None -> Any::class Choice, Number, Plural, SpellOut -> KotlinNumber::class Date -> LocalDate::class Time -> LocalTime::class TimeWithOffset -> OffsetTime::class DateTime -> LocalDateTime::class DateTimeWithOffset -> OffsetDateTime::class DateTimeWithZone -> ZonedDateTime::class Offset -> ZoneOffset::class Duration -> KotlinDuration::class Ordinal, SelectOrdinal -> Long::class Select -> String::class NoArg -> Nothing::class } private fun resolveCompatibleTokenType(tokens: List): TokenType? = tokens.reduceOrNull(::resolveCompatibleTokenType) private fun resolveCompatibleTokenType(first: TokenType?, second: TokenType?): TokenType? = when { first == null || second == null -> null first == second || second.compatibleTypes.contains(first) -> first first.compatibleTypes.contains(second) -> second else -> first.compatibleTypes.firstOrNull { second.compatibleTypes.contains(it) } } private val TokenType.compatibleTypes: List get() = compatibleTokenTypes[this]!! /** * For a token of type A, tokens of type B are considered compatible if the argument type that * satisfies B also satisfies A. * * For example: * - [DateTime] is compatible with [Date], because [LocalDateTime] contains the date information * required by [Date] tokens. * - [Date] is not compatible with [DateTime], because [LocalDate] does not contain the time * information required by [DateTime] tokens. * * Compatible types are ordered from least restrictive to most restrictive. [ZonedDateTime] contains * a superset of the information in [DateTime], so it comes later in the list of types compatible * with [Date]. */ private val compatibleTokenTypes: Map> = mapOf( None to TokenType.values().asList(), Number to listOf(Choice, Ordinal, Plural, SelectOrdinal, SpellOut), Date to listOf(DateTime, DateTimeWithOffset, DateTimeWithZone), Time to listOf(DateTime, TimeWithOffset, DateTimeWithOffset, DateTimeWithZone), TimeWithOffset to listOf(DateTimeWithOffset, DateTimeWithZone), DateTime to listOf(DateTimeWithOffset, DateTimeWithZone), DateTimeWithOffset to listOf(DateTimeWithZone), DateTimeWithZone to emptyList(), Offset to listOf(TimeWithOffset, DateTimeWithOffset, DateTimeWithZone), SpellOut to listOf(Choice, Number, Ordinal, Plural, SelectOrdinal), Ordinal to listOf(SelectOrdinal), Duration to emptyList(), Choice to listOf(Number, Ordinal, Plural, SelectOrdinal, SpellOut), Plural to listOf(Choice, Number, Ordinal, SelectOrdinal, SpellOut), Select to emptyList(), SelectOrdinal to listOf(Ordinal), NoArg to emptyList(), ) ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/GenerateFormattedResources.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.model.ResourceFolder import java.io.File import java.io.InputStream import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity.RELATIVE import org.gradle.api.tasks.TaskAction /** * A Gradle task that reads all of the Android string resources in a module and then generates * formatted resource methods for any that contain ICU arguments. */ @CacheableTask internal abstract class GenerateFormattedResources @Inject constructor() : DefaultTask() { @get:Input abstract val namespace: Property @get:InputFiles @get:PathSensitive(RELATIVE) abstract val resourceDirectories: ConfigurableFileCollection @get:OutputDirectory abstract val outputDirectory: DirectoryProperty @TaskAction fun generateFormattedStringResources() { outputDirectory.get().asFile.deleteRecursively() // Extract the 'values'-style directories from each resource directory. val valuesFolders = resourceDirectories.files .flatMap { it.listFiles().orEmpty().toList() } .filter { it.name == "values" || it.name.startsWith("values-") } // Turn each resource folder into a map of its name to its files. // // Example: // values -> [strings.xml, dimens.xml] // values-es -> [strings.xml] val filesByConfiguration = valuesFolders.associate { folder -> ResourceFolder(folder.name) to folder.listFiles().orEmpty().filter { it.extension.equals("xml", ignoreCase = true) } } // Parse the files in each folder into the tokenized resources. // // Example: // values -> [TokenizedResource(name=hi, ..), TokenizedResource(name=hello, ..)] // values-es -> [TokenizedResource(name=hello, ..)] val resourcesByConfiguration = filesByConfiguration.mapValues { (_, files) -> files.flatMap { it.checkedRead(::parseResources) }.map(::tokenizeResource) } // Split the folder map into individual maps keyed on resource name. // // Example: // hello -> { values -> TokenizedResource(..) // values-es -> TokenizedResource(..) } // hi -> { values -> TokenizedResource(..) } val resourceConfigurationsByName = resourcesByConfiguration .flatMap { (key, resources) -> resources.map { resource -> key to resource } } .groupBy { (_, resource) -> resource.name } .mapValues { (_, value) -> value.toMap() } // Parse the files in each folder into a set of public resource declarations. // TODO: Can limit parsing to only public.xml? The wording used at // https://developer.android.com/studio/projects/android-library#PrivateResources suggests this // is the case. Check AGP source. val publicResources = (filesByConfiguration[ResourceFolder.Default] ?: emptyList()) .flatMap { it.checkedRead(::parsePublicResources) } .toSet() // Merge each resource's configuration map into final, canonical versions. val mergedResources = resourceConfigurationsByName.mapNotNull { (name, resourceByConfiguration) -> mergeResources(name, resourceByConfiguration, publicResources) } if (mergedResources.isNotEmpty()) { writeResources(namespace.get(), mergedResources).writeTo(outputDirectory.get().asFile) } // TODO Fail on errors which make it this far. } private fun File.checkedRead(parser: (InputStream) -> T): T { return try { inputStream().buffered().use(parser) } catch (e: Exception) { throw IllegalArgumentException("Unable to parse $this", e) } } } ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/NodeListIterator.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import org.w3c.dom.Node import org.w3c.dom.NodeList internal fun NodeList.asIterator(): Iterator = object : Iterator { private var index = 0 override fun hasNext(): Boolean = index < length override fun next(): Node = item(index++) } ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/ParaphrasePlugin.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import com.android.build.api.dsl.CommonExtension import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.HasAndroidTest import com.android.build.api.variant.Sources import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.provider.Provider import org.gradle.util.GradleVersion /** * A Gradle plugin that generates type checked formatters for patterned Android string resources. */ public class ParaphrasePlugin : Plugin { override fun apply(target: Project): Unit = target.run { // If you update the minimum-supported Gradle version, check if the Kotlin api/language // version // can be bumped. See https://docs.gradle.org/current/userguide/compatibility.html#kotlin. val gradleMinimum = GradleVersion.version("9.0") val gradleCurrent = GradleVersion.current() require(gradleCurrent >= gradleMinimum) { "Plugin requires $gradleMinimum or newer. Found $gradleCurrent" } addDependencies() extensions.getByType(AndroidComponentsExtension::class.java).onVariants { variant -> registerGenerateFormattedResourcesTask( sources = variant.sources, name = variant.name, namespace = variant.namespace, ) (variant as? HasAndroidTest)?.androidTest?.let { androidTest -> registerGenerateFormattedResourcesTask( sources = androidTest.sources, name = androidTest.name, namespace = androidTest.namespace, ) } } } private fun Project.addDependencies() { val isInternal = properties["app.cash.paraphrase.internal"].toString() == "true" // Automatically add the runtime dependency. val runtimeDependency: Any = if (isInternal) { dependencies.project(mapOf("path" to ":runtime")) } else { "app.cash.paraphrase:paraphrase-runtime:${BuildConfig.VERSION}" } dependencies.add("api", runtimeDependency) // Automatically add the runtime Compose UI dependency if Compose is being used. afterEvaluate { val hasComposeFeature = extensions.getByType(CommonExtension::class.java).buildFeatures.compose == true val hasComposePlugin = pluginManager.hasPlugin("org.jetbrains.kotlin.plugin.compose") if (hasComposeFeature || hasComposePlugin) { val runtimeComposeUiDependency: Any = if (isInternal) { dependencies.project(mapOf("path" to ":runtime-compose-ui")) } else { "app.cash.paraphrase:paraphrase-runtime-compose-ui:${BuildConfig.VERSION}" } dependencies.add("implementation", runtimeComposeUiDependency) } } // Automatically add the AndroidX Collection dependency for ArrayMap. dependencies.add("implementation", BuildConfig.LIB_ANDROID_COLLECTION) } private fun Project.registerGenerateFormattedResourcesTask( sources: Sources, name: String, namespace: Provider, ) { val javaSources = sources.java ?: return val resSources = sources.res ?: return tasks .register( "generateFormattedResources${name.replaceFirstChar { it.uppercase() }}", GenerateFormattedResources::class.java, ) .apply { javaSources.addGeneratedSourceDirectory(this, GenerateFormattedResources::outputDirectory) configure { task -> task.description = "Generates type-safe formatters for $name string resources" task.namespace.set(namespace) task.resourceDirectories.from(resSources.all) task.outputDirectory.set(layout.buildDirectory.dir("generated/source/paraphrase/$name")) } } } } ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/PublicResourceParser.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.model.PublicResource import app.cash.paraphrase.plugin.model.ResourceName import java.io.InputStream import javax.xml.parsers.DocumentBuilderFactory import org.w3c.dom.Node /** * Parses and returns all of the Android resources declared in the given stream, regardless * of type. */ internal fun parsePublicResources(inputStream: InputStream): List = DocumentBuilderFactory.newInstance() .newDocumentBuilder() .parse(inputStream) .getElementsByTagName("public") .asIterator() .asSequence() .filter { it.nodeType == Node.ELEMENT_NODE } .map { val name = it.attributes.getNamedItem("name")?.childNodes?.item(0)?.textContent val type = it.attributes.getNamedItem("type")?.childNodes?.item(0)?.textContent when { name != null && type != null -> PublicResource.Named(name = ResourceName(name), type = type) name == null && type == null -> PublicResource.EmptyDeclaration name == null -> throw IllegalArgumentException(" resource with type $type must have a name") else -> throw IllegalArgumentException(" resource named $name has no type") } } .toList() ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/ResourceMerger.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.model.MergedResource import app.cash.paraphrase.plugin.model.PublicResource import app.cash.paraphrase.plugin.model.ResourceFolder import app.cash.paraphrase.plugin.model.ResourceName import app.cash.paraphrase.plugin.model.TokenizedResource import app.cash.paraphrase.plugin.model.TokenizedResource.Token import app.cash.paraphrase.plugin.model.TokenizedResource.Token.NamedToken import app.cash.paraphrase.plugin.model.TokenizedResource.Token.NumberedToken internal fun mergeResources( name: ResourceName, tokenizedResources: Map, publicResources: Collection, ): MergedResource? { // TODO For now, we only process strings in the default "values" folder. val defaultResource = tokenizedResources[ResourceFolder.Default] ?: return null val hasContiguousNumberedTokens = run { val argumentCount = defaultResource.tokens .mapTo(mutableSetOf()) { when (it) { is NamedToken -> it.name is NumberedToken -> it.number.toString() } } .size val tokenNumbers = defaultResource.tokens.filterIsInstance().mapTo(mutableSetOf()) { it.number } (0 until argumentCount).toSet() == tokenNumbers } val deprecation = if (defaultResource.tokens.any { it.type == TokenType.Choice }) { MergedResource.Deprecation.WithMessage( message = """ Use of the old 'choice' argument type is discouraged. Use a 'plural' argument to select sub-messages based on a numeric value, together with the plural rules for the specified language. Use a 'select' argument to select sub-messages via a fixed set of keywords. """ .trimIndent() .replace("\n", " ") ) } else { MergedResource.Deprecation.None } val arguments = defaultResource.tokens .groupBy { it.argumentKey } .mapValues { (argumentKey, tokens) -> resolveArgumentType(tokens.map { it.type })?.let { argumentType -> MergedResource.Argument( key = argumentKey, name = tokens.first().argumentName, type = argumentType, ) } } return MergedResource( name = name, description = defaultResource.description, visibility = publicResources.resolveVisibility(name = name, type = "string"), arguments = arguments.values.filterNotNull(), deprecation = deprecation, hasContiguousNumberedTokens = hasContiguousNumberedTokens, parsingErrors = arguments.filterValues { it == null }.keys.map { "Incompatible argument types for: $it" }, ) } private val Token.argumentKey: String get() = when (this) { is NamedToken -> name is NumberedToken -> number.toString() } private val Token.argumentName: String get() = when (this) { is NamedToken -> name is NumberedToken -> "arg$number" } /** * If no public resource declarations exist, then all resources are public. Otherwise, only those * declared public are public. */ private fun Collection.resolveVisibility( name: ResourceName, type: String, ): MergedResource.Visibility { val public = isEmpty() || any { it is PublicResource.Named && it.type == type && it.name == name } return if (public) MergedResource.Visibility.Public else MergedResource.Visibility.Private } ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/ResourceParser.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.model.ResourceName import app.cash.paraphrase.plugin.model.StringResource import java.io.InputStream import javax.xml.parsers.DocumentBuilderFactory import org.w3c.dom.Node /** * Parses and returns all of the Android resources declared in the given stream. * * Ignores all other resources, including and . */ internal fun parseResources(inputStream: InputStream): List = DocumentBuilderFactory.newInstance() .newDocumentBuilder() .parse(inputStream) .getElementsByTagName("string") .asIterator() .asSequence() .filter { it.nodeType == Node.ELEMENT_NODE } .map { val name = it.attributes.getNamedItem("name").childNodes.item(0).textContent StringResource( name = ResourceName(name), description = it.precedingComment()?.textContent?.trim(), text = it.textContent, ) } .toList() private fun Node.precedingComment(): Node? { val candidate = previousSibling ?: return null return when { candidate.nodeType == Node.COMMENT_NODE -> { candidate } candidate.nodeType == Node.TEXT_NODE && candidate.textContent.isBlank() -> { candidate.precedingComment() } else -> { null } } } ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/ResourceTokenizer.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.model.StringResource import app.cash.paraphrase.plugin.model.TokenizedResource import app.cash.paraphrase.plugin.model.TokenizedResource.Token import app.cash.paraphrase.plugin.model.TokenizedResource.Token.NamedToken import app.cash.paraphrase.plugin.model.TokenizedResource.Token.NumberedToken import com.ibm.icu.text.MessagePattern import com.ibm.icu.text.MessagePattern.ArgType.CHOICE import com.ibm.icu.text.MessagePattern.ArgType.NONE import com.ibm.icu.text.MessagePattern.ArgType.PLURAL import com.ibm.icu.text.MessagePattern.ArgType.SELECT import com.ibm.icu.text.MessagePattern.ArgType.SELECTORDINAL import com.ibm.icu.text.MessagePattern.ArgType.SIMPLE import com.ibm.icu.text.MessagePattern.Part import com.ibm.icu.text.MessagePattern.Part.Type.ARG_NAME import com.ibm.icu.text.MessagePattern.Part.Type.ARG_NUMBER import com.ibm.icu.text.MessagePattern.Part.Type.ARG_START import com.ibm.icu.text.MessagePattern.Part.Type.ARG_STYLE /** Parses the given resource and extracts the ICU argument tokens. */ internal fun tokenizeResource(stringResource: StringResource): TokenizedResource { val pattern = try { MessagePattern(stringResource.text) } catch (throwable: Throwable) { return stringResource.toTokenizedResource( tokens = emptyList(), parsingError = throwable.message, ) } if (!pattern.hasNamedArguments() && !pattern.hasNumberedArguments()) { return stringResource.toTokenizedResource(tokens = emptyList()) } val tokens = pattern .partsIterator() .asSequence() .withIndex() .filter { (_, part) -> part.type == ARG_START } .map { (index, part) -> pattern.getToken( identifier = pattern.getPart(index + 1), type = when (part.argType) { NONE -> TokenType.None SIMPLE -> when ( val simpleType = pattern.getSubstring(pattern.getPart(index + 2)).lowercase() ) { "date" -> { val stylePart = pattern.getPart(index + 3) if (stylePart.type == ARG_STYLE) { val style = pattern.getSubstring(stylePart).trim() when (style.lowercase()) { "short", "medium", "long", "full" -> TokenType.Date else -> getTokenType(dateTimeFormatPattern = style) } } else { TokenType.Date } } "duration" -> TokenType.Duration "ordinal" -> TokenType.Ordinal "number" -> TokenType.Number "spellout" -> TokenType.SpellOut "time" -> { val stylePart = pattern.getPart(index + 3) if (stylePart.type == ARG_STYLE) { val style = pattern.getSubstring(stylePart).trim() when (style.lowercase()) { "short", "medium" -> TokenType.Time "long", "full" -> TokenType.DateTimeWithZone else -> getTokenType(dateTimeFormatPattern = style) } } else { TokenType.Time } } else -> error("Unexpected simple argument type: $simpleType") } CHOICE -> TokenType.Choice PLURAL -> TokenType.Plural SELECT -> TokenType.Select SELECTORDINAL -> TokenType.SelectOrdinal else -> error("Unexpected argument type: ${part.argType.name}") }, ) } return stringResource.toTokenizedResource(tokens = tokens.toList()) } private fun StringResource.toTokenizedResource( tokens: List, parsingError: String? = null, ): TokenizedResource = TokenizedResource( name = name, description = description, tokens = tokens, parsingError = parsingError, ) private fun MessagePattern.getToken(identifier: Part, type: TokenType): Token = when (identifier.type) { ARG_NAME -> NamedToken(name = getSubstring(identifier), type = type) ARG_NUMBER -> NumberedToken(number = identifier.value, type = type) else -> error("Unexpected identifier type: ${identifier.type.name}") } private fun MessagePattern.partsIterator(): Iterator = object : Iterator { private var index = 0 override fun hasNext(): Boolean = index < countParts() override fun next(): Part = getPart(index++) } internal enum class TokenType { None, Number, Date, Time, TimeWithOffset, DateTime, DateTimeWithOffset, DateTimeWithZone, Offset, SpellOut, Ordinal, Duration, Choice, Plural, Select, SelectOrdinal, NoArg, } private fun getTokenType(dateTimeFormatPattern: String): TokenType { var hasDate = false var hasTime = false var hasOffset = false var hasZone = false for (patternItem in dateTimeFormatPattern.getDateTimeSymbols()) { if (patternItem in DateSymbols) hasDate = true if (patternItem in TimeSymbols) hasTime = true if (patternItem in OffsetSymbols) hasOffset = true if (patternItem in ZoneSymbols) hasZone = true // Break if we already satisfy the highest-priority condition: if (hasDate && hasTime && hasZone) break } return when { hasDate && hasTime && hasZone -> TokenType.DateTimeWithZone hasDate && hasTime && hasOffset -> TokenType.DateTimeWithOffset hasDate && hasTime -> TokenType.DateTime hasDate && hasZone -> TokenType.DateTimeWithZone hasDate && hasOffset -> TokenType.DateTimeWithOffset hasDate -> TokenType.Date hasTime && hasZone -> TokenType.DateTimeWithZone hasTime && hasOffset -> TokenType.TimeWithOffset hasTime -> TokenType.Time hasZone -> TokenType.DateTimeWithZone hasOffset -> TokenType.Offset else -> TokenType.NoArg } } // region Date/time format symbols // https://unicode-org.github.io/icu/userguide/format_parse/datetime/#date-field-symbol-table // Adapted from android.icu.text.SimpleDateFormat.getPatternItems // // https://cs.android.com/android/platform/superproject/+/master:external/icu/android_icu4j/src/main/java/android/icu/text/SimpleDateFormat.java;l=2146 private fun String.getDateTimeSymbols(): List { var isPrevQuote = false var inQuote = false val text = StringBuilder() var itemType = Char(0) var itemLength = 1 val items = mutableListOf() forEach { ch -> if (ch == '\'') { if (isPrevQuote) { text.append(ch) isPrevQuote = false } else { isPrevQuote = true if (itemType != Char(0)) { items.add(itemType) itemType = Char(0) } } inQuote = !inQuote } else { isPrevQuote = false if (inQuote) { text.append(ch) } else { if (ch.isDateTimeFormatSymbol) { // a date/time pattern character if (ch == itemType) { itemLength++ } else { if (itemType == Char(0)) { if (text.isNotEmpty()) { // Skip adding string literals to the pattern items list text.setLength(0) } } else { items.add(itemType) } itemType = ch itemLength = 1 } } else { // a string literal if (itemType != Char(0)) { items.add(itemType) itemType = Char(0) } text.append(ch) } } } } // handle last item if (itemType == Char(0)) { if (text.isNotEmpty()) { // Skip adding string literals to the pattern items list text.setLength(0) } } else { items.add(itemType) } return items.filter { it != Char(0) } } private val DateSymbols = setOf( 'G', // era designator 'y', // year 'Y', // year of "Week of Year" 'u', // extended year 'U', // cyclic year name, as in Chinese lunar calendar 'r', // related Gregorian year 'Q', // quarter 'q', // stand-alone quarter 'M', // month in year 'L', // stand-alone month in year 'w', // week of year 'W', // week of month 'd', // day in month 'D', // day of year 'F', // day of week in month 'g', // modified julian day 'E', // day of week 'e', // local day of week (example: if Monday is 1st day, Tuesday is 2nd) 'c', // stand-alone local day of week ) private val TimeSymbols = setOf( 'a', // AM or PM 'b', // am, pm, noon, midnight 'B', // flexible day periods 'h', // hour in am/pm (1~12) 'H', // hour in day (0~23) 'k', // hour in day (1~24) 'K', // hour in am/pm (0~11) 'm', // minute in hour 's', // second in minute 'S', // fractional second - truncates/appends zeros to the count of letters when formatting 'A', // milliseconds in day ) /** * Time zone formats that only depict an offset from GMT, and thus require only a * [java.time.ZoneOffset]. */ private val OffsetSymbols = setOf( 'Z', // ISO8601 basic/extended hms? / long localized GMT 'O', // short/long localized GMT 'X', // ISO8601 variants, with Z for 0 'x', // ISO8601 variants, without Z for 0 ) /** Time zone formats that depict a named time zone, and thus require a [java.time.ZoneId]. */ private val ZoneSymbols = setOf( 'z', // specific non-location 'v', // generic non-location (falls back first to VVVV) 'V', // short/long time zone ID / exemplar city / generic location (falls back to OOOO) ) private val Char.isDateTimeFormatSymbol: Boolean get() = this in DateSymbols || this in TimeSymbols || this in OffsetSymbols || this in ZoneSymbols // endregion ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.model.MergedResource import app.cash.paraphrase.plugin.model.MergedResource.Argument import app.cash.paraphrase.plugin.model.MergedResource.Deprecation import com.squareup.kotlinpoet.ANY import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec 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 import com.squareup.kotlinpoet.asClassName import com.squareup.kotlinpoet.buildCodeBlock import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.OffsetDateTime import java.time.OffsetTime import java.time.ZoneOffset import java.time.ZonedDateTime import kotlin.time.Duration /** Writes the given tokenized resources to a Kotlin source file. */ internal fun writeResources(packageName: String, mergedResources: List): FileSpec { val packageStringsType = ClassName(packageName = packageName, "R", "string") val maxVisibility = mergedResources.maxOf { it.visibility } return FileSpec.builder(packageName = packageName, fileName = "FormattedResources") .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") .addType( TypeSpec.objectBuilder("FormattedResources") .apply { mergedResources.forEach { mergedResource -> if (mergedResource.arguments.isNotEmpty()) { val funSpec = mergedResource.toFunSpec(packageStringsType) addFunction(funSpec) if (mergedResource.arguments.any { it.type == Long::class }) { // Since Ints are used more commonly than Longs, provide an overload to accept // Ints for Long arguments: addFunction(mergedResource.toIntOverloadFunSpec(funSpec)) } } else { addProperty(mergedResource.toPropertySpec(packageStringsType)) } } } .addModifiers(maxVisibility.toKModifier()) .build() ) .build() } private fun MergedResource.toFunSpec(packageStringsType: TypeName): FunSpec { return FunSpec.builder(name.value) .apply { if (description != null) addKdoc("%L", description) } .apply { arguments.forEach { addParameter(it.toParameterSpec()) } } .returns(Types.FormattedResource) .apply { if (deprecation is Deprecation.WithMessage) { addAnnotation(annotationSpec = deprecatedAnnotationSpec(deprecation)) } if (hasContiguousNumberedTokens) { addCode( buildCodeBlock { add("val arguments = arrayOf(⇥\n") for (argument in arguments) { addStatement("%L,", argument.toParameterCodeBlock()) } add("⇤)\n") } ) } else { addStatement( "val arguments = %T(%L)", Types.ArrayMap.parameterizedBy(STRING, ANY), arguments.size, ) // The `ArrayMap` into which we place the key-argument pairs uses a backing storage of an // array representing a binary tree of the keys ordered by their `hashCode()`. To maximize // storage density and minimize runtime complexity, the ideal insertion order sorted, as // it results in linear insertion into the array with no shifts. Thankfully // `String.hashCode()` is specified by the documentation and thus safe to rely on across the // JVM and Android. for (argument in arguments.sortedBy { it.key.hashCode() }) { addCode("arguments.put(\n⇥") addCode("%S,\n", argument.key) addCode("%L,\n", argument.toParameterCodeBlock()) addCode("⇤)\n") } } } .addCode( buildCodeBlock { add("return %T(⇥\n", Types.FormattedResource) addStatement("id = %T.%L,", packageStringsType, name.value) addStatement("arguments = arguments,") add("⇤)\n") } ) .addModifiers(visibility.toKModifier()) .build() } private fun deprecatedAnnotationSpec(deprecation: Deprecation.WithMessage): AnnotationSpec { return AnnotationSpec.builder(Deprecated::class).addMember("%S", deprecation.message).build() } private fun Argument.toParameterSpec(): ParameterSpec = ParameterSpec( name = name, type = when (type) { Nothing::class -> NOTHING.copy(nullable = true) else -> type.asClassName() }, ) private fun Argument.toParameterCodeBlock(): CodeBlock = 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) } 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 { return when (this) { MergedResource.Visibility.Public -> KModifier.PUBLIC MergedResource.Visibility.Private -> KModifier.INTERNAL } } private fun MergedResource.toIntOverloadFunSpec(overloaded: FunSpec): FunSpec { return FunSpec.builder(name.value) .apply { if (description != null) addKdoc("%L", description) addAnnotation( annotationSpec = AnnotationSpec.builder(Suppress::class).addMember("%S", "NOTHING_TO_INLINE").build() ) arguments.forEach { argument -> val parameterSpec = if (argument.type == Long::class) { ParameterSpec(name = argument.name, type = Int::class.asClassName()) } else { argument.toParameterSpec() } addParameter(parameterSpec) } } .returns(Types.FormattedResource) .apply { addCode( buildCodeBlock { add("return %N(⇥\n", overloaded) arguments.forEach { argument -> val argumentInvocation = if (argument.type == Long::class) { "%L.toLong(),\n" } else { "%L,\n" } add(argumentInvocation, argument.name) } add("⇤)\n") } ) } .addModifiers(visibility.toKModifier(), KModifier.INLINE) .build() } private fun MergedResource.toPropertySpec(packageStringsType: TypeName): PropertySpec = PropertySpec.builder(name.value, Int::class) .getter(FunSpec.getterBuilder().addCode("return %T.%L", packageStringsType, name.value).build()) .apply { if (description != null) addKdoc("%L", description) } .apply { if (deprecation is Deprecation.WithMessage) { val spec = deprecatedAnnotationSpec(deprecation) .toBuilder() .useSiteTarget(AnnotationSpec.UseSiteTarget.GET) .build() addAnnotation(annotationSpec = spec) } } .addAnnotation( AnnotationSpec.builder(Types.StringRes) .useSiteTarget(AnnotationSpec.UseSiteTarget.GET) .build() ) .addModifiers(visibility.toKModifier()) .build() private object Types { val ArrayMap = ClassName("androidx.collection", "ArrayMap") val Calendar = ClassName("android.icu.util", "Calendar") val FormattedResource = ClassName("app.cash.paraphrase", "FormattedResource") val TimeZone = ClassName("android.icu.util", "TimeZone") val ULocale = ClassName("android.icu.util", "ULocale") val StringRes = ClassName("androidx.annotation", "StringRes") } ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/model/MergedResource.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin.model import kotlin.reflect.KClass /** A string resource parsed from a strings.xml file with its associated argument tokens. */ internal data class MergedResource( val name: ResourceName, val description: String?, val visibility: Visibility, val arguments: List, val deprecation: Deprecation, /* True when the arguments bind to a contiguous set of integer tokens counting from 0. */ val hasContiguousNumberedTokens: Boolean, val parsingErrors: List, ) { data class Argument( /** The key into the format argument map. */ val key: String, /** The public name used as a function parameter. */ val name: String, val type: KClass<*>, ) enum class Visibility { Private, Public, } sealed interface Deprecation { object None : Deprecation { override fun toString() = "None" } data class WithMessage(val message: String) : Deprecation } } ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/model/PublicResource.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin.model /** A raw public resource parsed from e.g. a public.xml file. */ internal sealed interface PublicResource { /** An actual public resource, referencing another declared resource by [name] and [type]. */ data class Named(val name: ResourceName, val type: String) : PublicResource /** * An empty declaration, typically used to ensure all of a library's resources are * private. */ object EmptyDeclaration : PublicResource } ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/model/ResourceFolder.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin.model /** Represents "values-es" in `res/values-es/strings.xml` */ @JvmInline internal value class ResourceFolder(val name: String) { companion object { val Default = ResourceFolder("values") } } ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/model/ResourceName.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin.model /** * Represents "hello" in * * ``` * Sup * ``` */ @JvmInline internal value class ResourceName(val value: String) ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/model/StringResource.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin.model /** A raw string resource parsed from a strings.xml file. */ internal data class StringResource( val name: ResourceName, val description: String?, val text: String, ) ================================================ FILE: plugin/src/main/java/app/cash/paraphrase/plugin/model/TokenizedResource.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin.model import app.cash.paraphrase.plugin.TokenType /** A string resource parsed from a strings.xml file with its associated argument tokens. */ internal data class TokenizedResource( val name: ResourceName, val description: String?, val tokens: List, val parsingError: String?, ) { sealed interface Token { val type: TokenType data class NamedToken(val name: String, override val type: TokenType) : Token data class NumberedToken(val number: Int, override val type: TokenType) : Token } } ================================================ FILE: plugin/src/test/java/app/cash/paraphrase/plugin/ArgumentTypeResolverTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.TokenType.Choice import app.cash.paraphrase.plugin.TokenType.Date import app.cash.paraphrase.plugin.TokenType.DateTime import app.cash.paraphrase.plugin.TokenType.DateTimeWithOffset import app.cash.paraphrase.plugin.TokenType.DateTimeWithZone import app.cash.paraphrase.plugin.TokenType.Duration import app.cash.paraphrase.plugin.TokenType.NoArg import app.cash.paraphrase.plugin.TokenType.None import app.cash.paraphrase.plugin.TokenType.Number import app.cash.paraphrase.plugin.TokenType.Offset import app.cash.paraphrase.plugin.TokenType.Ordinal import app.cash.paraphrase.plugin.TokenType.Plural import app.cash.paraphrase.plugin.TokenType.Select import app.cash.paraphrase.plugin.TokenType.SelectOrdinal import app.cash.paraphrase.plugin.TokenType.SpellOut import app.cash.paraphrase.plugin.TokenType.Time import app.cash.paraphrase.plugin.TokenType.TimeWithOffset import assertk.assertThat import assertk.assertions.isEqualTo import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.OffsetDateTime import java.time.OffsetTime import java.time.ZoneOffset import java.time.ZonedDateTime import kotlin.Number as KotlinNumber import kotlin.reflect.KClass import kotlin.time.Duration as KotlinDuration import org.junit.Test class ArgumentTypeResolverTest { @Test fun resolveEmpty() { emptyList().assertArgumentType(null) } @Test fun resolveNone() { None.assertArgumentTypes { other -> resolveArgumentType(listOf(other)) } } @Test fun resolveNumber() { Number.assertArgumentTypes { other -> when (other) { None, Choice, Number, Plural, SpellOut -> KotlinNumber::class Ordinal, SelectOrdinal -> Long::class else -> null } } } @Test fun resolveDate() { Date.assertArgumentTypes { other -> when (other) { None, Date -> LocalDate::class Time, DateTime -> LocalDateTime::class Offset, TimeWithOffset, DateTimeWithOffset -> OffsetDateTime::class DateTimeWithZone -> ZonedDateTime::class else -> null } } } @Test fun resolveTime() { Time.assertArgumentTypes { other -> when (other) { None, Time -> LocalTime::class Date, DateTime -> LocalDateTime::class Offset, TimeWithOffset -> OffsetTime::class DateTimeWithOffset -> OffsetDateTime::class DateTimeWithZone -> ZonedDateTime::class else -> null } } } @Test fun resolveOffset() { Offset.assertArgumentTypes { other -> when (other) { None, Offset -> ZoneOffset::class Date, DateTime, DateTimeWithOffset -> OffsetDateTime::class Time, TimeWithOffset -> OffsetTime::class DateTimeWithZone -> ZonedDateTime::class else -> null } } } @Test fun resolveDateTime() { DateTime.assertArgumentTypes { other -> when (other) { None, Date, Time, DateTime -> LocalDateTime::class Offset, TimeWithOffset, DateTimeWithOffset -> OffsetDateTime::class DateTimeWithZone -> ZonedDateTime::class else -> null } } } @Test fun resolveTimeWithOffset() { TimeWithOffset.assertArgumentTypes { other -> when (other) { None, Time, Offset, TimeWithOffset -> OffsetTime::class Date, DateTime, DateTimeWithOffset -> OffsetDateTime::class DateTimeWithZone -> ZonedDateTime::class else -> null } } } @Test fun resolveDateTimeWithOffset() { DateTimeWithOffset.assertArgumentTypes { other -> when (other) { None, Date, Time, Offset, DateTime, TimeWithOffset, DateTimeWithOffset -> OffsetDateTime::class DateTimeWithZone -> ZonedDateTime::class else -> null } } } @Test fun resolveDateTimeWithZone() { DateTimeWithZone.assertArgumentTypes { other -> when (other) { None, Date, Time, Offset, DateTime, TimeWithOffset, DateTimeWithOffset, DateTimeWithZone -> ZonedDateTime::class else -> null } } } @Test fun resolveSpellOut() { SpellOut.assertArgumentTypes { other -> when (other) { None, Choice, Number, Plural, SpellOut -> KotlinNumber::class Ordinal, SelectOrdinal -> Long::class else -> null } } } @Test fun resolveOrdinal() { Ordinal.assertArgumentTypes { other -> when (other) { None, Choice, Number, Ordinal, Plural, SelectOrdinal, SpellOut -> Long::class else -> null } } } @Test fun resolveDuration() { Duration.assertArgumentTypes { other -> when (other) { None, Duration -> KotlinDuration::class else -> null } } } @Test fun resolveChoice() { Choice.assertArgumentTypes { other -> when (other) { None, Choice, Number, Plural, SpellOut -> KotlinNumber::class Ordinal, SelectOrdinal -> Long::class else -> null } } } @Test fun resolvePlural() { Plural.assertArgumentTypes { other -> when (other) { None, Choice, Number, Plural, SpellOut -> KotlinNumber::class Ordinal, SelectOrdinal -> Long::class else -> null } } } @Test fun resolveSelect() { Select.assertArgumentTypes { other -> when (other) { None, Select -> String::class else -> null } } } @Test fun resolveSelectOrdinal() { SelectOrdinal.assertArgumentTypes { other -> when (other) { None, Choice, Number, Ordinal, Plural, SelectOrdinal, SpellOut -> Long::class else -> null } } } @Test fun resolveNoArg() { NoArg.assertArgumentTypes { other -> when (other) { None, NoArg -> Nothing::class else -> null } } } private fun TokenType.assertArgumentTypes(expected: (TokenType) -> KClass<*>?) { listOf(this).assertArgumentType(expected(this)) TokenType.values().forEach { other -> listOf(this, other).assertArgumentType(expected(other)) } } private fun List.assertArgumentType(expected: KClass<*>?) = assertThat(resolveArgumentType(tokenTypes = this)).isEqualTo(expected) } ================================================ FILE: plugin/src/test/java/app/cash/paraphrase/plugin/PublicResourceParserTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.model.PublicResource import app.cash.paraphrase.plugin.model.ResourceName import assertk.assertThat import assertk.assertions.containsExactly import org.junit.Assert.assertThrows import org.junit.Test class PublicResourceParserTest { @Test fun parseSinglePublicResource() { """ """ .trimIndent() .assertParse(PublicResource.Named(name = ResourceName("test"), type = "string")) } @Test fun parseMultiplePublicResources() { """ """ .trimIndent() .assertParse( PublicResource.Named(name = ResourceName("test_1"), type = "string"), PublicResource.Named(name = ResourceName("test_2"), type = "anim"), PublicResource.Named(name = ResourceName("test_3"), type = "color"), ) } @Test fun parseEmptyPublicResource() { """ """ .trimIndent() .assertParse(PublicResource.EmptyDeclaration) } @Test fun ignoreOtherResources() { """ true String test2 """ .trimIndent() .assertParse(PublicResource.Named(name = ResourceName("test1"), type = "bool")) } @Test fun throwOnNamedResourceWithNoName() { assertThrows(IllegalArgumentException::class.java) { """ """ .trimIndent() .assertParse() } } @Test fun throwOnNamedResourceWithNoType() { assertThrows(IllegalArgumentException::class.java) { """ """ .trimIndent() .assertParse() } } private fun String.assertParse(vararg expectedResources: PublicResource) { assertThat(parsePublicResources(byteInputStream())).containsExactly(*expectedResources) } } ================================================ FILE: plugin/src/test/java/app/cash/paraphrase/plugin/ResourceMergerTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.TokenType.Choice import app.cash.paraphrase.plugin.TokenType.Date import app.cash.paraphrase.plugin.TokenType.Plural import app.cash.paraphrase.plugin.TokenType.Time import app.cash.paraphrase.plugin.model.MergedResource import app.cash.paraphrase.plugin.model.MergedResource.Argument import app.cash.paraphrase.plugin.model.MergedResource.Deprecation import app.cash.paraphrase.plugin.model.PublicResource import app.cash.paraphrase.plugin.model.ResourceFolder import app.cash.paraphrase.plugin.model.ResourceName import app.cash.paraphrase.plugin.model.TokenizedResource import app.cash.paraphrase.plugin.model.TokenizedResource.Token.NamedToken import app.cash.paraphrase.plugin.model.TokenizedResource.Token.NumberedToken import assertk.assertThat import assertk.assertions.contains import assertk.assertions.containsExactly import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import java.time.LocalDateTime import org.junit.Test class ResourceMergerTest { @Test fun emptyPublicResourcesProducesPublicResource() { val result = mergeResources( name = ResourceName("test"), tokenizedResources = mapOf( ResourceFolder.Default to TokenizedResource( name = ResourceName("test"), description = null, tokens = emptyList(), parsingError = null, ) ), publicResources = emptyList(), ) assertThat(result!!.visibility).isEqualTo(MergedResource.Visibility.Public) assertThat(result.deprecation).isEqualTo(Deprecation.None) } @Test fun inclusionInPublicResourcesProducesPublicResource() { val result = mergeResources( name = ResourceName("test"), tokenizedResources = mapOf( ResourceFolder.Default to TokenizedResource( name = ResourceName("test"), description = null, tokens = emptyList(), parsingError = null, ) ), publicResources = listOf(PublicResource.Named(name = ResourceName("test"), type = "string")), ) assertThat(result!!.visibility).isEqualTo(MergedResource.Visibility.Public) assertThat(result.deprecation).isEqualTo(Deprecation.None) } @Test fun inclusionInPublicResourcesWithWrongTypeProducesPrivateResource() { val result = mergeResources( name = ResourceName("test"), tokenizedResources = mapOf( ResourceFolder.Default to TokenizedResource( name = ResourceName("test"), description = null, tokens = emptyList(), parsingError = null, ) ), publicResources = listOf(PublicResource.Named(name = ResourceName("test"), type = "color")), ) assertThat(result!!.visibility).isEqualTo(MergedResource.Visibility.Private) assertThat(result.deprecation).isEqualTo(Deprecation.None) } @Test fun exclusionFromPublicResourcesProducesPrivateResource() { val result = mergeResources( name = ResourceName("test"), tokenizedResources = mapOf( ResourceFolder.Default to TokenizedResource( name = ResourceName("test"), description = null, tokens = emptyList(), parsingError = null, ) ), publicResources = listOf( PublicResource.EmptyDeclaration, PublicResource.Named(name = ResourceName("different"), type = "string"), ), ) assertThat(result!!.visibility).isEqualTo(MergedResource.Visibility.Private) assertThat(result.deprecation).isEqualTo(Deprecation.None) } @Test fun tokensWithCompatibleTypesAreCombined() { val result = mergeResources( name = ResourceName("test"), tokenizedResources = mapOf( ResourceFolder.Default to TokenizedResource( name = ResourceName("test"), description = null, tokens = listOf( NumberedToken(number = 0, type = Date), NumberedToken(number = 0, type = Time), ), parsingError = null, ) ), publicResources = emptyList(), ) assertThat(result!!.arguments) .containsExactly(Argument(key = "0", name = "arg0", type = LocalDateTime::class)) assertThat(result.parsingErrors).isEmpty() assertThat(result.deprecation).isEqualTo(Deprecation.None) } @Test fun tokensWithIncompatibleTypesReportsParsingError() { val result = mergeResources( name = ResourceName("test"), tokenizedResources = mapOf( ResourceFolder.Default to TokenizedResource( name = ResourceName("test"), description = null, tokens = listOf( NumberedToken(number = 0, type = Date), NumberedToken(number = 0, type = Plural), ), parsingError = null, ) ), publicResources = emptyList(), ) assertThat(result!!.arguments).isEmpty() assertThat(result.parsingErrors).containsExactly("Incompatible argument types for: 0") assertThat(result.deprecation).isEqualTo(Deprecation.None) } @Test fun choiceArgumentProducesDeprecatedFunction() { val result = mergeResources( name = ResourceName("test"), tokenizedResources = mapOf( ResourceFolder.Default to TokenizedResource( name = ResourceName("test"), description = null, tokens = listOf( NamedToken(name = "choice", type = Choice), NamedToken(name = "other", type = Date), ), parsingError = null, ) ), publicResources = emptyList(), ) val deprecation = result!!.deprecation as Deprecation.WithMessage assertThat(deprecation.message).contains("Use of the old 'choice' argument type is discouraged") } } ================================================ FILE: plugin/src/test/java/app/cash/paraphrase/plugin/ResourceParserTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.model.ResourceName import app.cash.paraphrase.plugin.model.StringResource import assertk.assertThat import assertk.assertions.containsExactly import org.junit.Test class ResourceParserTest { @Test fun parseSingleStringResource() { """ Test """ .trimIndent() .assertParse(StringResource(name = ResourceName("test"), description = null, text = "Test")) } @Test fun parseMultipleStringResources() { """ Test 1 Test 2 Test 3 """ .trimIndent() .assertParse( StringResource(name = ResourceName("test_1"), description = null, text = "Test 1"), StringResource(name = ResourceName("test_2"), description = null, text = "Test 2"), StringResource(name = ResourceName("test_3"), description = null, text = "Test 3"), ) } @Test fun parseStringResourceWithDescription() { """ Test """ .trimIndent() .assertParse( StringResource(name = ResourceName("test"), description = "Test Description", text = "Test") ) } @Test fun parseUntranslatableStringResource() { """ Test """ .trimIndent() .assertParse(StringResource(name = ResourceName("test"), description = null, text = "Test")) } @Test fun ignorePluralResource() { """ Test 0 Test 1 Test """ .trimIndent() .assertParse() } @Test fun ignoreStringArrayResource() { """ Test 1 Test 2 Test 3 """ .trimIndent() .assertParse() } private fun String.assertParse(vararg expectedResources: StringResource) { assertThat(parseResources(byteInputStream())).containsExactly(*expectedResources) } } ================================================ FILE: plugin/src/test/java/app/cash/paraphrase/plugin/ResourceTokenizerTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.TokenType.Date import app.cash.paraphrase.plugin.TokenType.DateTime import app.cash.paraphrase.plugin.TokenType.DateTimeWithOffset import app.cash.paraphrase.plugin.TokenType.DateTimeWithZone import app.cash.paraphrase.plugin.TokenType.NoArg import app.cash.paraphrase.plugin.TokenType.None import app.cash.paraphrase.plugin.TokenType.Number import app.cash.paraphrase.plugin.TokenType.Offset import app.cash.paraphrase.plugin.TokenType.Plural import app.cash.paraphrase.plugin.TokenType.Select import app.cash.paraphrase.plugin.TokenType.SelectOrdinal import app.cash.paraphrase.plugin.TokenType.Time import app.cash.paraphrase.plugin.TokenType.TimeWithOffset import app.cash.paraphrase.plugin.model.ResourceName import app.cash.paraphrase.plugin.model.StringResource import app.cash.paraphrase.plugin.model.TokenizedResource import app.cash.paraphrase.plugin.model.TokenizedResource.Token import app.cash.paraphrase.plugin.model.TokenizedResource.Token.NamedToken import app.cash.paraphrase.plugin.model.TokenizedResource.Token.NumberedToken import assertk.assertThat import assertk.assertions.isEqualTo import org.junit.Test class ResourceTokenizerTest { @Test fun tokenizeResourceWithNoArguments() { "Test".assertTokens() } @Test fun tokenizeResourceWithNamedSimpleTokens() { "Test {test} {test_number, number} {test_date, date} {test_time, time}" .assertTokens( NamedToken(name = "test", type = None), NamedToken(name = "test_number", type = Number), NamedToken(name = "test_date", type = Date), NamedToken(name = "test_time", type = Time), ) } @Test fun tokenizeResourceWithNumberedSimpleTokens() { "Test {0} {1, number} {2, date} {3, time}" .assertTokens( NumberedToken(number = 0, type = None), NumberedToken(number = 1, type = Number), NumberedToken(number = 2, type = Date), NumberedToken(number = 3, type = Time), ) } @Test fun tokenizeResourceWithNamedPluralArgument() { """ {test, plural, zero {Test 0 {test_nested}} one {Test 1 {test_nested}} other {Test # {test_nested}} } """ .trimIndent() .assertTokens( NamedToken(name = "test", type = Plural), NamedToken(name = "test_nested", type = None), NamedToken(name = "test_nested", type = None), NamedToken(name = "test_nested", type = None), ) } @Test fun tokenizeResourceWithNumberedPluralArgument() { """ {0, plural, zero {Test 0 {1}} one {Test 1 {1}} other {Test # {1}} } """ .trimIndent() .assertTokens( NumberedToken(number = 0, type = Plural), NumberedToken(number = 1, type = None), NumberedToken(number = 1, type = None), NumberedToken(number = 1, type = None), ) } @Test fun tokenizeResourceWithNamedSelectArgument() { """ {test, select, red {Test red {test_nested}} green {Test green {test_nested}} blue {Test blue {test_nested}} other {Test other {test_nested}} } """ .trimIndent() .assertTokens( NamedToken(name = "test", type = Select), NamedToken(name = "test_nested", type = None), NamedToken(name = "test_nested", type = None), NamedToken(name = "test_nested", type = None), NamedToken(name = "test_nested", type = None), ) } @Test fun tokenizeResourceWithNumberedSelectArgument() { """ {0, select, red {Test red {1}} green {Test green {1}} blue {Test blue {1}} other {Test other {1}} } """ .trimIndent() .assertTokens( NumberedToken(number = 0, type = Select), NumberedToken(number = 1, type = None), NumberedToken(number = 1, type = None), NumberedToken(number = 1, type = None), NumberedToken(number = 1, type = None), ) } @Test fun tokenizeResourceWithNamedSelectOrdinalArgument() { """ {test, selectordinal, zero {Test 0 {test_nested}} one {Test 1 {test_nested}} other {Test # {test_nested}} } """ .trimIndent() .assertTokens( NamedToken(name = "test", type = SelectOrdinal), NamedToken(name = "test_nested", type = None), NamedToken(name = "test_nested", type = None), NamedToken(name = "test_nested", type = None), ) } @Test fun tokenizeResourceWithNumberedSelectOrdinalArgument() { """ {0, selectordinal, zero {Test 0 {1}} one {Test 1 {1}} other {Test # {1}} } """ .trimIndent() .assertTokens( NumberedToken(number = 0, type = SelectOrdinal), NumberedToken(number = 1, type = None), NumberedToken(number = 1, type = None), NumberedToken(number = 1, type = None), ) } @Test fun tokenizeResourceWithReusedNamedChoiceArgument() { """ {count, plural, zero {Test 0 {count}} one {Test 1 {count}} other {Test # {count}} } """ .trimIndent() .assertTokens( NamedToken(name = "count", type = Plural), NamedToken(name = "count", type = None), NamedToken(name = "count", type = None), NamedToken(name = "count", type = None), ) } @Test fun tokenizeResourceWithReusedNumberedChoiceArgument() { """ {0, plural, zero {Test 0 {0}} one {Test 1 {0}} other {Test # {0}} } """ .trimIndent() .assertTokens( NumberedToken(number = 0, type = Plural), NumberedToken(number = 0, type = None), NumberedToken(number = 0, type = None), NumberedToken(number = 0, type = None), ) } @Test fun tokenizeResourceWithDateFormat() { """ Test {short, date, short} {medium, date, medium} {long, date, long} {full, date, full} """ .trimIndent() .assertTokens( NamedToken(name = "short", type = Date), NamedToken(name = "medium", type = Date), NamedToken(name = "long", type = Date), NamedToken(name = "full", type = Date), ) } @Test fun tokenizeResourceWithTimeFormat() { """ Test {short, time, short} {medium, time, medium} {long, time, long} {full, time, full} """ .trimIndent() .assertTokens( NamedToken(name = "short", type = Time), NamedToken(name = "medium", type = Time), NamedToken(name = "long", type = DateTimeWithZone), NamedToken(name = "full", type = DateTimeWithZone), ) } @Test fun tokenizeResourceWithDateTimeFormatPattern() { for (type in setOf("date", "time")) { """ Test {date_time_id, $type, yaz} {date_time_offset, $type, MbZ} {date_time, $type, Lh} {date_id, $type, wz} {date_offset, $type, WO} {date, $type, d} {time_id, $type, Hv} {time_offset, $type, mX} {time, $type, s} {id, $type, V} {offset, $type, x} {no_arg, $type, 'yaz'} """ .trimIndent() .assertTokens( NamedToken(name = "date_time_id", type = DateTimeWithZone), NamedToken(name = "date_time_offset", type = DateTimeWithOffset), NamedToken(name = "date_time", type = DateTime), NamedToken(name = "date_id", type = DateTimeWithZone), NamedToken(name = "date_offset", type = DateTimeWithOffset), NamedToken(name = "date", type = Date), NamedToken(name = "time_id", type = DateTimeWithZone), NamedToken(name = "time_offset", type = TimeWithOffset), NamedToken(name = "time", type = Time), NamedToken(name = "id", type = DateTimeWithZone), NamedToken(name = "offset", type = Offset), NamedToken(name = "no_arg", type = NoArg), ) } } @Test fun tokenizeResourceWithInvalidIcuFormat() { "Test {{test}}" .assertNoTokensWithError("""Bad argument syntax: [at pattern index 6] "{test}}"""") } private fun String.assertTokens(vararg tokens: Token) { assertThat( tokenizeResource( StringResource(name = ResourceName("test"), description = "Test Description", text = this) ) ) .isEqualTo( TokenizedResource( name = ResourceName("test"), description = "Test Description", tokens = tokens.toList(), parsingError = null, ) ) } private fun String.assertNoTokensWithError(message: String) { assertThat( tokenizeResource( StringResource(name = ResourceName("test"), description = "Test Description", text = this) ) ) .isEqualTo( TokenizedResource( name = ResourceName("test"), description = "Test Description", tokens = emptyList(), parsingError = message, ) ) } } ================================================ FILE: plugin/src/test/java/app/cash/paraphrase/plugin/ResourceWriterTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.plugin import app.cash.paraphrase.plugin.model.MergedResource import app.cash.paraphrase.plugin.model.MergedResource.Deprecation import app.cash.paraphrase.plugin.model.ResourceName import assertk.assertThat import assertk.assertions.contains import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.TypeSpec import org.junit.Assert.fail import org.junit.Test class ResourceWriterTest { @Test fun publicResourceGetsPublicFunction() { val result = writeResources( packageName = "com.example", mergedResources = listOf( MergedResource( name = ResourceName("test"), description = "See https://example.com/foo%3Abar for details", visibility = MergedResource.Visibility.Public, arguments = listOf(MergedResource.Argument("key", "name", String::class)), deprecation = Deprecation.None, hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), MergedResource( name = ResourceName("test_no_args"), description = "See https://example.com/foo%3Abar for details", visibility = MergedResource.Visibility.Public, arguments = emptyList(), deprecation = Deprecation.None, hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), ), ) result.assertFunctionVisibility( expectedClassVisibility = KModifier.PUBLIC, "test" to KModifier.PUBLIC, ) result.assertPropertyVisibility( expectedClassVisibility = KModifier.PUBLIC, "test_no_args" to KModifier.PUBLIC, ) } @Test fun privateResourceGetsInternalFunction() { val result = writeResources( packageName = "com.example", mergedResources = listOf( MergedResource( name = ResourceName("test1"), description = null, visibility = MergedResource.Visibility.Public, arguments = listOf(MergedResource.Argument("key", "name", String::class)), deprecation = Deprecation.None, hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), MergedResource( name = ResourceName("test2"), description = null, visibility = MergedResource.Visibility.Private, arguments = listOf(MergedResource.Argument("key", "name", String::class)), deprecation = Deprecation.None, hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), MergedResource( name = ResourceName("test_no_args1"), description = null, visibility = MergedResource.Visibility.Public, arguments = emptyList(), deprecation = Deprecation.None, hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), MergedResource( name = ResourceName("test_no_args2"), description = null, visibility = MergedResource.Visibility.Private, arguments = emptyList(), deprecation = Deprecation.None, hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), ), ) result.assertFunctionVisibility( expectedClassVisibility = KModifier.PUBLIC, "test1" to KModifier.PUBLIC, "test2" to KModifier.INTERNAL, ) result.assertPropertyVisibility( expectedClassVisibility = KModifier.PUBLIC, "test_no_args1" to KModifier.PUBLIC, "test_no_args2" to KModifier.INTERNAL, ) } @Test fun onlyPrivateResourcesProduceInternalObject() { val result = writeResources( packageName = "com.example", mergedResources = listOf( MergedResource( name = ResourceName("test2"), description = null, visibility = MergedResource.Visibility.Private, arguments = listOf(MergedResource.Argument("key", "name", String::class)), deprecation = Deprecation.None, hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), MergedResource( name = ResourceName("test3"), description = null, visibility = MergedResource.Visibility.Private, arguments = listOf(MergedResource.Argument("key", "name", String::class)), deprecation = Deprecation.None, hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), MergedResource( name = ResourceName("test_no_args2"), description = null, visibility = MergedResource.Visibility.Private, arguments = emptyList(), deprecation = Deprecation.None, hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), MergedResource( name = ResourceName("test_no_args3"), description = null, visibility = MergedResource.Visibility.Private, arguments = emptyList(), deprecation = Deprecation.None, hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), ), ) result.assertFunctionVisibility( expectedClassVisibility = KModifier.INTERNAL, "test2" to KModifier.INTERNAL, "test3" to KModifier.INTERNAL, ) result.assertPropertyVisibility( expectedClassVisibility = KModifier.INTERNAL, "test_no_args2" to KModifier.INTERNAL, "test_no_args3" to KModifier.INTERNAL, ) } private fun FileSpec.assertFunctionVisibility( expectedClassVisibility: KModifier, vararg expectedFunctionVisibility: Pair, ) { assertOnFormattedResourcesObject { formattedResourcesObject -> assertThat(formattedResourcesObject.modifiers).contains(expectedClassVisibility) expectedFunctionVisibility.forEach { (name, expectedVisibility) -> val function = formattedResourcesObject.funSpecs.find { it.name == name } if (function == null) { fail("Function with name <$name> not found") } else { assertThat(function.modifiers).contains(expectedVisibility) } } } } private fun FileSpec.assertPropertyVisibility( expectedClassVisibility: KModifier, vararg expectedPropertyVisibility: Pair, ) { assertOnFormattedResourcesObject { formattedResourcesObject -> assertThat(formattedResourcesObject.modifiers).contains(expectedClassVisibility) expectedPropertyVisibility.forEach { (name, expectedVisibility) -> val property = formattedResourcesObject.propertySpecs.find { it.name == name } if (property == null) { fail("Property with name <$name> not found") } else { assertThat(property.modifiers).contains(expectedVisibility) } } } } @Test fun deprecationWithMessageProducesDeprecationWithMessage() { val result = writeResources( packageName = "com.example", mergedResources = listOf( MergedResource( name = ResourceName("testFun"), description = null, visibility = MergedResource.Visibility.Public, arguments = listOf(MergedResource.Argument("key", "name", String::class)), deprecation = Deprecation.WithMessage("Test message 1"), hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), MergedResource( name = ResourceName("testProp"), description = null, visibility = MergedResource.Visibility.Public, arguments = emptyList(), deprecation = Deprecation.WithMessage("Test message 2"), hasContiguousNumberedTokens = false, parsingErrors = emptyList(), ), ), ) result.assertOnFormattedResourcesObject { formattedResourcesObject -> val testFun = formattedResourcesObject.funSpecs.single { it.name == "testFun" } assertThat(testFun.annotations) .contains( AnnotationSpec.builder(Deprecated::class).addMember("%S", "Test message 1").build() ) val testProp = formattedResourcesObject.propertySpecs.single { it.name == "testProp" } assertThat(testProp.annotations) .contains( AnnotationSpec.builder(Deprecated::class) .useSiteTarget(AnnotationSpec.UseSiteTarget.GET) .addMember("%S", "Test message 2") .build() ) } } private inline fun FileSpec.assertOnFormattedResourcesObject( block: (formattedResourcesObject: TypeSpec) -> Unit ) { val formattedResourcesObject = members.filterIsInstance().find { it.name == "FormattedResources" } if (formattedResourcesObject == null) { fail("FormattedResources object not found") } else { block(formattedResourcesObject) } } } ================================================ FILE: runtime/api/runtime.api ================================================ public final class app/cash/paraphrase/FormattedResource { public fun (ILjava/lang/Object;)V public fun equals (Ljava/lang/Object;)Z public final fun getArguments ()Ljava/lang/Object; public final fun getId ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class app/cash/paraphrase/FormattedResourcesKt { public static final fun getString (Landroid/content/Context;Lapp/cash/paraphrase/FormattedResource;)Ljava/lang/String; public static final fun getString (Landroid/content/Context;Lapp/cash/paraphrase/FormattedResource;Landroid/icu/util/ULocale;)Ljava/lang/String; public static final fun getString (Landroid/content/Context;Lapp/cash/paraphrase/FormattedResource;Ljava/util/Locale;)Ljava/lang/String; public static final fun getString (Landroid/content/res/Resources;Lapp/cash/paraphrase/FormattedResource;)Ljava/lang/String; public static final fun getString (Landroid/content/res/Resources;Lapp/cash/paraphrase/FormattedResource;Landroid/icu/util/ULocale;)Ljava/lang/String; public static final fun getString (Landroid/content/res/Resources;Lapp/cash/paraphrase/FormattedResource;Ljava/util/Locale;)Ljava/lang/String; } ================================================ FILE: runtime/build.gradle.kts ================================================ @Suppress("DSL_SCOPE_VIOLATION") plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinParcelize) alias(libs.plugins.mavenPublish) alias(libs.plugins.dokka) alias(libs.plugins.poko) } android { namespace = "app.cash.paraphrase" } dependencies { api(libs.androidAnnotation) testImplementation(libs.junit) testImplementation(libs.assertk) testImplementation(libs.robolectric) } ================================================ FILE: runtime/gradle.properties ================================================ # Maven POM_ARTIFACT_ID=paraphrase-runtime POM_NAME=Paraphrase runtime ================================================ FILE: runtime/src/main/java/app/cash/paraphrase/FormattedResource.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase import android.icu.text.MessageFormat import android.os.Parcelable import androidx.annotation.StringRes import dev.drewhamilton.poko.Poko import kotlinx.parcelize.Parcelize import kotlinx.parcelize.RawValue /** * A [FormattedResource] consists of: * 1. An Android string resource ID * 2. The arguments required to resolve it * * For example, if the following string was declared in the strings.xml resource file: * ```xml * * {suspects, plural, * =0 {{detective} has no suspects} * =1 {{detective} has one suspect} * other {{detective} has # suspects} * } * * ``` * * The [FormattedResource] would contain: * - The R.string.detective_has_suspects resource ID * - An integer value for the suspects argument * - A string value for the detective argument * * @property arguments Arguments passed directly to [MessageFormat.format]. */ @Parcelize @Poko public class FormattedResource( @get:StringRes @param:StringRes public val id: Int, @Poko.ReadArrayContent public val arguments: @RawValue Any, ) : Parcelable ================================================ FILE: runtime/src/main/java/app/cash/paraphrase/FormattedResources.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase import android.content.Context import android.content.res.Resources import android.icu.text.MessageFormat import android.icu.util.ULocale import java.util.Locale /** Resolves and returns the final formatted version of the given resource in the default locale. */ public fun Context.getString(formattedResource: FormattedResource): String = resources.getString(formattedResource) /** Resolves and returns the final formatted version of the given resource in the given locale. */ public fun Context.getString(formattedResource: FormattedResource, locale: Locale): String = resources.getString(formattedResource, locale) /** Resolves and returns the final formatted version of the given resource in the given locale. */ public fun Context.getString(formattedResource: FormattedResource, locale: ULocale): String = resources.getString(formattedResource, locale) /** Resolves and returns the final formatted version of the given resource in the default locale. */ public fun Resources.getString(formattedResource: FormattedResource): String = MessageFormat(getString(formattedResource.id)).format(formattedResource.arguments) /** Resolves and returns the final formatted version of the given resource in the given locale. */ public fun Resources.getString(formattedResource: FormattedResource, locale: Locale): String = MessageFormat(getString(formattedResource.id), locale).format(formattedResource.arguments) /** Resolves and returns the final formatted version of the given resource in the given locale. */ public fun Resources.getString(formattedResource: FormattedResource, locale: ULocale): String = MessageFormat(getString(formattedResource.id), locale).format(formattedResource.arguments) ================================================ FILE: runtime/src/test/java/app/cash/paraphrase/FormattedResourceTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase import android.os.Parcel import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isNotEqualTo import assertk.assertions.isTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class FormattedResourceTest { // region equals @Test fun `equals same instance returns true`() { val instance = FormattedResource(id = 123, arguments = intArrayOf(1, 2)) @Suppress("KotlinConstantConditions") assertThat(instance == instance).isTrue() } @Test fun `equals instance of different class returns false`() { val instance = FormattedResource(id = 123, arguments = 1) assertThat(instance.equals(listOf("a"))).isFalse() } @Test fun `equals with different ids returns false`() { val a = FormattedResource(id = 123, arguments = mapOf("a" to 1, "b" to 2)) val b = FormattedResource(id = 321, arguments = mapOf("a" to 1, "b" to 2)) assertThat(a == b).isFalse() assertThat(b == a).isFalse() } @Test fun `equals with different argument maps returns false`() { val a = FormattedResource(id = 123, arguments = mapOf("a" to 1, "b" to 2)) val b = FormattedResource(id = 123, arguments = mapOf("a" to 3, "b" to 4)) assertThat(a == b).isFalse() assertThat(b == a).isFalse() } @Test fun `equals with same ids and argument maps returns true`() { val a = FormattedResource(id = 123, arguments = mapOf("a" to 1, "b" to 2)) val b = FormattedResource(id = 123, arguments = mapOf("a" to 1, "b" to 2)) assertThat(a == b).isTrue() assertThat(b == a).isTrue() } @Test fun `equals with different argument arrays returns false`() { val a = FormattedResource(id = 123, arguments = arrayOf("a", "b")) val b = FormattedResource(id = 123, arguments = arrayOf("c", "d")) assertThat(a == b).isFalse() assertThat(b == a).isFalse() } @Test fun `equals with only one argument array returns false`() { val a = FormattedResource(id = 123, arguments = arrayOf("a", "b")) val b = FormattedResource(id = 123, arguments = "cd") assertThat(a == b).isFalse() assertThat(b == a).isFalse() } @Test fun `equals with same ids and argument arrays returns true`() { val a = FormattedResource(id = 123, arguments = arrayOf("a", "b")) val b = FormattedResource(id = 123, arguments = arrayOf("a", "b")) assertThat(a == b).isTrue() assertThat(b == a).isTrue() } // endregion // region hashCode @Test fun `hashCode with different ids returns different values`() { val a = FormattedResource(id = 123, arguments = mapOf("a" to 1, "b" to 2)) val b = FormattedResource(id = 321, arguments = mapOf("a" to 1, "b" to 2)) assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) } @Test fun `hashCode with different argument maps returns different values`() { val a = FormattedResource(id = 123, arguments = mapOf("a" to 1, "b" to 2)) val b = FormattedResource(id = 123, arguments = mapOf("a" to 3, "b" to 4)) assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) } @Test fun `hashCode with same ids and argument maps returns same values`() { val a = FormattedResource(id = 123, arguments = mapOf("a" to 1, "b" to 2)) val b = FormattedResource(id = 123, arguments = mapOf("a" to 1, "b" to 2)) assertThat(a.hashCode()).isEqualTo(b.hashCode()) } @Test fun `hashCode with different argument arrays returns different values`() { val a = FormattedResource(id = 123, arguments = arrayOf("a", "b")) val b = FormattedResource(id = 123, arguments = arrayOf("c", "d")) assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) } @Test fun `hashCode with same ids and argument arrays returns same values`() { val a = FormattedResource(id = 123, arguments = arrayOf("a", "b")) val b = FormattedResource(id = 123, arguments = arrayOf("a", "b")) assertThat(a.hashCode()).isEqualTo(b.hashCode()) } // endregion // region toString @Test fun `toString with map includes contents`() { val instance = FormattedResource(id = 123, arguments = mapOf("a" to 1, "b" to 2)) assertThat(instance.toString()).isEqualTo("FormattedResource(id=123, arguments={a=1, b=2})") } @Test fun `toString with array includes contents`() { val instance = FormattedResource(id = 123, arguments = arrayOf("a", "b")) assertThat(instance.toString()).isEqualTo("FormattedResource(id=123, arguments=[a, b])") } // endregion // region Parcelable @Test fun `parcelable with map`() { val instance = FormattedResource(id = 123, arguments = mapOf("a" to 1, "b" to 2)) assertThat(instance.roundTripThroughParcel()).isEqualTo(instance) } @Test fun `parcelable with array`() { val instance = FormattedResource(id = 123, arguments = arrayOf("a", "b")) assertThat(instance.roundTripThroughParcel()).isEqualTo(instance) } private fun FormattedResource.roundTripThroughParcel(): FormattedResource { val bytes = Parcel.obtain().run { writeParcelable(this@roundTripThroughParcel, 0) marshall() } return Parcel.obtain().run { unmarshall(bytes, 0, bytes.size) setDataPosition(0) readParcelable(FormattedResource::class.java.classLoader)!! } } // endregion } ================================================ FILE: runtime-compose-ui/api/runtime-compose-ui.api ================================================ public final class app/cash/paraphrase/compose/ComposableFormattedResourcesKt { public static final fun formattedResource (Lapp/cash/paraphrase/FormattedResource;Landroid/icu/util/ULocale;Landroidx/compose/runtime/Composer;I)Ljava/lang/String; public static final fun formattedResource (Lapp/cash/paraphrase/FormattedResource;Landroidx/compose/runtime/Composer;I)Ljava/lang/String; public static final fun formattedResource (Lapp/cash/paraphrase/FormattedResource;Ljava/util/Locale;Landroidx/compose/runtime/Composer;I)Ljava/lang/String; } ================================================ FILE: runtime-compose-ui/build.gradle.kts ================================================ plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinCompose) alias(libs.plugins.mavenPublish) alias(libs.plugins.dokka) } android { namespace = "app.cash.paraphrase.compose" } dependencies { api(libs.composeUi) api(projects.runtime) } ================================================ FILE: runtime-compose-ui/gradle.properties ================================================ # Maven POM_ARTIFACT_ID=paraphrase-runtime-compose-ui POM_NAME=Paraphrase runtime for Compose UI ================================================ FILE: runtime-compose-ui/src/main/java/app/cash/paraphrase/compose/ComposableFormattedResources.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.compose import android.icu.text.MessageFormat import android.icu.util.ULocale import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.res.stringResource import app.cash.paraphrase.FormattedResource import java.util.Locale /** Resolves and returns the final formatted version of the given resource in the default locale. */ @Composable @ReadOnlyComposable public fun formattedResource(formattedResource: FormattedResource): String = MessageFormat(stringResource(formattedResource.id)).format(formattedResource.arguments) /** Resolves and returns the final formatted version of the given resource in the given locale. */ @Composable @ReadOnlyComposable public fun formattedResource(formattedResource: FormattedResource, locale: Locale): String = MessageFormat(stringResource(formattedResource.id), locale).format(formattedResource.arguments) /** Resolves and returns the final formatted version of the given resource in the given locale. */ @Composable @ReadOnlyComposable public fun formattedResource(formattedResource: FormattedResource, locale: ULocale): String = MessageFormat(stringResource(formattedResource.id), locale).format(formattedResource.arguments) ================================================ FILE: sample/app/build.gradle.kts ================================================ @Suppress("DSL_SCOPE_VIOLATION") plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.kotlinCompose) id("app.cash.paraphrase") } android { namespace = "app.cash.paraphrase.sample.app" defaultConfig { targetSdk = 34 versionCode = 1 versionName = "1.0" } compileOptions { isCoreLibraryDesugaringEnabled = true } } kotlin { compilerOptions { allWarningsAsErrors = true } } dependencies { implementation(projects.sample.library) implementation(libs.androidActivityCompose) implementation(libs.googleMaterial) implementation(libs.composeMaterial) implementation(libs.composeUi) coreLibraryDesugaring(libs.coreLibraryDesugaring) } ================================================ FILE: sample/app/src/androidTest/kotlin/app/cash/paraphrase/sample/app/ParaphraseTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.sample.app import app.cash.paraphrase.sample.app.test.FormattedResources // Note: This is a compilation test, not a runtime test, so no assertions are needed. class ParaphraseTest { fun testFormattedResources() { FormattedResources.app_test_text_argument("Jobu Tupaki") } } ================================================ FILE: sample/app/src/androidTest/res/values/strings.xml ================================================ {name} ================================================ FILE: sample/app/src/debug/res/values/strings.xml ================================================ Debug: {arg} ================================================ FILE: sample/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: sample/app/src/main/java/app/cash/paraphrase/sample/app/MainActivity.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.sample.app import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.cash.paraphrase.FormattedResource import app.cash.paraphrase.compose.formattedResource import app.cash.paraphrase.sample.app.FormattedResources as AppFormattedResources import app.cash.paraphrase.sample.library.FormattedResources as LibraryFormattedResources import java.time.LocalDate import java.time.LocalTime import java.time.ZonedDateTime class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { LazyColumn { item { Header(text = "App Strings") } item { SampleRow("Text No Arguments", stringResource(AppFormattedResources.app_name)) } items(APP_SAMPLES) { SampleRow(it) } item { Header(text = "Library Strings") } item { SampleRow("Text No Arguments", stringResource(LibraryFormattedResources.library_name)) } items(LIBRARY_SAMPLES) { SampleRow(it) } } } } @Composable private fun Header(text: String) { Text( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), text = text.uppercase(), fontSize = 14.sp, fontWeight = FontWeight.Black, ) } @Composable private fun SampleRow(sample: Sample) { SampleRow(label = sample.label, text = formattedResource(sample.resource)) } @Composable private fun SampleRow(label: String, text: String) { Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { Text( modifier = Modifier.padding(bottom = 4.dp), color = Color.DarkGray, text = label, fontSize = 12.sp, ) Text(text = text, fontSize = 16.sp) } } companion object { data class Sample(val label: String, val resource: FormattedResource) private val APP_SAMPLES = listOf( Sample( label = "Text Argument", resource = AppFormattedResources.app_text_argument(name = "Jobu Tupaki"), ), Sample( label = "Date Argument", resource = AppFormattedResources.app_date_argument(release_date = LocalDate.now()), ), Sample( label = "Number Argument", resource = AppFormattedResources.app_number_argument(budget = 10_000_000), ), Sample( label = "Time Argument", resource = AppFormattedResources.app_time_argument(showtime = ZonedDateTime.now()), ), Sample( label = "Plural Argument", resource = AppFormattedResources.app_plural_argument(count = 5), ), Sample( label = "Select Argument", resource = AppFormattedResources.app_select_argument(verse = "alpha"), ), Sample( label = "Select Ordinal Argument", resource = AppFormattedResources.app_select_ordinal_argument(count = 5), ), ) private val LIBRARY_SAMPLES = listOf( Sample( label = "Text Argument", resource = LibraryFormattedResources.library_text_argument(name = "Jobu Tupaki"), ), Sample( label = "Date Argument", resource = LibraryFormattedResources.library_date_argument(release_date = LocalDate.now()), ), Sample( label = "Number Argument", resource = LibraryFormattedResources.library_number_argument(budget = 10_000_000), ), Sample( label = "Time Argument", resource = LibraryFormattedResources.library_time_argument(showtime = LocalTime.now()), ), Sample( label = "Plural Argument", resource = LibraryFormattedResources.library_plural_argument(count = 5), ), Sample( label = "Select Argument", resource = LibraryFormattedResources.library_select_argument(verse = "alpha"), ), Sample( label = "Select Ordinal Argument", resource = LibraryFormattedResources.library_select_ordinal_argument(count = 5), ), @Suppress("DEPRECATION") Sample( label = "Choice argument", resource = LibraryFormattedResources.library_choice_argument(outlook = 100), ), ) } } ================================================ FILE: sample/app/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: sample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml ================================================ ================================================ FILE: sample/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: sample/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: sample/app/src/main/res/values/colors.xml ================================================ #FFBB86FC #FF6200EE #FF3700B3 #FF03DAC5 #FF018786 #FF000000 #FFFFFFFF ================================================ FILE: sample/app/src/main/res/values/strings.xml ================================================ Paraphrase Sample App {name} {release_date, date, full} {budget, number} {showtime, time, long} {count, plural, =0 {Jobu does not order any bagels} =1 {Jobu orders an everything bagel} other {Jobu orders # everything bagels} } {verse, select, alpha {Evelyn talks to Alpha-Waymond} other {Evelyn talks to Waymond} } {count, selectordinal, one {Evelyn verse jumps for the #st time} two {Evelyn verse jumps for the #nd time} few {Evelyn verse jumps for the #rd time} other {Evelyn verse jumps for the #th time} } ================================================ FILE: sample/app/src/main/res/values/themes.xml ================================================ ================================================ FILE: sample/app/src/main/res/values-night/themes.xml ================================================ ================================================ FILE: sample/app/src/release/res/values/strings.xml ================================================ Release: {arg} ================================================ FILE: sample/library/build.gradle.kts ================================================ @Suppress("DSL_SCOPE_VIOLATION") plugins { alias(libs.plugins.androidLibrary) id("app.cash.paraphrase") } android { namespace = "app.cash.paraphrase.sample.library" } ================================================ FILE: sample/library/src/androidTest/kotlin/app/cash/paraphrase/sample/library/ParaphraseTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.sample.library import app.cash.paraphrase.sample.library.test.FormattedResources // Note: This is a compilation test, not a runtime test, so no assertions are needed. class ParaphraseTest { fun testFormattedResources() { FormattedResources.library_test_text_argument("Jobu Tupaki") } } ================================================ FILE: sample/library/src/androidTest/res/values/strings.xml ================================================ {name} ================================================ FILE: sample/library/src/main/res/values/strings.xml ================================================ Paraphrase Sample Library {name} {release_date, date, short} {budget, number} {showtime, time, short} {count, plural, =0 {Jobu does not order any bagels} =1 {Jobu orders an everything bagel} other {Jobu orders # everything bagels} } {verse, select, alpha {Evelyn talks to Alpha-Waymond} other {Evelyn talks to Waymond} } {count, selectordinal, one {Evelyn verse jumps for the #st time} two {Evelyn verse jumps for the #nd time} few {Evelyn verse jumps for the #rd time} other {Evelyn verse jumps for the #th time} } Jobu\'s outlook is {outlook, choice, -1#negative| 0#neutral| 1#positive } ================================================ FILE: settings.gradle.kts ================================================ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } @Suppress("UnstableApiUsage") dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } rootProject.name = "paraphrase" include(":plugin", ":runtime", ":runtime-compose-ui", ":sample:app", ":sample:library", ":tests") includeBuild("build-logic") { dependencySubstitution { substitute(module("app.cash.paraphrase:plugin")).using(project(":plugin")) } } ================================================ FILE: tests/build.gradle.kts ================================================ @Suppress("DSL_SCOPE_VIOLATION") plugins { alias(libs.plugins.androidTest) id("app.cash.paraphrase") } android { namespace = "app.cash.paraphrase.tests" // This must point at an application module, although we won't use it. targetProjectPath = ":sample:app" // Our test APK runs independently of the target project APK. experimentalProperties["android.experimental.self-instrumenting"] = true defaultConfig { targetSdk = 34 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { isCoreLibraryDesugaringEnabled = true } } dependencies { implementation(libs.junit) implementation(libs.assertk) implementation(libs.androidTestRunner) implementation(libs.testParameterInjector) coreLibraryDesugaring(libs.coreLibraryDesugaring) } ================================================ FILE: tests/src/main/kotlin/app/cash/paraphrase/tests/LocaleAndTimeZoneRule.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.tests import java.util.Locale import java.util.TimeZone import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement class LocaleAndTimeZoneRule( private val locale: Locale = Locale.getDefault(), private val timeZone: TimeZone = TimeZone.getDefault(), ) : TestRule { override fun apply(base: Statement, description: Description): Statement { return object : Statement() { override fun evaluate() { val oldLocale = Locale.getDefault() Locale.setDefault(locale) val oldTimeZone = TimeZone.getDefault() TimeZone.setDefault(timeZone) try { base.evaluate() } finally { Locale.setDefault(oldLocale) TimeZone.setDefault(oldTimeZone) } } } } } ================================================ FILE: tests/src/main/kotlin/app/cash/paraphrase/tests/LocalesTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.tests import android.icu.util.ULocale import androidx.test.platform.app.InstrumentationRegistry import app.cash.paraphrase.FormattedResource import app.cash.paraphrase.getString import app.cash.paraphrase.tests.LocalesTest.TestLocale.en_IL_ca_hebrew import app.cash.paraphrase.tests.LocalesTest.TestLocale.en_US import assertk.assertThat import assertk.assertions.isEqualTo import com.google.testing.junit.testparameterinjector.TestParameter import com.google.testing.junit.testparameterinjector.TestParameterInjector import java.time.LocalDate import java.time.Month import java.util.Locale import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(TestParameterInjector::class) class LocalesTest(@TestParameter private val testLocale: TestLocale) { @get:Rule val localeRule = LocaleAndTimeZoneRule(locale = testLocale.value) private val context = InstrumentationRegistry.getInstrumentation().context private val releaseDate = LocalDate.of(2022, Month.MARCH, 24) /** * Must be instantiated after [localeRule] has taken effect, to be sure we're testing with a * Calendar that was created under the [testLocale]. */ private lateinit var resource: FormattedResource @Before fun instantiateResource() { resource = FormattedResources.locale_date(releaseDate) } @Test fun defaultLocale() { val expected = when (testLocale) { en_US -> "Mar 24, 2022" en_IL_ca_hebrew -> "21 Adar II 5782" } assertThat(context.getString(resource)).isEqualTo("A $expected B") } @Test fun franceLocale() { assertThat(context.getString(resource, Locale.FRANCE)).isEqualTo("A 24 mars 2022 B") } @Test fun germanyULocale() { assertThat(context.getString(resource, ULocale.GERMANY)).isEqualTo("A 24.03.2022 B") } @Test fun hebrewCalendarLocale() { assertThat(context.getString(resource, hebrewCalendarLocale)).isEqualTo("A 21 Adar II 5782 B") } @Suppress("EnumEntryName", "unused") enum class TestLocale(val value: Locale) { en_US(value = Locale("en", "US")), en_IL_ca_hebrew(value = hebrewCalendarLocale), } private companion object { val hebrewCalendarLocale: Locale = Locale.Builder().setLocale(Locale("en", "IL")).setExtension('u', "ca-hebrew").build() } } ================================================ FILE: tests/src/main/kotlin/app/cash/paraphrase/tests/NamedTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.tests import androidx.test.platform.app.InstrumentationRegistry import app.cash.paraphrase.getString import assertk.assertThat import assertk.assertions.isEqualTo import org.junit.Test class NamedTest { private val context = InstrumentationRegistry.getInstrumentation().context @Test fun numberedSparseOne() { val formattedResource = FormattedResources.named_one("Z") assertThat(formattedResource.arguments as Map).isEqualTo(mapOf("one" to "Z")) val formatted = context.getString(formattedResource) assertThat(formatted).isEqualTo("A Z B") } @Test fun numberedSparseThree() { val formattedResource = FormattedResources.named_three("Z", "Y", "X") assertThat(formattedResource.arguments as Map) .isEqualTo(mapOf("one" to "Z", "two" to "Y", "three" to "X")) val formatted = context.getString(formattedResource) assertThat(formatted).isEqualTo("A Z B Y C X D") } } ================================================ FILE: tests/src/main/kotlin/app/cash/paraphrase/tests/NumberedTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.tests import androidx.test.platform.app.InstrumentationRegistry import app.cash.paraphrase.getString import assertk.assertThat import assertk.assertions.containsExactly import assertk.assertions.isEqualTo import org.junit.Test class NumberedTest { private val context = InstrumentationRegistry.getInstrumentation().context @Test fun numberedContiguousOne() { val formattedResource = FormattedResources.numbered_contiguous_one("Z") assertThat(formattedResource.arguments as Array).containsExactly("Z") val formatted = context.getString(formattedResource) assertThat(formatted).isEqualTo("A Z B") } @Test fun numberedContiguousThree() { val formattedResource = FormattedResources.numbered_contiguous_three("Z", "Y", "X") assertThat(formattedResource.arguments as Array).containsExactly("Z", "Y", "X") val formatted = context.getString(formattedResource) assertThat(formatted).isEqualTo("A Z B Y C X D") } @Test fun numberedSparseOne() { val formattedResource = FormattedResources.numbered_sparse_one("Z") assertThat(formattedResource.arguments as Map).isEqualTo(mapOf("1" to "Z")) val formatted = context.getString(formattedResource) assertThat(formatted).isEqualTo("A Z B") } @Test fun numberedSparseThree() { val formattedResource = FormattedResources.numbered_sparse_three("Z", "Y", "X") assertThat(formattedResource.arguments as Map) .isEqualTo(mapOf("1" to "Z", "3" to "Y", "5" to "X")) val formatted = context.getString(formattedResource) assertThat(formatted).isEqualTo("A Z B Y C X D") } } ================================================ FILE: tests/src/main/kotlin/app/cash/paraphrase/tests/TypesTest.kt ================================================ /* * Copyright (C) 2023 Cash App * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.cash.paraphrase.tests import android.os.Build import androidx.test.platform.app.InstrumentationRegistry import app.cash.paraphrase.getString import assertk.assertThat import assertk.assertions.isEqualTo import java.time.LocalDate import java.time.LocalTime import java.time.Month import java.time.OffsetTime import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime import java.util.Locale import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import org.junit.Rule import org.junit.Test class TypesTest { @get:Rule val localeRule = LocaleAndTimeZoneRule(locale = Locale("en", "US")) private val context = InstrumentationRegistry.getInstrumentation().context private val releaseDate = LocalDate.of(2022, Month.MARCH, 24) private val releaseTime = LocalTime.of(19, 23, 45) private val releaseDateTime = ZonedDateTime.of(releaseDate, releaseTime, ZoneId.of("Pacific/Honolulu")) @Test fun typeNone() { val formattedString = context.getString(FormattedResources.type_none("Z")) assertThat(formattedString).isEqualTo("A Z B") val formattedInteger = context.getString(FormattedResources.type_none(2)) assertThat(formattedInteger).isEqualTo("A 2 B") val formattedDouble = context.getString(FormattedResources.type_none(2.345)) assertThat(formattedDouble).isEqualTo("A 2.345 B") val formattedInstant = context.getString(FormattedResources.type_none(releaseDateTime.toInstant())) assertThat(formattedInstant).isEqualTo("A 2022-03-25T05:23:45Z B") } @Test fun typeNumber() { val formattedInteger = context.getString(FormattedResources.type_number(2)) assertThat(formattedInteger).isEqualTo("A 2 B") val formattedDouble = context.getString(FormattedResources.type_number(2.345)) assertThat(formattedDouble).isEqualTo("A 2.345 B") } @Test fun typeNumberInteger() { val formatted = context.getString(FormattedResources.type_number_integer(2)) assertThat(formatted).isEqualTo("A 2 B") } @Test fun typeNumberCurrency() { val formatted = context.getString(FormattedResources.type_number_currency(2)) assertThat(formatted).isEqualTo("A $2.00 B") } @Test fun typeNumberPercent() { val formatted = context.getString(FormattedResources.type_number_percent(.2)) assertThat(formatted).isEqualTo("A 20% B") } @Test fun typeNumberCustom() { val formatted = context.getString(FormattedResources.type_number_custom(1234567)) assertThat(formatted).isEqualTo("A 12,34,567 B") } @Test fun typeDate() { val formatted = context.getString(FormattedResources.type_date(releaseDate)) assertThat(formatted).isEqualTo("A Mar 24, 2022 B") } @Test fun typeDateShort() { val formatted = context.getString(FormattedResources.type_date_short(releaseDate)) assertThat(formatted).isEqualTo("A 3/24/22 B") } @Test fun typeDateMedium() { val formatted = context.getString(FormattedResources.type_date_medium(releaseDate)) assertThat(formatted).isEqualTo("A Mar 24, 2022 B") } @Test fun typeDateLong() { val formatted = context.getString(FormattedResources.type_date_long(releaseDate)) assertThat(formatted).isEqualTo("A March 24, 2022 B") } @Test fun typeDateFull() { val formatted = context.getString(FormattedResources.type_date_full(releaseDate)) assertThat(formatted).isEqualTo("A Thursday, March 24, 2022 B") } @Test fun typeDatePatternDateTimeZone() { val formatted = context.getString(FormattedResources.type_date_pattern_date_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 3-24, 7PM HST B") } @Test fun typeDatePatternDateTimeOffset() { val formatted = context.getString( FormattedResources.type_date_pattern_date_time_offset(releaseDateTime.toOffsetDateTime()) ) assertThat(formatted).isEqualTo("A 3-24, 7PM -10:00 B") } @Test fun typeDatePatternDateTime() { val localDateTime = releaseDateTime.toLocalDateTime() val formatted = context.getString(FormattedResources.type_date_pattern_date_time(localDateTime)) assertThat(formatted).isEqualTo("A 3-24 7PM B") } @Test fun typeDatePatternDateZone() { val formatted = context.getString(FormattedResources.type_date_pattern_date_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A March (HST) B") } @Test fun typeDatePatternDateOffset() { val formatted = context.getString( FormattedResources.type_date_pattern_date_offset(releaseDateTime.toOffsetDateTime()) ) assertThat(formatted).isEqualTo("A March (-10:00) B") } @Test fun typeDatePatternDate() { val formatted = context.getString(FormattedResources.type_date_pattern_date(releaseDate)) assertThat(formatted).isEqualTo("A 2022-03-24 B") } @Test fun typeDatePatternTimeZone() { val formatted = context.getString(FormattedResources.type_date_pattern_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 19:23 HST B") } @Test fun typeDatePatternTimeOffset() { val formatted = context.getString( FormattedResources.type_date_pattern_time_offset( // Ensures the UTC/GMT case works: releaseDateTime.withZoneSameLocal(ZoneOffset.UTC).toOffsetDateTime().toOffsetTime() ) ) assertThat(formatted).isEqualTo("A 19:23+0000 B") } @Test fun typeDatePatternTime() { val formatted = context.getString(FormattedResources.type_date_pattern_time(releaseTime)) assertThat(formatted).isEqualTo("A 23 past 7 B") } @Test fun typeDatePatternZone() { val formatted = context.getString(FormattedResources.type_date_pattern_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A Hawaii-Aleutian Standard Time B") } @Test fun typeDatePatternOffset() { val formatted = context.getString(FormattedResources.type_date_pattern_offset(releaseDateTime.offset)) assertThat(formatted).isEqualTo("A GMT-10:00 B") } @Test fun typeDatePatternNone() { val formatted = context.getString(FormattedResources.type_date_pattern_none(null)) assertThat(formatted).isEqualTo("A What is this for? B") } @Test fun typeTime() { val formatted = context.getString(FormattedResources.type_time(releaseTime)) assertThat(formatted).isEqualTo("A 7:23:45 PM B") } @Test fun typeTimeShort() { val formatted = context.getString(FormattedResources.type_time_short(releaseTime)) assertThat(formatted).isEqualTo("A 7:23 PM B") } @Test fun typeTimeMedium() { val formatted = context.getString(FormattedResources.type_time_medium(releaseTime)) assertThat(formatted).isEqualTo("A 7:23:45 PM B") } @Test fun typeTimeLong() { val formatted = context.getString(FormattedResources.type_time_long(releaseDateTime)) assertThat(formatted).isEqualTo("A 7:23:45 PM HST B") } @Test fun typeTimeFull() { val formatted = context.getString(FormattedResources.type_time_full(releaseDateTime)) assertThat(formatted).isEqualTo("A 7:23:45 PM Hawaii-Aleutian Standard Time B") } @Test fun typeTimePatternDateTimeZone() { val formatted = context.getString(FormattedResources.type_time_pattern_date_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 3-24, 7PM HST B") } @Test fun typeTimePatternDateTimeOffset() { val formatted = context.getString( FormattedResources.type_time_pattern_date_time_offset(releaseDateTime.toOffsetDateTime()) ) assertThat(formatted).isEqualTo("A 3-24, 7PM -10 B") } @Test fun typeTimePatternDateTime() { val localDateTime = releaseDateTime.toLocalDateTime() val formatted = context.getString(FormattedResources.type_time_pattern_date_time(localDateTime)) assertThat(formatted).isEqualTo("A 3-24 7PM B") } @Test fun typeTimePatternDateZone() { val formatted = context.getString(FormattedResources.type_time_pattern_date_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A March (HST) B") } @Test fun typeTimePatternDateOffset() { val formatted = context.getString( FormattedResources.type_time_pattern_date_offset(releaseDateTime.toOffsetDateTime()) ) assertThat(formatted).isEqualTo("A March (-10) B") } @Test fun typeTimePatternDate() { val formatted = context.getString(FormattedResources.type_time_pattern_date(releaseDate)) assertThat(formatted).isEqualTo("A 2022-03-24 B") } @Test fun typeTimePatternTimeZone() { val formatted = context.getString(FormattedResources.type_time_pattern_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 19:23 HST B") } @Test fun typeTimePatternTimeOffset() { val formatted = context.getString( FormattedResources.type_time_pattern_time_offset( OffsetTime.of(releaseDateTime.toLocalTime(), releaseDateTime.offset) ) ) assertThat(formatted).isEqualTo("A 19:23-1000 B") } @Test fun typeTimePatternTime() { val formatted = context.getString(FormattedResources.type_time_pattern_time(releaseTime)) assertThat(formatted).isEqualTo("A 19-23-45 B") } @Test fun typeTimePatternZone() { val formatted = context.getString(FormattedResources.type_time_pattern_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A Hawaii-Aleutian Standard Time B") } @Test fun typeTimePatternOffset() { val formatted = context.getString(FormattedResources.type_time_pattern_offset(releaseDateTime.offset)) assertThat(formatted).isEqualTo("A GMT-10:00 B") } @Test fun typeTimePatternNone() { val formatted = context.getString(FormattedResources.type_time_pattern_none(null)) assertThat(formatted).isEqualTo("A What is this for? B") } @Test fun typeTimeWithWinterTimeZone() { val winterDateTime = ZonedDateTime.of( LocalDate.of(2023, Month.FEBRUARY, 17), LocalTime.NOON, ZoneId.of("America/Chicago"), ) val formatted = context.getString(FormattedResources.type_time_long(winterDateTime)) assertThat(formatted).isEqualTo("A 12:00:00 PM CST B") } @Test fun typeTimeWithSummerTimeZone() { val summerDateTime = ZonedDateTime.of( LocalDate.of(2023, Month.JULY, 17), LocalTime.NOON, ZoneId.of("America/Chicago"), ) val formatted = context.getString(FormattedResources.type_time_long(summerDateTime)) assertThat(formatted).isEqualTo("A 12:00:00 PM CDT B") } @Test fun typeDuration() { val formattedSeconds = context.getString(FormattedResources.type_duration(3.seconds)) assertThat(formattedSeconds).isEqualTo("A 3 sec. B") val formattedMinutes = context.getString(FormattedResources.type_duration(3.minutes + 2.seconds)) assertThat(formattedMinutes).isEqualTo("A 3:02 B") val formattedHours = context.getString(FormattedResources.type_duration(3.hours + 2.minutes + 1.seconds)) assertThat(formattedHours).isEqualTo("A 3:02:01 B") } @Test fun typeOrdinal() { val zero = 0 // Requires an int overload to be invoked val formattedZero = context.getString(FormattedResources.type_ordinal(zero)) assertThat(formattedZero).isEqualTo("A 0th B") val formattedOne = context.getString(FormattedResources.type_ordinal(1)) assertThat(formattedOne).isEqualTo("A 1st B") val formattedTwo = context.getString(FormattedResources.type_ordinal(2)) assertThat(formattedTwo).isEqualTo("A 2nd B") val formattedThree = context.getString(FormattedResources.type_ordinal(3)) assertThat(formattedThree).isEqualTo("A 3rd B") val formattedFour = context.getString(FormattedResources.type_ordinal(4)) assertThat(formattedFour).isEqualTo("A 4th B") val formattedLong = context.getString(FormattedResources.type_ordinal(Long.MAX_VALUE)) val expected = if (Build.VERSION.SDK_INT >= 26) { "9,223,372,036,854,775,807th" } else { // ICU versions on older Android platforms lose bits by internally converting Long to // Double: "9,223,372,036,854,776,000th" } assertThat(formattedLong).isEqualTo("A $expected B") } @Test fun typeSpellout() { val formattedOnes = context.getString(FormattedResources.type_spellout(3)) assertThat(formattedOnes).isEqualTo("A three B") val formattedTens = context.getString(FormattedResources.type_spellout(32)) assertThat(formattedTens).isEqualTo("A thirty-two B") val formattedHundreds = context.getString(FormattedResources.type_spellout(321)) assertThat(formattedHundreds).isEqualTo("A three hundred twenty-one B") } @Test fun typePlural() { val formatted0 = context.getString(FormattedResources.type_count_plural(0)) assertThat(formatted0).isEqualTo("A Z B") val formatted1 = context.getString(FormattedResources.type_count_plural(1)) assertThat(formatted1).isEqualTo("A Y B") val formatted2 = context.getString(FormattedResources.type_count_plural(2)) assertThat(formatted2).isEqualTo("A X B") } @Test fun typeSelect() { val formattedAlpha = context.getString(FormattedResources.type_verse_select("alpha")) assertThat(formattedAlpha).isEqualTo("A Z B") val formattedBeta = context.getString(FormattedResources.type_verse_select("beta")) assertThat(formattedBeta).isEqualTo("A Y B") val formattedGamma = context.getString(FormattedResources.type_verse_select("gamma")) assertThat(formattedGamma).isEqualTo("A X B") } } ================================================ FILE: tests/src/main/res/values/locales.xml ================================================ A {value,date} B ================================================ FILE: tests/src/main/res/values/named.xml ================================================ A {one} B A {one} B {two} C {three} D ================================================ FILE: tests/src/main/res/values/numbered.xml ================================================ A {0} B A {0} B {1} C {2} D A {1} B A {1} B {3} C {5} D ================================================ FILE: tests/src/main/res/values/types.xml ================================================ A {value} B A {value,number} B A {value,number,integer} B A {value,number,currency} B A {value,number,percent} B A {value,number,#,##,###} B A {value,date} B A {value,date,short} B A {value,date,medium} B A {value,date,long} B A {value,date,full} B A {value,date,M-dd, ha z} B A {value,date,M-dd, ha xxx} B A {value,date,M-dd ha} B A {value,date,MMMM (z)} B A {value,date,MMMM (xxx)} B A {value,date,YYYY-MM-dd} B A {value,date,HH:mm zz} B A {value,date,HH:mmZ} B A {value,date,m \'past\' h} B A {value,date,zzzz} B A {value,date,ZZZZ} B A {value,date,\'What is this for?\'} B A {value,time} B A {value,time,short} B A {value,time,medium} B A {value,time,long} B A {value,time,full} B A {value,time,M-dd, ha z} B A {value,time,M-dd, ha x} B A {value,time,M-dd ha} B A {value,time,MMMM (z)} B A {value,time,MMMM (x)} B A {value,time,YYYY-MM-dd} B A {value,time,HH:mm zz} B A {value,time,HH:mmZ} B A {value,time,HH-mm-ss} B A {value,time,zzzz} B A {value,time,ZZZZ} B A {value,time,\'What is this for?\'} B A {value,duration} B A {value,ordinal} B A {value,spellout} B A {count, plural, =0 {Z} =1 {Y} other {X} } B A {count, selectordinal, one {Z #st} two {Z #nd} few {Z #rd} other {Z #th time} } B A {verse, select, alpha {Z} beta {Y} other {X} } B