Problem/Motivation

TranslationManager::selectPluralForm() and Drupal.formatPlural() use different logic so pick different variants:

Language Count Variant index picked by PHP Variant index picked by JavaScript
Javanese (n != 0) 1 🔴 0 🟢 1
Russian 201 🟢 0 🔴 2
Polish 202 🟢 1 🔴 2
Polish, untranslated 5 🟢 1 🔴 undefined
Any language, missing variant 1 2 🟢 0 🔴 undefined
Any language, empty variant 1 2 🔴 empty 🔴 empty
French (n > 1) 1.5 🟢 0 🔴 1
No formula "1" 🟢 0 🔴 1
  • PHP returns variant 0 for 1 without the formula (was #3496223: Plural Formula gets ignored for Singular Case for languages where Singular Form is not found in the first index, closed as duplicate to solve this more complete here).
  • The formula table has 0 to 199. Above 199, PHP uses 100 + n % 100, JavaScript the default entry, which is quite wrong.
  • For missing variants (can happen due to translation inconsistencies), PHP returns variant 1, or 0 for one variant, JavaScript undefined.
  • Both return empty text as-is for empty variants, which the translation form saves for empty fields where the translator did not provide the variant.
  • For non-integer numbers, PHP uses the integer part, like gettext, JavaScript uses the default entry instead.
  • Without a formula, JavaScript uses count !== 1, PHP also allows count "1", the comparison is not strict.

Proposed resolution

Unify how the logic works between the two programming languages, of course picking the correct decision in each case.

Add tests for all of these cases for both PHP and JS.

LLM disclosure

Generated with the help of an LLM.

Issue fork drupal-3631950

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

gábor hojtsy created an issue. See original summary.

gábor hojtsy’s picture

Issue summary: View changes
gábor hojtsy’s picture

Status: Active » Needs review
gábor hojtsy’s picture

Issue summary: View changes

Simplify table to make it easier to see where is wrong :)

gábor hojtsy’s picture

Issue summary: View changes
d34dman’s picture

@gábor hojtsy,

One thought on what we test against. The MR makes JS follow PHP, but I think both should follow GNU gettext.

A Plural-Forms formula is written for gettext's evaluator: a C-like expression with C precedence, evaluated on an unsigned integer n. Whoever wrote the formula, whether a translation team, the gettext manual or a 3rd party reference list, expected ngettext() behaviour. So for each row in the summary table, the question is "what would ngettext() return?" and not "what does PHP do today?". There is an arguably better ux here for both being equally wrong, but lets do better.

That reference settles several rows on its own:
- Fractional counts are truncated (1.5 counts as 1).
- With no formula, the default is n != 1.
- Javanese (n != 0) with n = 1 gives variant 1, so JS was right there and the old $count == 1 shortcut in PHP was wrong.

I ran into this while writing string_plural_form. It ships 26 plural formulas as plugins, and I checked each one against gettext, for example the Breton test: https://git.drupalcode.org/project/string_plural_form/-/blob/1.x/tests/U...

I compared all 26 formulas against gettext for core and for this MR. Details follow in the next comment (AI Assisted research).

d34dman’s picture

LLM disclosure

Generated with the help of an LLM

Investigation: core plural selection against GNU gettext

Method. I took the 26 plural formulas from string_plural_form 1.x. Each one went into a .po header, was compiled with msgfmt, and was evaluated with libintl's own dngettext() (GNU gettext-runtime 1.0). That is gettext's real evaluator, not a port. I used every count from 0 to 1199 plus large values (1000, 10000, 100000, 1000000, 2000000, … up to 109). I compared the results with three implementations:

  • core main in PHP: TranslationManager::selectPluralForm() with locale's getPluralIndex(), using the formula table from PoHeader::parsePluralForms()
  • core main in JS: Drupal.formatPlural()
  • core with MR !17514 applied

As a control, string_plural_form's hand-ported getIndex() matches gettext for all 26 formulas and every count tested.

Core JS today is wrong above 199 for 15 of the 26 formulas, because it uses the default entry. Example: Russian 201 gives 2 instead of 0. The MR fixes this.

Core PHP today is wrong at n = 1 for Arabic, Welsh, Cornish and Kölsch, where index 0 is the "zero" form and index 1 is "one". The $count == 1 shortcut returns 0. The MR fixes this, because it drops the shortcut.

With the MR applied, 4 formulas still disagree with gettext. All of them come from the 100 + n % 100 fallback above 199:

Formula (langcodes) n gettext With MR
Breton (br)
… : ((n != 0 && n % 1000000 == 0) ? 3 : 4)
1000000, 2000000, 109, … 3 4
Millions "many" form (ca, es, it, pt)
(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2)
1000000, 2000000, … 1 2
Same, French variant (fr)
(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2)
1000000, 2000000, … 1 2
Cornish (kw)
… n % 1000 == 0 && (n % 100000 >= 1000 && n % 100000 <= 20000 || …) …
1000, 2000, 10000, 20000, 100000, … 2 5

The comment in PoHeader::parsePluralForms() says "All known plural formula's are repetitive above 100". That holds for the classic formulas, but not for the CLDR-derived three-form formulas that have a separate "many" form for millions. A site using one of those formulas shows the wrong variant for "1 000 000 items", in both PHP and JS.

Suggestions

  1. Use gettext as the reference in the tests. Generate the expected vectors once with libintl, then have both the PHP unit test and the JS test assert against the same fixture. Testing that "JS matches PHP" only checks that the two agree, not that they are correct. I'm happy to provide the fixture and the small script that generates it.
  2. Treat the >199 fallback as a follow-up, separate from PHP/JS parity: either evaluate the parsed formula for counts outside the table, or store more than 0–199. It doesn't have to block this MR. The new JS comment about the 0–199 table should not suggest that the fallback is exact, though.