Showing preview only (222K chars total). Download the full file or copy to clipboard to get everything.
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: [
'(?<currentValue>.*)\\n',
],
datasourceTemplate: 'java-version',
depNameTemplate: 'java',
// Only write the major version.
extractVersionTemplate: '^(?<version>\\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
================================================
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Copyright (C) &#36;{today.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." />
<option name="myName" value="Square" />
</copyright>
</component>
================================================
FILE: .idea/copyright/profiles_settings.xml
================================================
<component name="CopyrightManager">
<settings default="Square">
<LanguageOptions name="__TEMPLATE__">
<option name="addBlankAfter" value="false" />
</LanguageOptions>
</settings>
</component>
================================================
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
<resources>
<!-- Describes an order placed at the deli. -->
<string name="order_description">
{count, plural,
=0 {{name} does not order any bagels}
=1 {{name} orders an everything bagel}
other {{name} orders # everything bagels}
}
</string>
</resources>
```
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<SpotlessExtension> {
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<KotlinJvmCompile> {
compilerOptions { jvmTarget = JvmTarget.fromTarget(javaVersion.toString()) }
}
val configureAndroid =
Action<Plugin<Any>> {
with(extensions.getByType<CommonExtension>()) {
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 <init> ()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<KotlinJvmCompile> {
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<TokenType>): 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>): 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<TokenType>
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<TokenType, List<TokenType>> =
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<String>
@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 <T> 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<Node> =
object : Iterator<Node> {
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<Project> {
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<String>,
) {
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 <public> resources declared in the given stream, regardless
* of type.
*/
internal fun parsePublicResources(inputStream: InputStream): List<PublicResource> =
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("<public> resource with type $type must have a name")
else -> throw IllegalArgumentException("<public> 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<ResourceFolder, TokenizedResource>,
publicResources: Collection<PublicResource>,
): 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<NumberedToken>().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<PublicResource>.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 <string> resources declared in the given stream.
*
* Ignores all other resources, including <plurals> and <string-array>.
*/
internal fun parseResources(inputStream: InputStream): List<StringResource> =
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<Token>,
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<Part> =
object : Iterator<Part> {
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<Char> {
var isPrevQuote = false
var inQuote = false
val text = StringBuilder()
var itemType = Char(0)
var itemLength = 1
val items = mutableListOf<Char>()
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<MergedResource>): 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<Argument>,
val deprecation: Deprecation,
/* True when the arguments bind to a contiguous set of integer tokens counting from 0. */
val hasContiguousNumberedTokens: Boolean,
val parsingErrors: List<String>,
) {
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 <public /> 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
*
* ```
* <string name="hello">Sup</string>
* ```
*/
@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<Token>,
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<TokenType>().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<TokenType>.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() {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<public name="test" type="string" />
</resources>
"""
.trimIndent()
.assertParse(PublicResource.Named(name = ResourceName("test"), type = "string"))
}
@Test
fun parseMultiplePublicResources() {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<public name="test_1" type="string" />
<public name="test_2" type="anim" />
<public name="test_3" type="color" />
</resources>
"""
.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() {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<public />
</resources>
"""
.trimIndent()
.assertParse(PublicResource.EmptyDeclaration)
}
@Test
fun ignoreOtherResources() {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<public name="test1" type="bool" />
<bool name="test1">true</bool>
<string name="test2">String test2</string>
</resources>
"""
.trimIndent()
.assertParse(PublicResource.Named(name = ResourceName("test1"), type = "bool"))
}
@Test
fun throwOnNamedResourceWithNoName() {
assertThrows(IllegalArgumentException::class.java) {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<public type="string" />
</resources>
"""
.trimIndent()
.assertParse()
}
}
@Test
fun throwOnNamedResourceWithNoType() {
assertThrows(IllegalArgumentException::class.java) {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<public name="test" />
</resources>
"""
.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() {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="test">Test</string>
</resources>
"""
.trimIndent()
.assertParse(StringResource(name = ResourceName("test"), description = null, text = "Test"))
}
@Test
fun parseMultipleStringResources() {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="test_1">Test 1</string>
<string name="test_2">Test 2</string>
<string name="test_3">Test 3</string>
</resources>
"""
.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() {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Test Description -->
<string name="test">Test</string>
</resources>
"""
.trimIndent()
.assertParse(
StringResource(name = ResourceName("test"), description = "Test Description", text = "Test")
)
}
@Test
fun parseUntranslatableStringResource() {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="test" translatable="false">Test</string>
</resources>
"""
.trimIndent()
.assertParse(StringResource(name = ResourceName("test"), description = null, text = "Test"))
}
@Test
fun ignorePluralResource() {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="test">
<item quantity="zero">Test 0</item>
<item quantity="one">Test 1</item>
<item quantity="other">Test</item>
</plurals>
</resources>
"""
.trimIndent()
.assertParse()
}
@Test
fun ignoreStringArrayResource() {
"""
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="test">
<item>Test 1</item>
<item>Test 2</item>
<item>Test 3</item>
</string-array>
</resources>
"""
.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<String, KModifier>,
) {
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<String, KModifier>,
) {
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<TypeSpec>().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 <init> (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
* <string name="detective_has_suspects">
* {suspects, plural,
* =0 {{detective} has no suspects}
* =1 {{detective} has one suspect}
* other {{detective} has # suspects}
* }
* </string>
* ```
*
* 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
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_test_text_argument">{name}</string>
</resources>
================================================
FILE: sample/app/src/debug/res/values/strings.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="variant_specific">Debug: {arg}</string>
</resources>
================================================
FILE: sample/app/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Paraphrase">
<activity
android:name="app.cash.paraphrase.sample.app.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
</application>
</manifest>
================================================
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
================================================
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
android:width="108dp">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
================================================
FILE: sample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
android:width="108dp">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
================================================
FILE: sample/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
================================================
FILE: sample/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
================================================
FILE: sample/app/src/main/res/values/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
================================================
FILE: sample/app/src/main/res/values/strings.xml
================================================
<resources>
<string name="app_name">Paraphrase Sample App</string>
<string name="app_text_argument">{name}</string>
<string name="app_date_argument">{release_date, date, full}</string>
<string name="app_number_argument">{budget, number}</string>
<string name="app_time_argument">{showtime, time, long}</string>
<string name="app_plural_argument">
{count, plural,
=0 {Jobu does not order any bagels}
=1 {Jobu orders an everything bagel}
other {Jobu orders # everything bagels}
}
</string>
<string name="app_select_argument">
{verse, select,
alpha {Evelyn talks to Alpha-Waymond}
other {Evelyn talks to Waymond}
}
</string>
<string name="app_select_ordinal_argument">
{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}
}
</string>
</resources>
================================================
FILE: sample/app/src/main/res/values/themes.xml
================================================
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Paraphrase" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
================================================
FILE: sample/app/src/main/res/values-night/themes.xml
================================================
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Paraphrase" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
================================================
FILE: sample/app/src/release/res/values/strings.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="variant_specific">Release: {arg}</string>
</resources>
================================================
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
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="library_test_text_argument">{name}</string>
</resources>
================================================
FILE: sample/library/src/main/res/values/strings.xml
================================================
<resources>
<string name="library_name">Paraphrase Sample Library</string>
<string name="library_text_argument">{name}</string>
<string name="library_date_argument">{release_date, date, short}</string>
<string name="library_number_argument">{budget, number}</string>
<string name="library_time_argument">{showtime, time, short}</string>
<string name="library_plural_argument">
{count, plural,
=0 {Jobu does not order any bagels}
=1 {Jobu orders an everything bagel}
other {Jobu orders # everything bagels}
}
</string>
<string name="library_select_argument">
{verse, select,
alpha {Evelyn talks to Alpha-Waymond}
other {Evelyn talks to Waymond}
}
</string>
<string name="library_select_ordinal_argument">
{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}
}
</string>
<string name="library_choice_argument">
Jobu\'s outlook is {outlook, choice,
-1#negative|
0#neutral|
1#positive
}
</string>
</resources>
================================================
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<String, Any>).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<String, Any>)
.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<Any>).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<Any>).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<String, Any>).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<String, Any>)
.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).isEq
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
Condensed preview — 87 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (215K chars).
[
{
"path": ".editorconfig",
"chars": 242,
"preview": "root = true\n\n[*]\nindent_size = 2\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n[*.{kt, kt"
},
{
"path": ".gitattributes",
"chars": 53,
"preview": "* text=auto eol=lf\n\n*.bat text eol=crlf\n*.jar binary\n"
},
{
"path": ".github/renovate.json5",
"chars": 939,
"preview": "{\n $schema: 'https://docs.renovatebot.com/renovate-schema.json',\n extends: [\n 'config:recommended',\n ],\n packageR"
},
{
"path": ".github/workflows/.java-version",
"chars": 3,
"preview": "25\n"
},
{
"path": ".github/workflows/build.yaml",
"chars": 2365,
"preview": "name: build\n\non:\n pull_request: {}\n workflow_dispatch: {}\n push:\n branches:\n - 'main'\n tags-ignore:\n "
},
{
"path": ".github/workflows/release.yaml",
"chars": 1457,
"preview": "name: release\n\non:\n push:\n tags:\n - '**'\n\nenv:\n GRADLE_OPTS: \"-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon="
},
{
"path": ".gitignore",
"chars": 120,
"preview": "# IntelliJ IDEA\n.idea/*\n!.idea/copyright\n\n# Gradle\n.gradle\nbuild\n/reports\n\n# Android\nlocal.properties\n\n# Kotlin\n.kotlin\n"
},
{
"path": ".idea/copyright/Square.xml",
"chars": 797,
"preview": "<component name=\"CopyrightManager\">\n <copyright>\n <option name=\"notice\" value=\"Copyright (C) &#36;{today.year} C"
},
{
"path": ".idea/copyright/profiles_settings.xml",
"chars": 210,
"preview": "<component name=\"CopyrightManager\">\n <settings default=\"Square\">\n <LanguageOptions name=\"__TEMPLATE__\">\n <optio"
},
{
"path": "CHANGELOG.md",
"chars": 2925,
"preview": "# Change Log\n\n## [Unreleased]\n[Unreleased]: https://github.com/cashapp/paraphrase/compare/0.6.1...HEAD\n\n## [0.6.1] - 202"
},
{
"path": "LICENSE.txt",
"chars": 11358,
"preview": "\n Apache License\n Version 2.0, January 2004\n "
},
{
"path": "README.md",
"chars": 3135,
"preview": "# Paraphrase\n\nA Gradle plugin that generates type-safe formatters for Android string resources in the ICU message format"
},
{
"path": "RELEASING.md",
"chars": 1093,
"preview": "# Releasing\n\n1. Update the `VERSION_NAME` in `gradle.properties` to the release version\n (i.e., no \"-SNAPSHOT\" suffix)"
},
{
"path": "build-logic/build.gradle.kts",
"chars": 0,
"preview": ""
},
{
"path": "build-logic/settings.gradle.kts",
"chars": 497,
"preview": "pluginManagement {\n repositories {\n google()\n mavenCentral()\n gradlePluginPortal()\n }\n}\n\n@Suppress(\"UnstableA"
},
{
"path": "build.gradle.kts",
"chars": 3175,
"preview": "import com.android.build.api.dsl.CommonExtension\nimport com.diffplug.gradle.spotless.SpotlessExtension\nimport com.vannik"
},
{
"path": "gradle/libs.versions.toml",
"chars": 1836,
"preview": "[versions]\nagp = \"9.2.1\"\nkotlin = \"2.3.21\"\n\n[libraries]\nagp = { module = \"com.android.tools.build:gradle\", version.ref ="
},
{
"path": "gradle/license-header.txt",
"chars": 598,
"preview": "/*\n * Copyright (C) $YEAR Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not "
},
{
"path": "gradle/wrapper/gradle-wrapper.properties",
"chars": 281,
"preview": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributi"
},
{
"path": "gradle.properties",
"chars": 1024,
"preview": "# Maven\nGROUP=app.cash.paraphrase\n# HEY! If you change major version update release.yaml doc folder.\nVERSION_NAME=0.7.0-"
},
{
"path": "gradlew",
"chars": 8631,
"preview": "#!/bin/sh\n\n#\n# Copyright © 2015 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\")"
},
{
"path": "gradlew.bat",
"chars": 2846,
"preview": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (th"
},
{
"path": "plugin/api/plugin.api",
"chars": 211,
"preview": "public final class app/cash/paraphrase/plugin/ParaphrasePlugin : org/gradle/api/Plugin {\n\tpublic fun <init> ()V\n\tpublic "
},
{
"path": "plugin/build.gradle.kts",
"chars": 1431,
"preview": "import org.jetbrains.kotlin.gradle.dsl.JvmTarget\nimport org.jetbrains.kotlin.gradle.dsl.KotlinVersion\nimport org.jetbrai"
},
{
"path": "plugin/gradle.properties",
"chars": 203,
"preview": "# Maven\nPOM_ARTIFACT_ID=paraphrase-plugin\nPOM_NAME=Paraphrase Gradle plugin\n\n# Omit automatic compile dependency on kotl"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/ArgumentTypeResolver.kt",
"chars": 5027,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/GenerateFormattedResources.kt",
"chars": 4581,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/NodeListIterator.kt",
"chars": 898,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/ParaphrasePlugin.kt",
"chars": 4307,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/PublicResourceParser.kt",
"chars": 1865,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/ResourceMerger.kt",
"chars": 4081,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/ResourceParser.kt",
"chars": 1919,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/ResourceTokenizer.kt",
"chars": 10643,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt",
"chars": 11344,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/model/MergedResource.kt",
"chars": 1559,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/model/PublicResource.kt",
"chars": 1089,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/model/ResourceFolder.kt",
"chars": 837,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/model/ResourceName.kt",
"chars": 790,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/model/StringResource.kt",
"chars": 812,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/main/java/app/cash/paraphrase/plugin/model/TokenizedResource.kt",
"chars": 1155,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/test/java/app/cash/paraphrase/plugin/ArgumentTypeResolverTest.kt",
"chars": 7409,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/test/java/app/cash/paraphrase/plugin/PublicResourceParserTest.kt",
"chars": 3114,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/test/java/app/cash/paraphrase/plugin/ResourceMergerTest.kt",
"chars": 7037,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/test/java/app/cash/paraphrase/plugin/ResourceParserTest.kt",
"chars": 3301,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/test/java/app/cash/paraphrase/plugin/ResourceTokenizerTest.kt",
"chars": 9600,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "plugin/src/test/java/app/cash/paraphrase/plugin/ResourceWriterTest.kt",
"chars": 10140,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "runtime/api/runtime.api",
"chars": 1183,
"preview": "public final class app/cash/paraphrase/FormattedResource {\n\tpublic fun <init> (ILjava/lang/Object;)V\n\tpublic fun equals "
},
{
"path": "runtime/build.gradle.kts",
"chars": 415,
"preview": "@Suppress(\"DSL_SCOPE_VIOLATION\")\nplugins {\n alias(libs.plugins.androidLibrary)\n alias(libs.plugins.kotlinParcelize)\n "
},
{
"path": "runtime/gradle.properties",
"chars": 71,
"preview": "# Maven\nPOM_ARTIFACT_ID=paraphrase-runtime\nPOM_NAME=Paraphrase runtime\n"
},
{
"path": "runtime/src/main/java/app/cash/paraphrase/FormattedResource.kt",
"chars": 1726,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "runtime/src/main/java/app/cash/paraphrase/FormattedResources.kt",
"chars": 2336,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "runtime/src/test/java/app/cash/paraphrase/FormattedResourceTest.kt",
"chars": 5989,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "runtime-compose-ui/api/runtime-compose-ui.api",
"chars": 544,
"preview": "public final class app/cash/paraphrase/compose/ComposableFormattedResourcesKt {\n\tpublic static final fun formattedResour"
},
{
"path": "runtime-compose-ui/build.gradle.kts",
"chars": 269,
"preview": "plugins {\n alias(libs.plugins.androidLibrary)\n alias(libs.plugins.kotlinCompose)\n alias(libs.plugins.mavenPublish)\n "
},
{
"path": "runtime-compose-ui/gradle.properties",
"chars": 97,
"preview": "# Maven\nPOM_ARTIFACT_ID=paraphrase-runtime-compose-ui\nPOM_NAME=Paraphrase runtime for Compose UI\n"
},
{
"path": "runtime-compose-ui/src/main/java/app/cash/paraphrase/compose/ComposableFormattedResources.kt",
"chars": 1861,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "sample/app/build.gradle.kts",
"chars": 698,
"preview": "@Suppress(\"DSL_SCOPE_VIOLATION\")\nplugins {\n alias(libs.plugins.androidApplication)\n alias(libs.plugins.kotlinCompose)\n"
},
{
"path": "sample/app/src/androidTest/kotlin/app/cash/paraphrase/sample/app/ParaphraseTest.kt",
"chars": 909,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "sample/app/src/androidTest/res/values/strings.xml",
"chars": 120,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <string name=\"app_test_text_argument\">{name}</string>\n</resources>\n"
},
{
"path": "sample/app/src/debug/res/values/strings.xml",
"chars": 120,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <string name=\"variant_specific\">Debug: {arg}</string>\n</resources>\n"
},
{
"path": "sample/app/src/main/AndroidManifest.xml",
"chars": 800,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n <applica"
},
{
"path": "sample/app/src/main/java/app/cash/paraphrase/sample/app/MainActivity.kt",
"chars": 5494,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "sample/app/src/main/res/drawable/ic_launcher_background.xml",
"chars": 5280,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:he"
},
{
"path": "sample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml",
"chars": 1589,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n xmlns:aapt=\"http://schemas.android.com/aapt\"\n "
},
{
"path": "sample/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
"chars": 338,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n <bac"
},
{
"path": "sample/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml",
"chars": 338,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n <bac"
},
{
"path": "sample/app/src/main/res/values/colors.xml",
"chars": 365,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <color name=\"purple_200\">#FFBB86FC</color>\n <color name=\"purple_50"
},
{
"path": "sample/app/src/main/res/values/strings.xml",
"chars": 984,
"preview": "<resources>\n <string name=\"app_name\">Paraphrase Sample App</string>\n <string name=\"app_text_argument\">{name}</string>\n"
},
{
"path": "sample/app/src/main/res/values/themes.xml",
"chars": 763,
"preview": "<resources xmlns:tools=\"http://schemas.android.com/tools\">\n <!-- Base application theme. -->\n <style name=\"Theme.Parap"
},
{
"path": "sample/app/src/main/res/values-night/themes.xml",
"chars": 763,
"preview": "<resources xmlns:tools=\"http://schemas.android.com/tools\">\n <!-- Base application theme. -->\n <style name=\"Theme.Parap"
},
{
"path": "sample/app/src/release/res/values/strings.xml",
"chars": 122,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <string name=\"variant_specific\">Release: {arg}</string>\n</resources"
},
{
"path": "sample/library/build.gradle.kts",
"chars": 174,
"preview": "@Suppress(\"DSL_SCOPE_VIOLATION\")\nplugins {\n alias(libs.plugins.androidLibrary)\n id(\"app.cash.paraphrase\")\n}\n\nandroid {"
},
{
"path": "sample/library/src/androidTest/kotlin/app/cash/paraphrase/sample/library/ParaphraseTest.kt",
"chars": 921,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "sample/library/src/androidTest/res/values/strings.xml",
"chars": 124,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <string name=\"library_test_text_argument\">{name}</string>\n</resourc"
},
{
"path": "sample/library/src/main/res/values/strings.xml",
"chars": 1176,
"preview": "<resources>\n <string name=\"library_name\">Paraphrase Sample Library</string>\n <string name=\"library_text_argument\">{nam"
},
{
"path": "settings.gradle.kts",
"chars": 608,
"preview": "enableFeaturePreview(\"TYPESAFE_PROJECT_ACCESSORS\")\n\npluginManagement {\n repositories {\n google()\n mavenCentral()\n"
},
{
"path": "tests/build.gradle.kts",
"chars": 811,
"preview": "@Suppress(\"DSL_SCOPE_VIOLATION\")\nplugins {\n alias(libs.plugins.androidTest)\n id(\"app.cash.paraphrase\")\n}\n\nandroid {\n "
},
{
"path": "tests/src/main/kotlin/app/cash/paraphrase/tests/LocaleAndTimeZoneRule.kt",
"chars": 1427,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "tests/src/main/kotlin/app/cash/paraphrase/tests/LocalesTest.kt",
"chars": 2946,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "tests/src/main/kotlin/app/cash/paraphrase/tests/NamedTest.kt",
"chars": 1570,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "tests/src/main/kotlin/app/cash/paraphrase/tests/NumberedTest.kt",
"chars": 2263,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "tests/src/main/kotlin/app/cash/paraphrase/tests/TypesTest.kt",
"chars": 14246,
"preview": "/*\n * Copyright (C) 2023 Cash App\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not u"
},
{
"path": "tests/src/main/res/values/locales.xml",
"chars": 80,
"preview": "<resources>\n <string name=\"locale_date\">A {value,date} B</string>\n</resources>\n"
},
{
"path": "tests/src/main/res/values/named.xml",
"chars": 137,
"preview": "<resources>\n <string name=\"named_one\">A {one} B</string>\n <string name=\"named_three\">A {one} B {two} C {three} D</stri"
},
{
"path": "tests/src/main/res/values/numbered.xml",
"chars": 277,
"preview": "<resources>\n <string name=\"numbered_contiguous_one\">A {0} B</string>\n <string name=\"numbered_contiguous_three\">A {0} B"
},
{
"path": "tests/src/main/res/values/types.xml",
"chars": 3557,
"preview": "<resources>\n <string name=\"type_none\">A {value} B</string>\n\n <string name=\"type_number\">A {value,number} B</string>\n "
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the cashapp/paraphrase GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 87 files (195.7 KB), approximately 52.1k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.