Skip to content

Fix Language and Plural-Forms headers written by waGettextParser - #390

Open
SergeR wants to merge 1 commit into
webasyst:devfrom
SergeR:gettext-plural-forms
Open

Fix Language and Plural-Forms headers written by waGettextParser#390
SergeR wants to merge 1 commit into
webasyst:devfrom
SergeR:gettext-plural-forms

Conversation

@SergeR

@SergeR SergeR commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

The problem

php wa.php locale <slug> produces .po files with two defects. Both are invisible on ru_RU, which is why they went unnoticed, and both hit every other locale.

Language: is never written

getMeta() builds the header of a new catalog. It writes X-Poedit-Language, a Poedit field, but not Language: — the standard header gettext has used since 0.18. The template looks like it was copied from Poedit output older than that.

Existing catalogs are no better: getOldMeta() rewrites the keys it finds in the file, and a key that was never there does not appear by itself, however many times the catalog is regenerated.

This affects all locales, ru_RU included. msgfmt --check complains about it.

The header and the body disagree on how many plural forms there are

getPluralByLocale() was a single if: ru_RU got the three-form Slavic rule, everything else got nplurals=2; plural=(n != 1). getPluralsText() meanwhile wrote plural forms like this:

for ($i = 0; $i < 3; $i++) {

Always exactly three, whatever the header said. Two symptoms follow:

  • en_US, de_DE, fr_FR, nl_NL, kk_KZ — the rule is right for them, but the header declares two forms while the body holds three. msgfmt rejects the file: number of plural forms in 'msgstr' doesn't match the number specified in 'Plural-Forms'. The catalog does not compile at all.
  • uk_UA, be_BY, pl_PL — three forms in the body is what they need, so the file compiles, and the damage moves to runtime. The header claims two forms with (n != 1), so the third form is unreachable and the first two are bounded as "1 / everything else" instead of "1 / 2–4 / 5+". The user sees the Russian equivalent of "5 файла".

ru_RU was the only locale where the header and the body agreed.

Reproduction on the current dev, from the root of an installation:

php -r '
require "wa-system/autoload/waAutoload.class.php";
waAutoload::register();
require "wa-system/helper/misc.php";
class E implements waLocaleParseEntityInterface {
  public function getLocales(){return ["nl_NL"];}
  public function getMessages(){return [];}
  public function getAdditionalMessages(){return [];}
  public function getLocalePath(){return sys_get_temp_dir();}
  public function getDomain(){return "t";}
  public function getProject(){return "t";}
  public function preSave(&$m,$l){}
}
class P extends waGettextParser {
  public function pl($l){return $this->getPluralByLocale($l);}
  public function pt($id,$s,$c,$p){return $this->getPluralsText($id,$s,$c,$p);}
}
$p = new P(new E());
echo trim($p->pl("nl_NL")), PHP_EOL;
echo substr_count($p->pt("%d file",[],null,"%d files"), "msgstr["), PHP_EOL;
'
Plural-Forms: nplurals=2; plural=(n != 1);\n
3

The header promises two forms, the generator writes three.

The fix

Plural rules move into the locale data files. A new plural_forms key in wa-system/locale/data/{locale}.php, alongside everything else the framework knows about a locale:

'plural_forms' => array(
    'nplurals' => 3,
    'plural' => '((((n%10)==1)&&((n%100)!=11))?(0):(...))'
),

The parser keeps no language table of its own. It reads the rule through waLocale::getInfo() and falls back to a single default (nplurals=2; plural=(n != 1)) for a locale with no data file — a default, not a second copy of the data.

The number of forms in the body comes from the header that actually goes into the file. getPluralsText() receives it from the same prepared metadata getOldMeta() renders, so the two cannot drift apart again — which is the failure mode that produced this bug in the first place.

Language: is added to getMeta()'s template, and getOldMeta() now fills it in for existing catalogs. That part needs no data file and works for any locale.

Six locale data files are addeduk_UA, be_BY, pl_PL, kk_KZ, nl_NL, ka_GE. Without them the fix cannot reach the locales that need it most: uk_UA and be_BY would still fall back to the two-form default.

Two rules here are easy to get wrong and are worth a look:

  • Polish has three forms, but not the Slavic rule — its first form is used for n == 1 only, so 21 takes the third form (21 plików), where Russian takes the first (21 файл).
  • French has two forms, but (n > 1), not (n != 1): zero is singular in French (0 fichier).

Existing headers are respected. A catalog's Plural-Forms is replaced only when it declares fewer forms than the locale has — precisely the catalogs the old generator broke. A header declaring enough forms is left alone, so a rule a translator corrected by hand survives regeneration, and so does a locale the framework ships no data file for. Repairing a broken uk_UA catalog keeps its third translation rather than truncating it.

ru_RU is untouched by design. The generated Plural-Forms line is byte-identical to the one every existing Russian catalog already carries; there is a test asserting exactly that.

Two smaller things in the same files

  • waLocale::format() read $locale_info['frac_digits'] directly while the two keys next to it on the same line already went through ifset(). Locale data files on installations in the wild have unknown shape — and es_ES.php / fr_FR.php in this repository did not carry the key either, so that is fixed too, along with the other number-formatting keys those files were missing.
  • getMeta() emitted X-Poedit-Basepath: utf-8: the charset was interpolated where $meta['basepath'] was meant. The key was defined and unused.

Happy to drop either of these into a separate PR if you would rather keep this one narrow.

Compatibility

  • ru_RU catalogs: no change, byte for byte.
  • Catalogs already carrying a valid Plural-Forms: no change unless it declares too few forms for the language.
  • Locales without a data file: Language: starts being written, plural handling falls back to the previous two-form default, and any hand-written rule is preserved.
  • The six new data files add six entries to the locale list offered by waContactLocaleField when options['all'] is set. waLocale::getAll() filters by wa-config/locale.php by default, so interface language selection and waRequest::getLocale() are unaffected.

Tests

The repository has no PHPUnit setup, so I have not added one here. The suite lives in a gist instead, same as for #380 — link in a comment below. It covers both files end to end, parametrised over every locale in wa-system/locale/data/, and fails 30 of its 122 tests against the unpatched parser.

🤖 Generated with Claude Code

The .po generator behind `php wa.php locale <slug>` had two independent bugs,
both invisible on ru_RU and hitting every other locale.

1. The `Language:` header was never written. getMeta()'s template carries
   `X-Poedit-Language` but not the standard header gettext has used since 0.18,
   and getOldMeta() only rewrites keys already found in the file, so no amount
   of regeneration adds it. msgfmt --check complains about this on every locale,
   ru_RU included.

2. getPluralByLocale() knew a single language: ru_RU got the three-form Slavic
   rule, everything else got `nplurals=2; plural=(n != 1)`. getPluralsText(),
   meanwhile, always wrote exactly three `msgstr[]`. So for two-form locales the
   header and the body disagreed and msgfmt refused the catalog outright
   ("number of plural forms in 'msgstr' doesn't match ..."), while uk_UA and
   be_BY compiled but got Russian's count with English's rule - the third form
   unreachable, the first two wrongly bounded.

Plural rules now live in wa-system/locale/data/{locale}.php under a new
`plural_forms` key, next to the rest of what the framework knows about a locale.
The parser holds no language table: it reads the rule through waLocale::getInfo()
and falls back to a single default for locales without a data file. The number
of forms written to the body is taken from the very header that goes into the
file, so the two cannot drift apart again.

Locale data files are added for uk_UA, be_BY, pl_PL, kk_KZ, nl_NL and ka_GE -
without them the fix cannot reach the locales that need it most. Note that
Polish shares neither its rule with the other Slavic languages (its first form
is used for 1 only) nor French with the other two-form ones (zero is singular
in French).

An existing `Plural-Forms` header is replaced only when it declares fewer forms
than the locale has, which is exactly the catalogs broken by the bug above; a
header declaring enough forms is left alone, so both a rule corrected by hand
and a locale with no data file keep working. ru_RU catalogs are unaffected: the
generated header is byte-identical to the previous one.

Also in wa-system/locale:

* waLocale::format() read `frac_digits` without ifset() while the two keys next
  to it on the same line were already guarded. Locale data files in the wild
  have unknown shape, and es_ES/fr_FR in this repository did not carry the key
  either, which is now fixed as well.
* getMeta() wrote `X-Poedit-Basepath: utf-8`, having interpolated the charset
  where the basepath was meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SergeR

SergeR commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Tests for this PR, kept out of the diff: https://gist.github.com/SergeR/fe435c48cc806fc195027e991c07f9e6

Same arrangement as #380: a throwaway tests/ folder at the repo root, a gitignored PHPUnit phar fetched by a script, no Composer and nothing permanent added to the repository. The README in the gist has the file layout and the commands.

122 tests, 7567 assertions, and 30 of them fail against waGettextParser as it is on dev — the suite is written to catch this bug, not just to describe the fix.

What it exercises:

  • the generator run end to end into a temp directory, parametrised over every locale in wa-system/locale/data/: header nplurals equals the number of msgstr[] lines written, Language: present and matching, regeneration byte-stable, Plural-Forms unchanged through a getMessagesMetaPlurals() / getOldMeta() round trip;
  • ru_RU header byte-identical to the current one;
  • a catalog broken by the old generator gets repaired without losing its third translation;
  • a hand-written rule for a locale with no data file survives regeneration;
  • every plural expression evaluated for n = 0..200 the same way waGettext::meta2array() evaluates it at runtime — no rule may select a form outside its declared nplurals, and every declared form must be reachable;
  • Polish is not Russian (n=21 lands on different forms), French counts zero as singular, Georgian declares two forms.

Not proposing PHPUnit as a dependency — this is just so the behaviour can be checked locally. Happy to reshape it if the project adopts a format of its own.

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.

1 participant