← Back to Blog

Fix gen-l10n's ICU Lexing Error in Flutter ARB Files

flutteri18ngen-l10narbicudebugging

Fix gen-l10n's ICU Lexing Error in Flutter ARB Files

Your translator hands back app_fr.arb, app_es.arb and app_zh.arb, you run the build, and it dies:

[app_fr.arb:welcomeBanner] ICU Lexing Error: Unexpected character.
    Bonjour {prénom}, vous avez {nb-messages} messages
               ^
Found syntax errors.

Two things make this worse than it needs to be. flutter gen-l10n logs each parse failure with printError and only afterwards throws Found syntax errors. — so under flutter build the useful lines scroll past and the last thing on screen is four words. And the caret is a UTF-16 offset padded with plain spaces, so as soon as there's CJK or accented text before the bad character, the ^ lands in the wrong column.

The good news: the lexer is tiny and fully deterministic. Learn its one rule and you can find the offending character by eye — then script it so it never reaches CI.

The one rule that explains the whole error

gen-l10n's message_parser.dart has exactly two modes, toggled by every { and }:

  • String mode (the default): it swallows everything up to the next brace with RegExp(r'[^{}]+'). Emoji, #, %, apostrophes, Han characters — all fine, all literal.
  • Token mode (entered on {): it accepts only these matchers, tried in this order:
RegExp whitespace   = RegExp(r'\s+');
RegExp numeric      = RegExp(r'[0-9]+');
RegExp comma        = RegExp(r',');
RegExp equalSign    = RegExp(r'=');
RegExp colon        = RegExp(r':');
RegExp alphanumeric = RegExp(r'[a-zA-Z0-9|_]+');

If none match and the character isn't { or }, the lexer throws ICU Lexing Error: Unexpected character.

So: the error always fires inside braces, on a character that is not an ASCII letter, digit, _, |, whitespace, ,, = or :. Every real-world report of ICU Lexing Error Unexpected character in Flutter reduces to that. Here are the three ways translators produce one.

Trigger 1: a localized or punctuated placeholder name

This is by far the most common cause after a translation round-trip, because translators translate everything — including the word inside the braces.

"welcomeBanner": "Bonjour {prénom}, vous avez {nb-messages} messages"

The lexer reads {, matches pr as an identifier, hits é at character 11, and stops. nb-messages would fail the same way on the -. The same class of bug covers {item.count}, {user-name}, {名前}, {año} and {count%}.

A nastier variant is punctuation the translator's keyboard produced automatically — a full-width comma inside a plural block:

"cartCount": "{count,plural,other{购物车中有 {count} 件商品}}"

Character 6 is (U+FF0C), not , (U+002C). The two are visually near-identical in a JSON diff and completely different to the lexer.

Fix: placeholder names are code, not copy. Keep them ASCII and identical across every locale file, and make sure the ARB's "@key" metadata declares them:

"welcomeBanner": "Bonjour {firstName}, vous avez {messageCount} messages",
"@welcomeBanner": {
  "placeholders": {
    "firstName": { "type": "String" },
    "messageCount": { "type": "int" }
  }
}

Trigger 2: a bare { or } in translated copy

A literal brace in prose flips the lexer into token mode, and whatever follows decides which error you get.

"tip": "Type {@ to mention a teammate"

@ isn't a valid token → ICU Lexing Error: Unexpected character. Whereas "Wrap the value in { } to interpolate" lexes cleanly and then dies one stage later with ICU Syntax Error: Expected "identifier" but found "}" — the exact case filed as flutter#122404. Same root cause, two different messages depending on the next character.

There are two supported fixes, and the tradeoff matters.

relax-syntax: true (Flutter 3.16.0 and later, from PR #130736) is what you usually want:

# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
relax-syntax: true

With it on, a { is treated as a plain character unless it is immediately followed by a name that appears in your declared placeholder list, and a } is treated as a plain character unless it closes a brace that was itself special. One caveat worth knowing: that list comes from the template file's placeholders, so a placeholder declared only in app_de.arb and not in app_en.arb will silently be treated as literal text.

use-escaping: true is the older mechanism — wrap ICU syntax in single quotes and use '' for a literal apostrophe:

"tip": "Type '{' to open the panel — don''t forget the bracket"

Think hard before enabling this on an existing project. It makes every apostrophe significant across every locale, and an odd number of them produces its own failure, ICU Lexing Error: Unmatched single quotes. French, Italian and Catalan ARBs are full of apostrophes, so flipping this flag after translations exist tends to trade one broken build for thirty.

Trigger 3: underscores in placeholder names on Flutter 3.7.0–3.7.2

If your CI image is pinned to an old stable, this one is real and looks impossible to debug, because the ARB is correct:

"itemsSelected": "{item_count, plural, =1{1 élément} other{{item_count} éléments}}"

The original lexer used RegExp(r'[a-zA-Z0-9]+') for identifiers, so item_count broke at the _ — reported as flutter#120098. PR #119190 widened the pattern to [a-zA-Z0-9|_]+, and that cherry-pick shipped in Flutter 3.7.3. Every stable since (up to today's 3.47.x) handles underscores fine.

If you're seeing a flutter arb placeholder underscore plural error, the fix is a version bump, not an ARB edit. Check with flutter --version before you rename fifty keys.

The # myth, and a real bug hiding behind it

In standard ICU MessageFormat, # inside a plural body renders the number. Flutter's parser does not implement that. In string mode # is matched by [^{}]+ like any other character, so it never triggers a lexing error — it is emitted verbatim:

"badPlural": "{count, plural, other{Vous avez # articles}}"

This compiles happily and ships "Vous avez # articles" to production. If your translators come from a web i18n stack, grep for it. The correct form repeats the placeholder:

"goodPlural": "{count, plural, other{Vous avez {count} articles}}"

(A # can cause the lexing error, but only in the argument header — {count, plural, # other{...}} — which is rare.)

A pre-flight script that names the key and the character

Rather than reading gen-l10n's misaligned caret, mirror its lexer and report the exact key. The scanning loop below is a direct translation of the two-mode lexer described above, with use-escaping and relax-syntax off (the defaults). Drop it at tool/check_arb.dart:

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

// Mirrors flutter_tools/lib/src/localizations/message_parser.dart
final _string = RegExp(r'[^{}]+');
final _token = RegExp(r'\s+|[0-9]+|,|=|:|[a-zA-Z0-9|_]+');

/// Index of the first character gen-l10n cannot lex, or -1 if the message is clean.
int firstBadChar(String s) {
  var isString = true, i = 0;
  while (i < s.length) {
    final m = (isString ? _string : _token).matchAsPrefix(s, i);
    if (m != null) {
      i = m.end;
    } else if (s[i] == '{' || s[i] == '}') {
      isString = !isString;
      i++;
    } else {
      return i; // ICU Lexing Error: Unexpected character.
    }
  }
  return -1;
}

void main(List<String> args) {
  final dir = Directory(args.isEmpty ? 'lib/l10n' : args.first);
  var failed = false;
  final files = dir.listSync().whereType<File>().where((f) => f.path.endsWith('.arb')).toList()
    ..sort((a, b) => a.path.compareTo(b.path));

  for (final file in files) {
    final Map<String, dynamic> arb = jsonDecode(file.readAsStringSync());
    arb.forEach((key, value) {
      if (key.startsWith('@') || value is! String) return; // skip @@locale and metadata
      final at = firstBadChar(value);
      if (at < 0) return;
      failed = true;
      stderr.writeln('${file.path}:$key:$at  unexpected "${value[at]}" '
          '(U+${value.codeUnitAt(at).toRadixString(16).toUpperCase().padLeft(4, '0')})');
      stderr.writeln('  ${value.substring(0, at)}[HERE]${value.substring(at)}');
    });
  }
  exitCode = failed ? 1 : 0;
}

Against the French file above it prints:

lib/l10n/app_fr.arb:welcomeBanner:11  unexpected "é" (U+00E9)
  Bonjour {pr[HERE]énom}, vous avez {nb-messages} messages

The [HERE] marker instead of a caret line is deliberate — it survives proportional fonts, CI log viewers and multi-byte text, which is exactly where gen-l10n's own caret misleads you.

Wire it in before gen-l10n runs

dart run tool/check_arb.dart lib/l10n && flutter gen-l10n

The script exits non-zero on the first bad file, so the CI step fails with a key name instead of Found syntax errors. It also runs in well under a second on a few thousand strings, which makes it cheap enough for a pre-commit hook.

One more habit worth adopting: run flutter gen-l10n on its own rather than relying on flutter run to trigger it. Standalone, the per-key [file:key] lines are the last thing printed and impossible to miss.

Better: don't let translators touch the braces

Every trigger above is the same failure mode — ICU syntax living in a raw JSON file that a non-engineer edits by hand. A lexer check catches it after the fact; a structured editor stops it happening.

FlutterLocalisation is an ARB editor and translation-management platform built for exactly this. Translators edit app_<locale>.arb messages in a UI instead of raw JSON, across as many locales as you support, and its ICU plural-syntax validation flags locales that are missing a plural category the language actually requires — the dropped few/many that quietly breaks Arabic, Polish and Russian output long after the build turns green.

More Flutter i18n walkthroughs live on the FlutterLocalisation blog, and the pricing page has the plan breakdown.

Try FlutterLocalisation free — import your existing ARB files and let the editor keep the braces intact.