Plurales y placeholders ICU: lo que gen-l10n deja pasar

Actualizado el · LocalePilot

flutter gen-l10n rechaza un mensaje ICU mal formado, pero acepta sin decir nada una traducción que perdió su placeholder, o un plural que dirá «1 artículo» para cero. Esto es lo que verifica, lo que no, y un script para cubrir la diferencia.

Todos los ejemplos de abajo se probaron con Flutter 3.47.5 y flutter gen-l10n, con una plantilla app_en.arb y una traducción app_fr.arb (francés).

¿Tienes prisa? Genera el bloque de plural de cada idioma directamente en el navegador: gratis, sin registro, no se sube nada.

Lo que gen-l10n sí bloquea

La sintaxis ICU se verifica en todos los archivos, no solo en la plantilla. Un plural sin rama other detiene la generación, con el nombre del archivo y la posición del error:

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

Lo mismo ocurre con una llave sin cerrar. Esos errores aparecen enseguida; los de las secciones siguientes pasan sin aviso.

Un placeholder perdido pasa sin decir nada

La plantilla en inglés declara un parámetro:

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

La traducción al francés lo perdió: "welcomeUser": "Bon retour parmi nous !". La generación funciona, el analizador no dice nada y el método generado simplemente ignora su argumento:

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

Nada falla, pero los usuarios francófonos nunca ven su nombre. A veces es intencionado (la frase suena mejor sin él), pero normalmente es un error, y ninguna herramienta estándar distingue un caso del otro. Tienes que comparar los placeholders de cada traducción con los de la plantilla; más abajo hay un script para eso.

Un placeholder renombrado cambia la API de todas las configuraciones regionales

Aquí la traducción usa un nombre que la plantilla no tiene, por ejemplo {nombre} en lugar de {count}:

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

gen-l10n sigue sin avisar. Añade nombre como parámetro adicional en la clase abstracta, es decir, en todas las configuraciones regionales:

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

El error aparece más tarde, cuando compila cada llamada existente: Too few positional arguments: 3 required, 2 given. Apunta a tu código Dart, no al archivo ARB que lo causó. Si a un método de localización le aparece de repente un parámetro Object, busca ese nombre en tus traducciones.

En francés, one también cubre el cero

Las categorías one, few, many… siguen las reglas CLDR de cada idioma. En inglés (y en español), one solo significa 1. En francés significa 0 y 1. Con este mensaje:

"itemsOne": "{count, plural, one{{count} article} other{{count} articles}}"
countInglésFrancés
00 items0 article
11 item1 article
22 items2 articles

«0 article» es francés correcto, así que ahí no hay problema. La trampa está en otro sitio: gen-l10n convierte =0 y =1 en los argumentos zero y one de Intl.pluralLogic. Por eso una rama =1 se convierte en la categoría one, y en francés esa categoría también se aplica al cero:

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

Con count = 0, el inglés muestra «0 items left» y el francés muestra «1 article restant» (queda un artículo). El texto es incorrecto y nada lo señala. La solución: añade una rama =0, o escribe one{{count} article restant} para que el número mostrado sea siempre el correcto. Cualquier idioma en el que one cubra más que el 1 tiene el mismo problema, como el portugués de Brasil; en español, one solo cubre el 1.

Apóstrofos y use-escaping

Por defecto, un apóstrofo recto es solo un carácter: "Don't forget the milk" funciona. Si activas use-escaping: true en l10n.yaml (para escribir llaves literales), se convierte en el carácter de escape de ICU y un apóstrofo suelto detiene la generación:

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

Entonces tienes que duplicarlo (Don''t) o usar el apóstrofo tipográfico ’, que no se ve afectado. En un proyecto existente, activar esa opción obliga a revisar todos los idiomas; las contracciones del inglés y las elisiones del francés te darán trabajo.

Una verificación que vale la pena añadir a la CI

El caso silencioso es el placeholder perdido. Este script lee los placeholders declarados en la plantilla y comprueba que cada traducción los siga usando:

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>;

Con el ejemplo de esta página, imprime lib/l10n/app_fr.arb · welcomeUser: {name} missing y termina con un error. Sus límites: solo mira los placeholders declarados en @key.placeholders y, en un plural, comprueba que la variable aparezca en algún lugar del mensaje, no en cada rama. Las dos trampas de plurales anteriores se detectan mejor con una prueba que llame a tus mensajes con 0, 1 y 2.

Preguntas frecuentes

¿flutter gen-l10n verifica las traducciones o solo la plantilla?

Verifica la sintaxis ICU de cada archivo ARB. No compara los placeholders de una traducción con los de la plantilla: un placeholder perdido pasa sin aviso.

¿Por qué mi método de localización tiene un parámetro Object de más?

Una traducción usa un nombre de placeholder que no existe en la plantilla, por ejemplo {nombre} en lugar de {count}. gen-l10n lo añade como parámetro en todas las configuraciones regionales, y las llamadas existentes dejan de compilar.

¿=1 u one en un plural?

En ICU, =1 apunta exactamente al valor 1, pero gen-l10n lo pasa como la categoría one, que en francés también cubre el 0. Añade una rama =0 o muestra {count} en la rama one.