fix: use the right plural forms on Android instead of the English ones

Hermes has no `Intl.PluralRules`, so `src/i18n/format.ts` fell back to
the English rule (1 is "one", everything else "other") for every
language. Russian showed "5 письма" instead of "5 писем", Polish,
Czech, Arabic and the other languages with more than two forms were
wrong in the same way, and French treated 0 as plural.

Add the CLDR 48 cardinal rules for the app's 27 locales and use them
whenever `Intl.PluralRules` is missing or rejects the locale. Engines
that have it keep using it; a test checks the built-in rules against
it for every locale.
This commit is contained in:
Linus Rath
2026-09-24 19:01:24 +02:00
parent 2511df1059
commit e6549d31da
2 changed files with 222 additions and 6 deletions
+96 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { formatMessage, pluralCategory } from '../format';
import { describe, it, expect, vi } from 'vitest';
import { builtinPluralCategory, formatMessage, pluralCategory } from '../format';
describe('formatMessage', () => {
it('substitutes simple arguments', () => {
@@ -66,3 +66,97 @@ describe('pluralCategory', () => {
expect(pluralCategory(7, 'zz-not-a-locale')).toBe('other');
});
});
// The app's locales (src/i18n/index.ts SUPPORTED_LOCALES).
const APP_LOCALES = [
'ar', 'ca', 'cs', 'sk', 'da', 'de', 'en', 'fa', 'es', 'fr', 'he', 'it', 'hu', 'lv',
'nl', 'nb', 'pl', 'pt', 'ro', 'tr', 'ru', 'uk', 'ko', 'ja', 'mn', 'zh', 'zh-TW',
];
describe('builtinPluralCategory (Hermes has no Intl.PluralRules)', () => {
// Sample numbers from the CLDR language plural rules chart.
const CLDR_EXAMPLES: Record<string, Partial<Record<string, number[]>>> = {
ru: { one: [1, 21, 31, 101, 1001], few: [2, 3, 4, 22, 24, 102, 1002], many: [0, 5, 11, 12, 14, 19, 20, 25, 100, 111, 1000], other: [0.5, 1.5, 10.1] },
uk: { one: [1, 21, 101], few: [2, 4, 22, 104], many: [0, 5, 11, 14, 20, 100, 1000], other: [0.5, 1.5] },
pl: { one: [1], few: [2, 3, 4, 22, 24, 102, 1002], many: [0, 5, 11, 12, 14, 19, 21, 25, 100, 112, 1000], other: [0.5, 1.5, 10.1] },
cs: { one: [1], few: [2, 3, 4], many: [0.5, 1.5, 10.1], other: [0, 5, 19, 100, 1000] },
sk: { one: [1], few: [2, 3, 4], many: [0.5, 1.5], other: [0, 5, 100] },
lv: { zero: [0, 10, 11, 19, 20, 30, 100, 1000, 0.11], one: [1, 21, 31, 101, 0.1, 1.1, 0.01, 0.21], other: [2, 9, 22, 29, 102, 0.2, 0.02, 1.5] },
ar: { zero: [0], one: [1], two: [2], few: [3, 10, 103, 110, 1003], many: [11, 26, 99, 111, 1011], other: [100, 101, 102, 200, 1000, 0.1, 1.5] },
he: { one: [1, 0.5, 0.1], two: [2], other: [0, 3, 10, 17, 20, 100, 1000, 1.5] },
ro: { one: [1], few: [0, 2, 16, 19, 101, 119, 1001, 0.5, 1.5], other: [20, 35, 100, 120, 1000] },
fr: { one: [0, 1, 1.5], many: [1000000, 2000000], other: [2, 17, 100, 1000, 10000] },
es: { one: [1], many: [1000000], other: [0, 2, 16, 100, 1000, 0.5, 1.5] },
it: { one: [1], many: [1000000], other: [0, 2, 16, 100, 1.5] },
ca: { one: [1], many: [1000000], other: [0, 2, 16, 100, 1.5] },
pt: { one: [0, 1, 0.5, 1.5], many: [1000000], other: [2, 17, 100, 1000] },
da: { one: [1, 0.1, 1.6], other: [0, 2, 16, 100, 2.5] },
fa: { one: [0, 1, 0.5], other: [2, 17, 100, 1.5] },
de: { one: [1], other: [0, 2, 16, 100, 1.5] },
en: { one: [1], other: [0, 2, 16, 100, 1.5] },
nl: { one: [1], other: [0, 2, 1.5] },
hu: { one: [1], other: [0, 2, 1.5] },
nb: { one: [1], other: [0, 2, 1.5] },
tr: { one: [1], other: [0, 2, 1.5] },
mn: { one: [1], other: [0, 2, 1.5] },
ko: { other: [0, 1, 2, 1.5] },
ja: { other: [0, 1, 2, 1.5] },
zh: { other: [0, 1, 2, 1.5] },
'zh-TW': { other: [0, 1, 2, 1.5] },
};
for (const [locale, categories] of Object.entries(CLDR_EXAMPLES)) {
it(`follows the CLDR examples for ${locale}`, () => {
for (const [category, samples] of Object.entries(categories)) {
for (const n of samples ?? []) {
expect(`${n} → ${builtinPluralCategory(n, locale)}`).toBe(`${n} → ${category}`);
}
}
});
}
it('covers every app locale', () => {
expect(Object.keys(CLDR_EXAMPLES).sort()).toEqual([...APP_LOCALES].sort());
});
it('agrees with the engine Intl.PluralRules for every app locale', () => {
const samples: number[] = [];
for (let n = 0; n <= 1200; n++) samples.push(n);
samples.push(1000000, 2000000, 1234567, 10000000, -1, -2, -5);
for (let d = 0; d <= 40; d++) samples.push(d / 10, d / 100 + 1, d / 100 + 20);
const mismatches: string[] = [];
for (const locale of APP_LOCALES) {
const intl = new Intl.PluralRules(locale);
for (const n of samples) {
const expected = intl.select(n);
const actual = builtinPluralCategory(n, locale);
if (actual !== expected) mismatches.push(`${locale} ${n}: ${actual}, Intl says ${expected}`);
}
}
expect(mismatches.slice(0, 20)).toEqual([]);
});
it('uses the English rule for languages it has no rule for', () => {
expect(builtinPluralCategory(1, 'xx')).toBe('one');
expect(builtinPluralCategory(2, 'xx')).toBe('other');
});
});
describe('formatMessage without Intl.PluralRules', () => {
it('picks the plural form from the built-in rules', async () => {
const intl = Intl as unknown as { PluralRules: unknown };
const original = intl.PluralRules;
intl.PluralRules = undefined;
try {
vi.resetModules();
const fresh = await import('../format');
const msg = '{count, plural, one {# письмо} few {# письма} many {# писем} other {# письма}}';
expect(fresh.formatMessage(msg, { count: 2 }, 'ru')).toBe('2 письма');
expect(fresh.formatMessage(msg, { count: 5 }, 'ru')).toBe('5 писем');
expect(fresh.formatMessage(msg, { count: 21 }, 'ru')).toBe('21 письмо');
expect(fresh.pluralCategory(0, 'fr')).toBe('one');
} finally {
intl.PluralRules = original;
}
});
});
+126 -4
View File
@@ -30,10 +30,132 @@ function pluralRulesFor(locale: string): Intl.PluralRules | null {
return rules;
}
// ─── Built-in CLDR cardinal rules ─────────────────────────────────────────
// Hermes ships no Intl.PluralRules, so without these every locale would get
// the English one/other split ("2 писем" instead of "2 письма"). These are the
// CLDR 48 cardinal rules (unicode.org/cldr/charts/latest/supplemental/
// language_plural_rules.html) for the app's locales, written out from the
// rule text. Operands as in UTS #35: n absolute value, i integer digits,
// v number of visible fraction digits, f visible fraction digits (with
// Intl's default formatting there are no trailing zeros, so t = f).
interface PluralOperands {
n: number;
i: number;
v: number;
f: number;
}
function pluralOperands(value: number): PluralOperands {
// Intl.PluralRules formats with at most three fraction digits by default.
const n = Number(Math.abs(value).toFixed(3));
if (Number.isInteger(n)) return { n, i: n, v: 0, f: 0 };
const [int, frac] = String(n).split('.');
return { n, i: Number(int), v: frac.length, f: Number(frac) };
}
// CLDR ranges (`a..b`) only match integers: 3.5 is not in 3..10.
function inRange(x: number, lo: number, hi: number): boolean {
return Number.isInteger(x) && x >= lo && x <= hi;
}
type PluralRule = (o: PluralOperands) => PluralCategory;
// "many" for whole millions (fr "1 million de …"): e = 0 and i != 0 and
// i % 1000000 = 0 and v = 0. Plain numbers never use compact exponents.
const isMillions = ({ i, v }: PluralOperands) => v === 0 && i !== 0 && i % 1000000 === 0;
const oneForIntegerOne: PluralRule = ({ i, v }) => (i === 1 && v === 0 ? 'one' : 'other');
const oneForOne: PluralRule = ({ n }) => (n === 1 ? 'one' : 'other');
const otherOnly: PluralRule = () => 'other';
// ru, uk
const eastSlavic: PluralRule = ({ i, v }) => {
if (v !== 0) return 'other';
if (i % 10 === 1 && i % 100 !== 11) return 'one';
if (inRange(i % 10, 2, 4) && !inRange(i % 100, 12, 14)) return 'few';
return 'many';
};
// cs, sk
const czechSlovak: PluralRule = ({ i, v }) => {
if (v !== 0) return 'many';
if (i === 1) return 'one';
if (inRange(i, 2, 4)) return 'few';
return 'other';
};
const PLURAL_RULES: Record<string, PluralRule> = {
ar: ({ n }) => {
if (n === 0) return 'zero';
if (n === 1) return 'one';
if (n === 2) return 'two';
if (inRange(n % 100, 3, 10)) return 'few';
if (inRange(n % 100, 11, 99)) return 'many';
return 'other';
},
ca: (o) => (o.i === 1 && o.v === 0 ? 'one' : isMillions(o) ? 'many' : 'other'),
cs: czechSlovak,
sk: czechSlovak,
da: ({ n, i, f }) => (n === 1 || (f !== 0 && (i === 0 || i === 1)) ? 'one' : 'other'),
de: oneForIntegerOne,
en: oneForIntegerOne,
fa: ({ n, i }) => (i === 0 || n === 1 ? 'one' : 'other'),
es: (o) => (o.n === 1 ? 'one' : isMillions(o) ? 'many' : 'other'),
fr: (o) => (o.i === 0 || o.i === 1 ? 'one' : isMillions(o) ? 'many' : 'other'),
he: ({ i, v }) => {
if ((i === 1 && v === 0) || (i === 0 && v !== 0)) return 'one';
if (i === 2 && v === 0) return 'two';
return 'other';
},
it: (o) => (o.i === 1 && o.v === 0 ? 'one' : isMillions(o) ? 'many' : 'other'),
hu: oneForOne,
lv: ({ n, v, f }) => {
if (n % 10 === 0 || inRange(n % 100, 11, 19) || (v === 2 && inRange(f % 100, 11, 19))) return 'zero';
if (
(n % 10 === 1 && n % 100 !== 11)
|| (v === 2 && f % 10 === 1 && f % 100 !== 11)
|| (v !== 2 && f % 10 === 1)
) return 'one';
return 'other';
},
nl: oneForIntegerOne,
nb: oneForOne,
pl: ({ i, v }) => {
if (v !== 0) return 'other';
if (i === 1) return 'one';
if (inRange(i % 10, 2, 4) && !inRange(i % 100, 12, 14)) return 'few';
return 'many';
},
pt: (o) => (o.i === 0 || o.i === 1 ? 'one' : isMillions(o) ? 'many' : 'other'),
ro: ({ n, i, v }) => {
if (i === 1 && v === 0) return 'one';
if (v !== 0 || n === 0 || (n !== 1 && inRange(n % 100, 1, 19))) return 'few';
return 'other';
},
tr: oneForOne,
ru: eastSlavic,
uk: eastSlavic,
ko: otherOnly,
ja: otherOnly,
mn: oneForOne,
zh: otherOnly,
};
/**
* CLDR plural category for `n` in `locale`. Falls back to the English rule
* (1 → one, everything else → other) when Intl.PluralRules is unavailable
* (older Hermes builds) or rejects the locale.
* Plural category from the built-in CLDR rules above, by language subtag
* (`zh-TW` → `zh`). Languages without a rule get the English one.
*/
export function builtinPluralCategory(n: number, locale: string): PluralCategory {
const language = locale.toLowerCase().split(/[-_]/)[0];
const rule = PLURAL_RULES[language] ?? oneForIntegerOne;
return rule(pluralOperands(n));
}
/**
* CLDR plural category for `n` in `locale`. Uses Intl.PluralRules where the
* engine has it and the built-in rules where it doesn't (Hermes) or rejects
* the locale.
*/
export function pluralCategory(n: number, locale: string): PluralCategory {
const rules = pluralRulesFor(locale);
@@ -44,7 +166,7 @@ export function pluralCategory(n: number, locale: string): PluralCategory {
// fall through
}
}
return n === 1 ? 'one' : 'other';
return builtinPluralCategory(n, locale);
}
// Find the index of the `}` that closes the `{` at `open`, honouring nesting.