Full Code of cashapp/paraphrase for AI

main ccf406337c10 cached
87 files
195.7 KB
52.1k tokens
1 requests
Download .txt
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) &amp;#36;{today.year} Cash App&#10;&#10;Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);&#10;you may not use this file except in compliance with the License.&#10;You may obtain a copy of the License at&#10;&#10;     http://www.apache.org/licenses/LICENSE-2.0&#10;&#10;Unless required by applicable law or agreed to in writing, software&#10;distributed under the License is distributed on an &quot;AS IS&quot; BASIS,&#10;WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#10;See the License for the specific language governing permissions and&#10;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
Download .txt
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) &amp;#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.

Copied to clipboard!