Skip to content

i18n: languages with more than two plural forms cannot be translated #12106

Open

Description

@smwbev

Summary

Languages with more than two plural forms cannot be translated correctly today. Russian needs four (one / few / many / other); every plural site in the renderer can express two. This is not a catalog gap — the selection happens in JavaScript, so no translation can fix it.

Two independent causes, both measurable.

1. Call sites pick the form in JS, not through i18next

Every plural family in the renderer is resolved with a ternary:

// ComputerUsePane.tsx
: missingCount === 1
  ? translate('…permissionsRequired_one', '1 permission required…')
  : translate('…permissionsRequired_other', '{{value0}} permissions…', { value0: missingCount })

All 7 plural families follow this shape (found_theme, import_theme, imported, over_limit, fullCoverage, permissionsRequired, plus the one in WarpThemeImportModal). None passes count, so i18next's CLDR selection never runs and _few / _many are unreachable regardless of what a catalog provides.

This is worth flagging because config/i18n-translation-source.md assumes the opposite:

Plurals use Orca's existing per-key convention: each i18next plural-suffixed key (…_one, …_other) is its own PO entry. The profile must not use msgid_plural/msgstr[n], which would collide with i18next's own CLDR plural selection.

The PO profile is designed around a selector that is currently bypassed. Worth reconciling before PR B lands, since the migration inherits whatever convention the call sites use.

2. Plugin language packs never reach CLDR rules

Even after a call site passes count, plugin catalogs still get English plural behaviour. They register under the synthetic plugin<hex> resource language from pluginLanguageResourceId(), which i18next cannot map to CLDR. Measured against a real catalog containing all four Russian forms:

count ru plugin<hex>
1 1 разрешение 1 разрешение
2 2 разрешения 2 разрешения
5 5 разрешений ✅ 5 разрешения ❌

At 5 the synthetic language falls back to _other instead of _many.

Why this matters beyond correctness

The current shape produced real defects in shipped catalogs. Before #11826, permissionsRequired used suffix interpolation, and the translations read:

ko: 'agents가 앱 창을 조작하려면 먼저 {{value0}} 권한{{value1}}이 필요합니다.'
zh: '在智能体可操作应用窗口前需要 {{value0}} 项权限{{value1}}。'

Korean and Chinese have no plural suffix, so every count above one rendered 권한s이 and 权限s — an English s spliced into the sentence. That one is fixed, but the pattern that produced it is still the default.

Suggested direction

Not sending a PR for this: it changes a convention used across the renderer, and the right shape depends on how PR B wants to model plurals. Two pieces, in order:

  1. Route plural resolution through the declared locale. getIntlLocale() (proposed in fix(i18n): format the stats date with the selected UI language #12105) already maps a plugin pack's synthetic tag back to its BCP-47 locale. Feeding that to i18next's pluralResolver would fix every count-based call site at once, without touching them individually — suggested by @greptile-apps in review.
  2. Move call sites to count. Mechanical, 7 families, but only worth doing once (1) makes the extra forms reachable.

Happy to implement either once there is a preferred direction.

Environment

  • Orca main @ 0d38053 (v1.4.163)
  • Measured with i18next as bundled, catalogs from src/renderer/src/i18n/locales/

Activity

  1. smwbev commented on Aug 4, 2026

    @smwbev
    ContributorAuthor

    A concrete instance of this came up today in #12478, from the built-in-catalog side rather than the plugin side.

    @nwparker asks there, as an open question for a maintainer:

    ja/ko/zh get identical _one and _other values because those languages don't inflect — required because the code picks the suffix. Confirm that matches how the localization team wants plural families filled.

    That is the same constraint this issue describes, seen from the other end. For ja/ko/zh the duplicate values are harmless and arguably the honest encoding. For a language with three or more CLDR categories it is a ceiling: Russian needs one (1, 21, 31…), few (2–4, 22–24…) and many (0, 5–20…), and a === 1 branch in the code can only express two of them — 2 сессии and 5 сессий are different words, so one of the two is always wrong. Polish, Czech, Ukrainian, Arabic and Welsh have the same shape.

    So the question is not only which of count versus a ternary the codebase prefers, but whether plural selection lives in the code at all. While it does, a plugin language pack cannot express the difference no matter what it puts in the catalog — the synthetic plugin<hex> resource language never reaches CLDR selection.

    Two more data points since this was filed:

    Happy to prototype whichever direction a maintainer prefers — including the null result, that plugin packs stay two-form by design and the constraint just gets documented for pack authors.

  2. nwparker commented on Aug 4, 2026

    @nwparker
    Contributor

    down for a prototype! 🙏

    Honestly down for either way. A is fine is perf isn't weird and we can ensure agents call the right thing. Otherwise B w/ machnical works as well. Not strong feelings TBH

  3. smwbev commented on Aug 4, 2026

    @smwbev
    ContributorAuthor

    Prototype for option (1) is up: #12505.

    It does what the issue proposed — feeds the locale a pack declares to the plural resolver, and only to the resolver, so resource lookup keeps running on the synthetic plugin<hex> tag. No call site changes; every count-based one starts selecting real categories.

    @nwparker — on your "if perf isn't weird" condition, the measurement went the other way and the reason is worth a look. PluralResolver.getRule writes its cache at the end of the method, but the no-separator fallback returns early:

    if (!code.match(/-|_/)) return dummyRule;   // never reaches the cache write

    A synthetic tag has no - or _, so today every single call constructs a fresh Intl.PluralRules over a 90-character invalid tag and catches the RangeError. Per getRule: 3.549 µs for plugin<hex> versus 0.129 µs for ru-RU, which is cached. End to end on t() with count, 200k calls: 15.249 → 3.580 µs/call.

    So the honest answer is that the hot path is throwing once per lookup today, and mapping the code to a real locale puts it back on the cached path. That is an i18next-side bug affecting any consumer with a non-BCP-47 resource language — happy to file it upstream separately if the reading looks right to you.

    Option (2), moving the seven ternary call sites to count, is deliberately not in that PR. It only pays off once (1) makes the extra categories reachable, and keeping them apart means (1) can be judged on its own.

  4. smwbev commented on Sep 9, 2026

    @smwbev
    ContributorAuthor

    A third pattern turned up while checking v1.4.198, and it is worse than the two above: the plural suffix is concatenated outside translate(), so the string is not merely limited to two forms — it cannot be translated at all.

    components/skills/SkillInstallReviewContent.tsx:84:

    {result.placements.length}{' '}
    {translate('auto.components.skills.SkillInstallReviewContent.3fc62a61eb', 'placement')}
    {result.placements.length === 1 ? '' : 's'}{' '}
    {translate('auto.components.skills.SkillInstallReviewContent.1b6ad2ca5c', 'checked.')}

    Both keys are in the catalog and look translatable. Translate placement into Russian and the rendered result is 3 размещениеs checked. — an English s welded onto a Russian noun. The only way a translator can avoid that is to leave the key in English, which is what our pack does today: these two keys are deliberately unfilled.

    Eight sites in six files share this shape in v1.4.198:

    File Line
    components/editor/ConflictComponents.tsx 331
    components/right-sidebar/checks-panel/conflict-summary.tsx 36
    components/right-sidebar/checks-panel/triage-strip.tsx 76, 114, 143
    components/right-sidebar/source-control/listing/section-header.tsx 46
    components/settings/McpConfigSection.tsx 305
    components/skills/SkillInstallReviewContent.tsx 84

    The codebase already has a shape that works. Fourteen other sites pass the suffix through the catalog as a placeholder:

    translate('…74f3063026', '{{value0}} site{{value1}} connected',
      { value0: sites.length, value1: sites.length === 1 ? '' : 's' })

    Here a translator can ignore {{value1}} and write Подключено сайтов: {{value0}}, which reads correctly for every count. Same two-form limitation as the ternary sites, but at least the sentence survives translation. Moving the eight sites above to this shape would cost little and would not require resolving the larger count/CLDR question in this issue.

    Separately, and outside this issue's scope: sixteen sites in nine files build the same N item(s) phrase in a bare template literal with no translate() at all — source-control-dropdown-labels.ts (5), linear-scope-selector.tsx:117, GhAuthErrorHelp.tsx:241 among them. Those are invisible to the coverage audit for the reason described in #11826, not a plural problem.

    Happy to send a PR for the eight sites if the placeholder shape is the direction you want.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions