Skip to content

fix: keep zero plural form in Apple XLIFF export - #3789

Open
munzzyy wants to merge 2 commits into
tolgee:mainfrom
munzzyy:cole-munz/apple-xliff-plural-zero
Open

fix: keep zero plural form in Apple XLIFF export#3789
munzzyy wants to merge 2 commits into
tolgee:mainfrom
munzzyy:cole-munz/apple-xliff-plural-zero

Conversation

@munzzyy

@munzzyy munzzyy commented Jul 5, 2026

Copy link
Copy Markdown

Problem

Apple XLIFF export drops any plural form that CLDR doesn't define for the target locale. English only has one and other in CLDR, so a key with a zero form (e.g. "No participants" / "1 Participant" / "%d Participants") gets exported without plural.zero at all. The string is just gone from the file.

AppleXliffExporter.handlePlural runs the converted forms through populateForms, which builds its output set from getPluralFormsForLocale(languageTag) only. Any form outside that set gets dropped instead of exported. This is locale-specific behavior, not something Apple's formats actually require: AppleXcstringsExporter (the .xcstrings exporter) skips this filtering entirely and just writes out whatever forms formsResult contains. That's why the reporter's .xcstrings workaround kept the zero case while XLIFF/stringsdict export lost it.

Change

AppleXliffExporter's populateForms now keeps every form that's actually present in the message, on top of the locale's CLDR forms (so many for Czech still gets filled in from other when the message doesn't specify it, same as before). This lines XLIFF export up with what the xcstrings exporter already does, so a zero form the user wrote makes it into the export regardless of whether the target locale's CLDR data includes it.

I left io.tolgee.formats.populateForms (used by the Android XML resources exporter) alone. Android's plural resources really are constrained to CLDR quantities, so filtering there is correct.

Tests

Added a test to AppleXliffFileExporterTest with an English key carrying zero/one/other forms and checked all three trans-units, including plural.zero, show up in the exported XLIFF.

No JDK on this machine, so I couldn't run ./gradlew :data:test locally. I traced the fix by hand against PluralsFormUtilTest (which already asserts getPluralFormsForLocale("en") returns only one, other) and against how AppleXcstringsExporter builds its output. Would appreciate CI confirming the new test passes.

Fixes #3074

Summary by CodeRabbit

  • Bug Fixes

    • Apple XLIFF exports now include the plural forms required for each locale.
    • Explicit zero plural translations are preserved, including =0 translations that are mapped to the zero category when appropriate.
    • Missing plural values fall back to the “other” translation to help prevent incomplete exports.
  • Tests

    • Added coverage for English exports containing explicit zero and =0 plural forms.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Updates Apple XLIFF plural mapping to preserve authored categories, include locale-required categories, and convert authored =0 to zero when needed. Adds tests for explicit zero and =0 forms in English exports.

Changes

Apple XLIFF plural export fix

Layer / File(s) Summary
Plural form mapping logic update
backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt
The exporter combines CLDR-required and authored plural categories. Missing values use other. An authored =0 form maps to zero when no explicit zero exists.
Zero plural form export tests
backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/AppleXliffFileExporterTest.kt
Tests verify that explicit zero and =0 forms produce plural.zero entries with the expected target strings.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • tolgee/tolgee-platform#3681: Both changes handle exact-match plural selectors and formKeywords, but this PR updates Apple XLIFF export mapping.

Suggested reviewers: anty0, jancizmar

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: preserving zero plural forms in Apple XLIFF exports.
Linked Issues check ✅ Passed The changes address issue #3074 by preserving authored zero forms and exporting their corresponding Apple XLIFF entries.
Out of Scope Changes check ✅ Passed The exporter changes and tests remain focused on Apple plural-form handling described in issue #3074.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt (1)

231-242: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the shared plural-form helper here AppleXliffExporter.kt:231-242 — this duplicates the existing populateForms logic in pluralFormsUtil.kt; call the helper instead of inlining the union/fallback code so Apple plural handling stays in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt`
around lines 231 - 242, The plural-form handling in
AppleXliffExporter.populateForms is duplicating the shared logic from
pluralFormsUtil.kt. Update populateForms to delegate to the existing helper
instead of inlining the union of getPluralFormsForLocale(languageTag) with
forms.keys and the "other" fallback, so Apple plural behavior stays centralized
and easier to maintain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt`:
- Around line 231-242: The plural-form handling in
AppleXliffExporter.populateForms is duplicating the shared logic from
pluralFormsUtil.kt. Update populateForms to delegate to the existing helper
instead of inlining the union of getPluralFormsForLocale(languageTag) with
forms.keys and the "other" fallback, so Apple plural behavior stays centralized
and easier to maintain.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 200c9117-a38a-4c96-91a1-09abd7e39465

📥 Commits

Reviewing files that changed from the base of the PR and between f14e1b8 and a675d8c.

📒 Files selected for processing (2)
  • backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt
  • backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/AppleXliffFileExporterTest.kt

@munzzyy

munzzyy commented Jul 5, 2026

Copy link
Copy Markdown
Author

Deliberately not delegating to io.tolgee.formats.populateForms here. That helper builds its result set from getPluralFormsForLocale(languageTag) alone, which is exactly the bug this PR fixes — it's why a zero form written by the user gets dropped for locales like English where CLDR doesn't define zero. The local version adds + forms.keys so any form the user actually wrote survives, in addition to the CLDR-required ones.

The shared helper stays correct as-is for the Android XML resources exporter, since Android plural resources really are constrained to CLDR quantities (noted in the PR description). Keeping this one local avoids changing behavior for that other caller. Happy to rename it to something more specific like populateAppleForms if the duplication is confusing, but I'd rather not merge the two implementations.

@munzzyy

munzzyy commented Jul 6, 2026

Copy link
Copy Markdown
Author

Checked the shared helper before switching to it: pluralFormsUtil.kt's populateForms (lines 33-40) builds its result set from getPluralFormsForLocale(languageTag) alone, so for en (CLDR forms one, other) it drops any zero form the user actually wrote — which is the exact bug this PR fixes, and what AppleXliffFileExporterTest's new "keeps a zero plural form" test asserts against. My local version unions in forms.keys on top of the CLDR set specifically to keep that. Leaving the inline version as-is since delegating here would reintroduce the drop.

@munzzyy

munzzyy commented Jul 27, 2026

Copy link
Copy Markdown
Author

On the suggestion to reuse populateForms: that helper builds its form list from getPluralFormsForLocale(languageTag) on its own, so it keeps only the CLDR forms for the locale and drops any authored form CLDR doesn't list. For en that's the zero form this PR is trying to keep, so delegating back to it would undo the fix.

I kept the change inside the Apple exporter on purpose instead of widening the shared helper. Apple's stringsdict/xcstrings accept whatever forms the author wrote, but the other formats that call populateForms don't necessarily want CLDR-extra forms, and broadening the shared helper would change their output too. Happy to move this into an Apple-specific helper if you'd rather not inline it, but it shouldn't route back through populateForms as it stands.

@Anty0
Anty0 self-requested a review July 27, 2026 08:25
@Anty0

Anty0 commented Jul 28, 2026

Copy link
Copy Markdown
Member

Hi! Thanks for the PR ^^
Technically, no other plural forms are supported for the language, and per the ICU spec, it's undefined behavior. That's probably why we strip them.

For ICU, the proper way to handle the zero case is =0 rather than zero, since zero isn't guaranteed to mean only 0. I'm not sure whether Apple supports these explicit =N forms. I'll check with the rest of the team to see what they think about this change.

@munzzyy

munzzyy commented Jul 28, 2026

Copy link
Copy Markdown
Author

Checked the Apple side of this, since that's what the exporter feeds. Apple's plural docs use exactly this case as their worked example: English with an optional zero variant — "the English language only requires the one and other categories to represent plural forms, and zero is optional" — and the sample stringsdict carries zero -> "No homes found". That example only does anything if the zero variant renders for a count of 0 in English, so on Apple's side it's a documented optional category for every language rather than undefined behavior. And there's no =N syntax in stringsdict or string catalogs; zero is how that format spells the 0 case.

https://developer.apple.com/documentation/xcode/localizing-strings-that-contain-plurals

I think that's the reason to keep this scoped to the Apple XLIFF exporter: in ICU terms an authored zero for English is nonstandard, agreed — but the Apple target doesn't consume ICU, and stripping the form on export drops content that Xcode itself round-trips fine. Curious what the team comes back with.

@Anty0

Anty0 commented Jul 29, 2026

Copy link
Copy Markdown
Member

Hmm, in that case, we could probably keep all the standard categories (including zero) and filter out =N. On top of that, if the plural doesn't have zero but does have =0, we could fold it into zero. What do you think? I'll still check with colleagues, but this sounds like the best way to go. Also, there are two Apple formats. I wonder whether the other behaves differently or whether we should fold the changes into that format too.

@munzzyy

munzzyy commented Jul 29, 2026

Copy link
Copy Markdown
Author

That works, and it's a better shape than what I have.

Filtering =N is the part my patch currently gets wrong. I merge forms.keys wholesale, so an authored =0 goes straight into the XLIFF as a =0 quantity key, and that isn't one of the six Apple accepts. Restricting the merge to formKeywords fixes it.

Folding =0 into zero when there's no zero makes sense for the Apple targets specifically, since stringsdict and xcstrings have no way to express an exact match. zero is the closest thing the format has.

On the second format: populateForms is only called from AppleXliffExporter, so xcstrings and strings/stringsdict never hit the locale filter and don't strip an authored zero today. The =N half may still apply to them though. StringsdictWriter writes the form key straight into the plist as the quantity key, so a =0 would land there verbatim. I'd rather add a test and see what those two actually emit than assume it.

Happy to implement it the way you describe, with the =N filter and the =0 fold, and to put it somewhere shared so all the Apple formats get the same treatment instead of just XLIFF. Want me to push that onto this PR?

@munzzyy

munzzyy commented Jul 31, 2026

Copy link
Copy Markdown
Author

Pushed the shape we talked through. populateForms now keeps the locale's CLDR forms plus any named category the user actually wrote, and drops the =N keys instead of merging them in — so an authored =0 no longer lands in the XLIFF as a =0 quantity. When a plural has =0 but no explicit zero, it comes out as zero, since that's the nearest thing stringsdict/xcstrings can say. Added a test for that fold next to the existing zero-form one.

No rush on your end — if the team lands somewhere different after you've talked it over, I'm happy to reshape it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/AppleXliffFileExporterTest.kt (1)

311-324: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the required TestData fixture flow.

Both tests create export views directly. Use a TestData class, save it with testDataService, and clean it in @AfterEach.

As per coding guidelines, “Use TestData classes for test setup following the pattern: create TestData instance, save with testDataService, and clean in @AfterEach.”

Also applies to: 356-369

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/AppleXliffFileExporterTest.kt`
around lines 311 - 324, Update both affected tests to use the required TestData
setup flow: create the appropriate TestData instance, persist it through
testDataService before obtaining the exporter, and remove the direct export-view
construction. Add or update `@AfterEach` cleanup to remove the saved fixture using
the existing test cleanup pattern.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt`:
- Around line 246-247: Update the zero-form handling in the Apple XLIFF plural
export logic to check whether forms contains an explicit "zero" entry, rather
than whether result already contains "zero"; this must allow authored "=0" to
replace the CLDR zero fallback while preserving explicit zero forms. Add a
regression test using a locale with CLDR zero to verify "=0" maps to zero when
no explicit zero form exists.

---

Nitpick comments:
In
`@backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/AppleXliffFileExporterTest.kt`:
- Around line 311-324: Update both affected tests to use the required TestData
setup flow: create the appropriate TestData instance, persist it through
testDataService before obtaining the exporter, and remove the direct export-view
construction. Add or update `@AfterEach` cleanup to remove the saved fixture using
the existing test cleanup pattern.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 35187cf8-82c9-43e1-b59f-36e9faa22b6e

📥 Commits

Reviewing files that changed from the base of the PR and between a675d8c and 8c1227a.

📒 Files selected for processing (2)
  • backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt
  • backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/AppleXliffFileExporterTest.kt

Comment on lines +246 to +247
if ("zero" !in result) {
forms["=0"]?.let { result["zero"] = it }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Check for an explicit zero form in forms.

For a locale with CLDR zero, result already contains zero from getPluralFormsForLocale. The current condition then drops an authored =0 and retains the other fallback. Check forms instead, so =0 replaces the fallback when no explicit zero exists. Add a regression test with a locale that has CLDR zero.

Proposed fix
-    if ("zero" !in result) {
+    if ("zero" !in forms) {
       forms["=0"]?.let { result["zero"] = it }
     }

The PR objective requires =0 to map to zero when no explicit zero form exists.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ("zero" !in result) {
forms["=0"]?.let { result["zero"] = it }
if ("zero" !in forms) {
forms["=0"]?.let { result["zero"] = it }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt`
around lines 246 - 247, Update the zero-form handling in the Apple XLIFF plural
export logic to check whether forms contains an explicit "zero" entry, rather
than whether result already contains "zero"; this must allow authored "=0" to
replace the CLDR zero fallback while preserving explicit zero forms. Add a
regression test using a locale with CLDR zero to verify "=0" maps to zero when
no explicit zero form exists.

@munzzyy munzzyy closed this Aug 10, 2026
@munzzyy
munzzyy force-pushed the cole-munz/apple-xliff-plural-zero branch from 8c1227a to f14e1b8 Compare August 10, 2026 17:01
Apple XLIFF export dropped any plural form that CLDR doesn't define
for the target locale, so an authored zero case (e.g. English
zero/one/other) never made it into the exported stringsdict/xcstrings.
AppleXliffExporter now keeps every form the user actually wrote in
addition to the locale's CLDR forms, matching what the xcstrings
exporter already does.

Fixes tolgee#3074
populateForms merged every authored form key wholesale, so an ICU =N exact
match (e.g. =0) went into the XLIFF as a =N quantity key, which the Apple
plural formats don't accept. Keep the locale's CLDR forms plus any authored
named category (zero/one/two/few/many/other) and drop the =N keys. When a
plural has an =0 but no explicit zero, surface it as zero, which is the
closest thing stringsdict/xcstrings can express.
@munzzyy munzzyy reopened this Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Apple XLIFF plurals are not functioning

2 participants