ICU 複数形とプレースホルダー:gen-l10n が通してしまうもの

更新 · LocalePilot

flutter gen-l10n は不正な ICU メッセージを拒否しますが、プレースホルダーを失った翻訳や、ゼロのときに「1 件」と表示する複数形は黙って受け入れます。何がチェックされ、何がされないのか、そしてその穴を埋めるスクリプトを紹介します。

以下の例はすべて、Flutter 3.47.5 と flutter gen-l10n で、テンプレート app_en.arb とフランス語訳 app_fr.arb を使って検証しています。

お急ぎですか?言語ごとの複数形ブロックをブラウザでそのまま生成できます。無料・登録不要で、何もアップロードされません。

gen-l10n が確実に止めるもの

ICU 構文は翻訳ファイルも含め、すべてのファイルでチェックされます。other の分岐がない複数形は、ファイル名とエラーの位置を示して生成を止めます。

[app_fr.arb:itemsOne] ICU Syntax Error: Plural expressions must have an "other" case.
    {count, plural, one{{count} article}}
                    ^

閉じていない波括弧も同様です。ここまではすぐに気づけますが、厄介なのは次のケースです。

抜けたプレースホルダーは何も言われずに通る

英語のテンプレートはパラメータを宣言しています。

"welcomeUser": "Welcome back, {name}!",
"@welcomeUser": { "placeholders": { "name": { "type": "String" } } }

フランス語訳ではそれが失われています:"welcomeUser": "Bon retour parmi nous !"。生成は成功し、アナライザーも何も言わず、生成されたメソッドは引数を無視するだけです。

@override
String welcomeUser(String name) {
  return 'Bon retour parmi nous !';
}

クラッシュはしません。フランス語のユーザーに名前が表示されないだけです。意図的な場合(名前がないほうが自然な文になる)もありますが、たいていはミスであり、標準のツールではその 2 つを区別できません。各翻訳のプレースホルダーをテンプレートと比較する必要があります。そのためのスクリプトをこの後で紹介します。

名前を変えたプレースホルダーは全ロケールの API を変える

こちらはもっと奇妙です。翻訳がテンプレートにない名前、たとえば {count} の代わりに {nombre} を使っているとします。

"reminder": "Il reste {nombre} articles dans {listName}."

gen-l10n はやはり何も報告しません。nombre を余分なパラメータとして抽象クラスに追加するので、すべてのロケールに影響します。

String reminder(String listName, int count, Object nombre);

エラーが出るのは後になって、既存の呼び出し箇所をコンパイルするときです:Too few positional arguments: 3 required, 2 given. このエラーは原因となった ARB ファイルではなく、Dart のコードを指します。ローカライズメソッドに突然 Object パラメータが増えたら、翻訳の中からその名前を探してください。

フランス語では one がゼロも含む

one、few、many などのカテゴリは各言語の CLDR ルールに従います。英語では one は 1 だけを意味しますが、フランス語では 0 と 1 を意味します(日本語は other だけです)。次のメッセージの場合:

"itemsOne": "{count, plural, one{{count} article} other{{count} articles}}"
count英語フランス語
00 items0 article
11 item1 article
22 items2 articles

「0 article」はフランス語として正しいので、ここは問題ありません。落とし穴は別のところにあります。gen-l10n は =0 と =1 を Intl.pluralLogic の zero と one 引数に変換します。そのため =1 の分岐は one カテゴリになり、フランス語ではこのカテゴリがゼロにも適用されます。

"exactOne": "{count, plural, =1{1 article restant} other{{count} articles restants}}"

count = 0 のとき、英語は「0 items left」と表示しますが、フランス語は「1 article restant」(残り 1 件)と表示します。テキストが間違っているのに、何も検出されません。対策は、=0 の分岐を追加するか、one{{count} article restant} と書いて表示される数値が常に正しくなるようにすることです。one が 1 以外も含む言語はすべて同じ問題を抱えています。

アポストロフィと use-escaping

デフォルトでは、半角のアポストロフィはただの文字です:"Don't forget the milk" は問題なく動きます。l10n.yaml で use-escaping: true を有効にすると(波括弧をそのまま書くため)、アポストロフィは ICU のエスケープ文字になり、単独のアポストロフィで生成が止まります。

[app_en.arb:apostrophe] ICU Lexing Error: Unmatched single quotes.
    Don't forget the milk
       ^

その場合は二重にする(Don''t)か、エスケープの影響を受けないタイポグラフィ用のアポストロフィ ’ を使います。既存のプロジェクトでこのオプションを切り替えると、すべての言語を見直すことになります。英語の短縮形やフランス語のエリジオンで手間がかかるでしょう。

CI に追加する価値のあるチェック

警告が出ないのは、抜けたプレースホルダーのケースです。このスクリプトはテンプレートで宣言されたプレースホルダーを読み込み、各翻訳がそれを使い続けているかをチェックします。

import 'dart:convert';
import 'dart:io';

void main() {
  final template = _read('lib/l10n/app_en.arb');
  var errors = 0;
  for (final file in Directory('lib/l10n').listSync().whereType<File>()) {
    if (!file.path.endsWith('.arb') || file.path.endsWith('app_en.arb')) continue;
    final translation = _read(file.path);
    for (final key in template.keys.where((k) => !k.startsWith('@'))) {
      final text = translation[key];
      if (text is! String) continue; // missing key: see untranslated-messages-file
      final meta = template['@$key'] as Map<String, dynamic>?;
      final declared = (meta?['placeholders'] as Map<String, dynamic>?)?.keys ?? const <String>[];
      for (final name in declared) {
        if (!RegExp(r'\{\s*' + name + r'\s*[,}]').hasMatch(text)) {
          stderr.writeln('${file.path} · $key: {$name} missing');
          errors++;
        }
      }
    }
  }
  if (errors > 0) exit(1);
}

Map<String, dynamic> _read(String path) => jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;

このページの例では、lib/l10n/app_fr.arb · welcomeUser: {name} missing と出力してエラーで終了します。制限:@key.placeholders で宣言されたプレースホルダーしか見ず、複数形では変数がメッセージのどこかに現れるかをチェックするだけで、すべての分岐にあるかは確認しません。上で紹介した複数形の 2 つの落とし穴は、メッセージを 0、1、2 で呼び出すテストで検出するのがいちばんです。

よくある質問

flutter gen-l10n は翻訳もチェックしますか?それともテンプレートだけですか?

すべての ARB ファイルの ICU 構文をチェックします。ただし、翻訳のプレースホルダーをテンプレートと比較はしません。プレースホルダーが抜けていても警告なしに通ります。

ローカライズメソッドに余分な Object パラメータが増えたのはなぜですか?

テンプレートにないプレースホルダー名を翻訳が使っているためです。たとえば {count} の代わりに {nombre}。gen-l10n はそれをすべてのロケールのパラメータとして追加し、既存の呼び出し箇所がコンパイルできなくなります。

複数形では =1 と one のどちらを使うべきですか?

ICU では =1 は値 1 だけを対象にしますが、gen-l10n はそれを one カテゴリとして渡し、フランス語ではこのカテゴリが 0 も含みます。=0 の分岐を追加するか、one の分岐で {count} を表示してください。日本語は other だけなので、この問題は起きません。