Pluriels et placeholders ICU : ce que gen-l10n laisse passer

Mis à jour le · GLabs325

flutter gen-l10n refuse un message ICU mal formé, mais accepte sans rien dire une traduction qui a perdu son placeholder, ou un pluriel qui affichera « 1 article » pour zéro. Voici ce qu'il vérifie, ce qu'il ne vérifie pas, et un script pour combler l'écart.

Tous les exemples ci-dessous ont été testés avec Flutter 3.47.5 et flutter gen-l10n, avec un fichier modèle app_en.arb et une traduction app_fr.arb.

Ce que gen-l10n bloque

La syntaxe ICU est vérifiée dans tous les fichiers, pas seulement dans le modèle. Un pluriel sans branche other arrête la génération, avec le nom du fichier et la position de l'erreur :

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

Même chose pour une accolade non fermée. Ces erreurs-là, on les voit tout de suite. Les suivantes, non.

Un placeholder oublié passe sans un mot

Le modèle anglais déclare un paramètre :

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

La traduction française l'a perdu : "welcomeUser": "Bon retour parmi nous !". La génération réussit, l'analyseur ne dit rien, et la méthode générée ignore tout simplement son argument :

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

L'application ne plante pas. L'utilisateur francophone ne voit juste jamais son prénom. Parfois c'est voulu (une tournure plus naturelle sans le nom), le plus souvent c'est un oubli, et aucun outil standard ne fait la différence. Il faut comparer les placeholders de chaque traduction avec ceux du modèle : un script pour ça plus bas.

Un placeholder renommé modifie l'API de toutes les langues

Plus déroutant : la traduction utilise un nom qui n'existe pas dans le modèle, par exemple {nombre} au lieu de {count}.

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

gen-l10n ne signale toujours rien. Il ajoute nombre comme paramètre supplémentaire, dans la classe abstraite et donc dans toutes les langues :

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

L'erreur arrive plus loin, à la compilation de chaque appel existant : Too few positional arguments: 3 required, 2 given. Le message pointe vers votre code Dart, pas vers le fichier ARB fautif. Si une méthode de localisation gagne soudain un paramètre de type Object, cherchez le nom en question dans les traductions.

En français, one couvre aussi zéro

Les catégories one, few, many… suivent les règles CLDR de chaque langue. En anglais, one ne vaut que pour 1. En français, il vaut pour 0 et 1. Avec ce message :

"itemsOne": "{count, plural, one{{count} article} other{{count} articles}}"
countAnglaisFrançais
00 items0 article
11 item1 article
22 items2 articles

« 0 article » est correct en français, donc pas de problème ici. Le piège est ailleurs : gen-l10n transforme =0 et =1 en arguments zero et one de Intl.pluralLogic. Une branche =1 devient donc la catégorie one, et en français elle s'applique aussi à zéro :

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

Avec count = 0, l'anglais affiche « 0 items left » et le français « 1 article restant ». Le texte est faux, et rien ne le signale. La correction : ajouter une branche =0, ou écrire one{{count} article restant} pour que le nombre affiché soit toujours le bon.

Les apostrophes et use-escaping

Par défaut, l'apostrophe droite est un caractère comme un autre : "N'oubliez pas le lait" fonctionne. Si vous activez use-escaping: true dans l10n.yaml (pour écrire des accolades littérales), elle devient un caractère d'échappement ICU, et une apostrophe seule arrête la génération :

[app_fr.arb:apostrophe] ICU Lexing Error: Unmatched single quotes.
    N'oubliez pas le lait
     ^

Il faut alors la doubler (N''oubliez) ou utiliser l'apostrophe typographique ’, qui n'est pas concernée. Sur un projet existant, activer l'option oblige à repasser sur toutes les langues ; le français sera le plus touché.

Un contrôle à ajouter en CI

Le cas silencieux est le placeholder oublié. Ce script lit les placeholders déclarés dans le modèle et vérifie qu'ils apparaissent dans chaque traduction :

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; // clé absente : voir 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} absent');
          errors++;
        }
      }
    }
  }
  if (errors > 0) exit(1);
}

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

Sur l'exemple de cette page, il affiche lib/l10n/app_fr.arb · welcomeUser : {name} absent et sort en erreur. Ses limites : il ne regarde que les placeholders déclarés dans @clé.placeholders, et pour un pluriel il vérifie que la variable apparaît quelque part dans le message, pas dans chaque branche. Pour le reste, les deux pièges de pluriel ci-dessus se repèrent à la relecture, avec un test qui appelle vos messages pour 0, 1 et 2.

Questions fréquentes

flutter gen-l10n vérifie-t-il les traductions ou seulement le modèle ?

Il vérifie la syntaxe ICU de tous les fichiers ARB. En revanche, il ne compare pas les placeholders d'une traduction avec ceux du modèle : un placeholder oublié passe sans avertissement.

Pourquoi ma méthode de localisation a-t-elle un paramètre Object en plus ?

Une traduction utilise un nom de placeholder qui n'existe pas dans le modèle, par exemple {nombre} au lieu de {count}. gen-l10n l'ajoute comme paramètre à toutes les langues et les appels existants ne compilent plus.

=1 ou one dans un pluriel ?

=1 cible exactement la valeur 1 dans la syntaxe ICU, mais gen-l10n le transmet comme catégorie one, qui couvre aussi 0 en français. Ajoutez une branche =0 ou affichez {count} dans la branche one.

Autres guides