Plurais ICU e placeholders: o que o gen-l10n deixa passar

Atualizado em · LocalePilot

O flutter gen-l10n rejeita uma mensagem ICU malformada, mas aceita em silêncio uma tradução que perdeu o placeholder, ou um plural que vai dizer "1 item" para zero. Veja o que ele verifica, o que não verifica e um script para fechar essa lacuna.

Todos os exemplos abaixo foram testados com Flutter 3.47.5 e flutter gen-l10n, usando um template app_en.arb e uma tradução app_fr.arb.

Com pressa? Gere o bloco de plural de cada idioma direto no navegador: grátis, sem cadastro, nada é enviado.

O que o gen-l10n bloqueia

A sintaxe ICU é verificada em todos os arquivos, não só no template. Um plural sem branch other interrompe a geração, com o nome do arquivo e a posição do erro:

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

O mesmo vale para uma chave não fechada. Esses erros aparecem na hora; os das seções seguintes, não.

Um placeholder perdido passa sem um pio

O template em inglês declara um parâmetro:

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

A tradução francesa o perdeu: "welcomeUser": "Bon retour parmi nous !". A geração funciona, o analyzer fica quieto e o método gerado simplesmente ignora o argumento:

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

Nada quebra, mas os usuários francófonos nunca veem o próprio nome. Às vezes é proposital (a frase fica melhor sem ele), geralmente é um erro, e nenhuma ferramenta padrão diferencia os dois casos. É preciso comparar os placeholders de cada tradução com os do template; há um script para isso mais abaixo.

Um placeholder renomeado muda a API de todos os locales

Este é mais estranho. A tradução usa um nome que o template não tem, por exemplo {nombre} em vez de {count}:

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

O gen-l10n continua sem reclamar. Ele adiciona nombre como parâmetro extra na classe abstrata, ou seja, em todos os locales:

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

O erro aparece depois, quando cada chamada existente é compilada: Too few positional arguments: 3 required, 2 given. Ele aponta para o seu código Dart, não para o arquivo ARB que o causou. Se um método de localização de repente ganhar um parâmetro Object, procure esse nome nas suas traduções.

Em francês, one também cobre o zero

As categorias one, few, many… seguem as regras CLDR de cada idioma. Em inglês, one significa apenas 1. Em francês, significa 0 e 1. Com esta mensagem:

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

"0 article" é francês correto, então aqui não há problema. A armadilha está em outro lugar: o gen-l10n transforma =0 e =1 nos argumentos zero e one de Intl.pluralLogic. Um branch =1 vira, portanto, a categoria one, e em francês essa categoria também se aplica ao zero:

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

Com count = 0, o inglês mostra "0 items left" e o francês mostra "1 article restant" (resta um item), sem nenhum aviso. A correção: adicione um branch =0, ou escreva one{{count} article restant} para que o número exibido esteja sempre certo. Todo idioma em que one cobre mais do que 1 tem o mesmo problema, e o português do Brasil é um deles: pelas regras CLDR, one cobre 0 e 1 em pt.

Apóstrofos e use-escaping

Por padrão, um apóstrofo reto é só um caractere: "Don't forget the milk" funciona. Se você ativar use-escaping: true no l10n.yaml (para escrever chaves literais), ele vira o caractere de escape do ICU, e um apóstrofo isolado interrompe a geração:

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

Aí é preciso duplicá-lo (Don''t) ou usar o apóstrofo tipográfico ’, que não é afetado. Em um projeto existente, ligar essa opção significa revisar todos os idiomas; as contrações do inglês e as elisões do francês vão dar trabalho.

Uma verificação que vale a pena colocar na CI

O caso silencioso é o placeholder perdido. Este script lê os placeholders declarados no template e verifica se cada tradução ainda os usa:

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

No exemplo desta página, ele imprime lib/l10n/app_fr.arb · welcomeUser: {name} missing e sai com erro. Seus limites: ele só olha os placeholders declarados em @key.placeholders e, em um plural, verifica se a variável aparece em algum ponto da mensagem, não em cada branch. As duas armadilhas de plural acima são mais bem pegas por um teste que chama suas mensagens com 0, 1 e 2.

Perguntas frequentes

O flutter gen-l10n verifica as traduções ou só o template?

Ele verifica a sintaxe ICU de todos os arquivos ARB. Não compara os placeholders de uma tradução com os do template: um placeholder perdido passa sem aviso.

Por que meu método de localização ganhou um parâmetro Object a mais?

Uma tradução usa um nome de placeholder que não existe no template, por exemplo {nombre} em vez de {count}. O gen-l10n o adiciona como parâmetro em todos os locales, e as chamadas existentes deixam de compilar.

=1 ou one em um plural?

No ICU, =1 visa exatamente o valor 1, mas o gen-l10n o repassa como a categoria one, que em francês e em português do Brasil também cobre o 0. Adicione um branch =0 ou mostre {count} no branch one.