← Back to Blog

Fix Flutter gen-l10n "Found Syntax Errors" in Your ARB

flutteri18narbgen-l10nicudebugging

Fix Flutter gen-l10n "Found Syntax Errors" in Your ARB

You bump the Flutter SDK, run flutter gen-l10n (or just flutter run), and the build stops with something like:

[app_de.arb:itemCount] ICU Syntax Error: Expected "identifier" but found "}".
    Du hast {} Artikel
             ^
Found syntax errors.

Sometimes you get the caret. Often — long message, wrapped CI log, dozens of failures at once — all you keep is Found syntax errors. and a message id. What you never get is a file line number, which is exactly what your editor needs.

This isn't something to wait out. Flutter 3.7 replaced the old permissive ARB handling with a real ICU lexer and parser (packages/flutter_tools/lib/src/localizations/message_parser.dart), so messages that silently "worked" for years started failing the moment the SDK moved. The parser raises L10nParserException, which carries the filename, the message id, the message string, and the character offset inside that message — not the offset inside the file. That's the whole reason you see file:key and a caret, but no line.

Triage order

After an upgrade, work top to bottom. This is roughly the frequency order.

1. JSON before ICU

ARB is decoded with json.decode, so a malformed file never reaches the ICU parser at all — you get a FormatException, not a syntax error report. Dart's JSON parser rejects trailing commas and comments.

Before:

{
  "signIn": "Sign in",
  "signOut": "Sign out",
}

After: drop the comma after the last pair. If a merge conflict marker or a smart quote slipped in, that shows up here too.

2. A literal curly brace

The most common post-upgrade breakage. { opens a placeholder; anything that isn't a valid identifier after it is a hard error.

Before:

"cssHint": "Wrap the value in { }"
[app_en.arb:cssHint] ICU Syntax Error: Expected "identifier" but found " ".

A lone trailing brace produces the sibling error, Expected "identifier" but found no tokens. You have three fixes:

  • Set use-escaping: true in l10n.yaml and wrap the literal in single quotes: "Wrap the value in '{' '}'".
  • Set relax-syntax: true, which makes { a plain string when it isn't followed by a valid placeholder, and } a plain string when it closes nothing.
  • Rephrase the string so the brace is gone. Boring, but it needs no global flag.

3. Stray apostrophes

This one bites because you enabled use-escaping to fix #2. With escaping on, ' starts an escape sequence, so ordinary English contractions become unmatched quotes.

Before (with use-escaping: true):

"noItems": "It's empty in here"
[app_en.arb:noItems] ICU Lexing Error: Unmatched single quotes.

After — a literal apostrophe is two single quotes:

"noItems": "It''s empty in here"

This is why one flag flip produces forty errors across your locale files at once. Fix them all, or don't turn the flag on.

4. plural and select cases

Every plural and select needs an other case, and plural categories are restricted to zero, one, two, few, many, other (plus explicit =0, =1, … forms).

Before:

"itemCount": "{count, plural, =0{No items} one{1 item}}"
ICU Syntax Error: Plural expressions must have an "other" case.

After:

"itemCount": "{count, plural, =0{No items} one{1 item} other{{count} items}}"

A related trap that is not a syntax error: a locale whose grammar needs few or many — Arabic, Polish, Russian — but that only ships one/other. gen-l10n compiles it happily and the app reads wrong at runtime. FlutterLocalisation's ICU plural validation flags exactly those missing categories per locale.

5. Placeholder types and @-metadata

Types are checked against Dart types; Int is not int.

Before:

"lastSeen": "Last seen {when}",
"@lastSeen": {
  "placeholders": { "when": { "type": "Date" } }
}

After — the supported set is String, int, num, double, DateTime, Object:

"@lastSeen": {
  "placeholders": {
    "when": { "type": "DateTime", "format": "yMMMd" }
  }
}

A DateTime placeholder needs a format (an intl DateFormat skeleton) or isCustomDateFormat: true with your own pattern. Number placeholders only accept intl NumberFormat constructors such as compact, currency, decimalPattern, percentPattern. The other half of this class is a translated locale that uses a placeholder the template locale never declared — the generator has no metadata for it and the message blows up in app_fr.arb while app_en.arb is fine. See our ARB placeholder guide for the full type/format matrix.

The script: file, line, key

Drop this in tool/arb_lint.dart. It has no dependencies, walks every app_*.arb, and prints path:line key -> problem — the file line, which gen-l10n won't give you.

// tool/arb_lint.dart
// run: dart run tool/arb_lint.dart lib/l10n [--escaping]
import 'dart:convert';
import 'dart:io';

const _validTypes = {'String', 'int', 'num', 'double', 'DateTime', 'Object'};
const _argTypes = {'plural', 'select', 'date', 'time', 'number'};

var useEscaping = false;

void main(List<String> args) {
  useEscaping = args.contains('--escaping');
  final rest = args.where((a) => !a.startsWith('--')).toList();
  final dir = Directory(rest.isEmpty ? 'lib/l10n' : rest.first);
  if (!dir.existsSync()) {
    stderr.writeln('No such directory: ${dir.path}');
    exit(2);
  }
  final files = dir
      .listSync()
      .whereType<File>()
      .where((f) => f.path.endsWith('.arb'))
      .toList()
    ..sort((a, b) => a.path.compareTo(b.path));

  var problems = 0;
  for (final file in files) {
    final source = file.readAsStringSync();
    Map<String, dynamic> arb;
    try {
      arb = json.decode(source) as Map<String, dynamic>;
    } on FormatException catch (e) {
      print('${file.path}:${_lineOf(source, e.offset ?? 0)}  JSON: ${e.message}');
      problems++;
      continue;
    }
    for (final entry in arb.entries) {
      if (entry.key.startsWith('@')) continue;
      final value = entry.value;
      if (value is! String) continue;

      final declared = <String>{};
      final meta = arb['@${entry.key}'];
      if (meta is Map && meta['placeholders'] is Map) {
        (meta['placeholders'] as Map).forEach((name, spec) {
          declared.add('$name');
          final type = spec is Map ? spec['type'] : null;
          if (type != null && !_validTypes.contains(type)) {
            problems += _report(file, source, entry.key,
                'invalid placeholder type "$type" for "$name"');
          }
        });
      }
      for (final issue in _scan(value, declared)) {
        problems += _report(file, source, entry.key, issue);
      }
    }
  }
  print(problems == 0
      ? 'OK - ${files.length} ARB file(s) parsed clean.'
      : '$problems problem(s) found.');
  exit(problems == 0 ? 0 : 1);
}

List<String> _scan(String msg, Set<String> declared) {
  final issues = <String>[];
  final open = <int>[];
  var quotes = 0;

  for (var i = 0; i < msg.length; i++) {
    final c = msg[i];
    if (c == "'" && useEscaping) {
      if (i + 1 < msg.length && msg[i + 1] == "'") {
        i++;
        continue;
      }
      quotes++;
    } else if (c == '{') {
      open.add(i);
      final head = RegExp(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:,\s*([A-Za-z]+))?\s*[,}]')
          .firstMatch(msg.substring(i + 1));
      if (head == null) {
        issues.add('col ${i + 1}: "{" is not followed by a placeholder name');
      } else {
        final name = head.group(1)!;
        if (declared.isNotEmpty && !declared.contains(name)) {
          issues.add('col ${i + 1}: "$name" is not declared in @-metadata');
        }
        final kind = head.group(2);
        if (kind != null && !_argTypes.contains(kind)) {
          issues.add('col ${i + 1}: unknown argument type "$kind"');
        }
      }
    } else if (c == '}') {
      if (open.isEmpty) {
        issues.add('col ${i + 1}: "}" closes nothing');
      } else {
        final start = open.removeLast();
        final inner = msg.substring(start + 1, i);
        final m = RegExp(r'^\s*\w+\s*,\s*(plural|select)\b').firstMatch(inner);
        if (m != null && !RegExp(r'(^|[\s,])other\s*\{').hasMatch(inner)) {
          issues.add('col ${start + 1}: ${m.group(1)} has no "other" case');
        }
      }
    }
  }
  if (open.isNotEmpty) {
    issues.add('col ${open.first + 1}: "{" is never closed');
  }
  if (useEscaping && quotes.isOdd) {
    issues.add('unmatched single quote (ICU Lexing Error: Unmatched single quotes)');
  }
  return issues;
}

int _report(File f, String src, String key, String issue) {
  final at = src.indexOf('"$key"');
  print('${f.path}:${at < 0 ? '?' : _lineOf(src, at)}  $key  ->  $issue');
  return 1;
}

int _lineOf(String src, int offset) {
  final end = offset > src.length ? src.length : offset;
  return '\n'.allMatches(src.substring(0, end)).length + 1;
}

Typical run:

$ dart run tool/arb_lint.dart lib/l10n --escaping
lib/l10n/app_en.arb:41  cssHint  ->  col 18: "{" is not followed by a placeholder name
lib/l10n/app_pl.arb:12  itemCount  ->  col 1: plural has no "other" case
lib/l10n/app_fr.arb:88  greeting  ->  col 7: "userName" is not declared in @-metadata
3 problem(s) found.

Pass --escaping only if your l10n.yaml sets use-escaping: true; otherwise the apostrophe check produces false positives, because without that flag ' is just a character.

Keep it from happening again

Run the linter before the generator, so a bad ARB fails in one second with a line number instead of ninety seconds into a build:

dart run tool/arb_lint.dart lib/l10n --escaping && flutter gen-l10n

The l10n.yaml switches worth knowing:

  • use-escaping — single quotes become escaping syntax; '' is a literal apostrophe.
  • relax-syntax — unmatched { / } are treated as plain text.
  • suppress-warnings — hides warnings only. It will not silence Found syntax errors., and reaching for it usually means you're hiding an untranslated-message report you actually wanted.

Stop hunting braces by hand

Most of these failures are authoring accidents in raw JSON: a brace typed into a help string, an apostrophe added by a translator, a plural case dropped during a merge. Editing ARB through the FlutterLocalisation ARB editor keeps placeholders and plural categories structured instead of hand-typed, and its ICU plural validation catches the missing-category class that gen-l10n never reports at all. Try FlutterLocalisation free and let the upgrade be boring next time.