gen_localizations.dart 22.5 KB
Newer Older
1 2 3 4
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
// This program generates a getMaterialTranslation() function that looks up the
// translations provided by the arb files. The returned value is a generated
7 8
// instance of GlobalMaterialLocalizations that corresponds to a single
// locale.
9
//
10
// The *.arb files are in packages/flutter_localizations/lib/src/l10n.
11 12 13 14 15
//
// The arb (JSON) format files must contain a single map indexed by locale.
// Each map value is itself a map with resource identifier keys and localized
// resource string values.
//
16 17 18 19
// The arb filenames are expected to have the form "material_(\w+)\.arb", where
// the group following "_" identifies the language code and the country code,
// e.g. "material_en.arb" or "material_en_GB.arb". In most cases both codes are
// just two characters.
20 21 22 23
//
// This app is typically run by hand when a module's .arb files have been
// updated.
//
24 25 26 27 28 29 30
// ## Usage
//
// Run this program from the root of the git repository.
//
// The following outputs the generated Dart code to the console as a dry run:
//
// ```
31
// dart dev/tools/localization/gen_localizations.dart
32 33
// ```
//
34
// If the data looks good, use the `-w` or `--overwrite` option to overwrite the
35
// packages/flutter_localizations/lib/src/l10n/generated_material_localizations.dart file:
36 37
//
// ```
38
// dart dev/tools/localization/gen_localizations.dart --overwrite
39
// ```
40

41
import 'dart:async';
42 43
import 'dart:io';

44 45
import 'package:path/path.dart' as path;
import 'package:meta/meta.dart';
46

47
import 'gen_material_localizations.dart';
48
import 'localizations_utils.dart';
49 50
import 'localizations_validator.dart';

51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
/// This is the core of this script; it generates the code used for translations.
String generateArbBasedLocalizationSubclasses({
  @required Map<LocaleInfo, Map<String, String>> localeToResources,
  @required Map<LocaleInfo, Map<String, dynamic>> localeToResourceAttributes,
  @required String generatedClassPrefix,
  @required String baseClass,
  @required HeaderGenerator generateHeader,
  @required ConstructorGenerator generateConstructor,
  @required String factoryDeclaration,
  @required String factoryArguments,
}) {
  assert(localeToResources != null);
  assert(localeToResourceAttributes != null);
  assert(generatedClassPrefix.isNotEmpty);
  assert(baseClass.isNotEmpty);
  assert(generateHeader != null);
  assert(generateConstructor != null);
  assert(factoryDeclaration.isNotEmpty);
  assert(factoryArguments.isNotEmpty);
70

71
  final StringBuffer output = StringBuffer();
72
  output.writeln(generateHeader('dart dev/tools/localization/gen_localizations.dart --overwrite'));
73

74
  final StringBuffer supportedLocales = StringBuffer();
75

76 77 78 79
  final Map<String, List<LocaleInfo>> languageToLocales = <String, List<LocaleInfo>>{};
  final Map<String, Set<String>> languageToScriptCodes = <String, Set<String>>{};
  // Used to calculate if there are any corresponding countries for a given language and script.
  final Map<LocaleInfo, Set<String>> languageAndScriptToCountryCodes = <LocaleInfo, Set<String>>{};
80
  final Set<String> allResourceIdentifiers = <String>{};
81 82
  for (LocaleInfo locale in localeToResources.keys.toList()..sort()) {
    if (locale.scriptCode != null) {
83
      languageToScriptCodes[locale.languageCode] ??= <String>{};
84 85 86 87
      languageToScriptCodes[locale.languageCode].add(locale.scriptCode);
    }
    if (locale.countryCode != null && locale.scriptCode != null) {
      final LocaleInfo key = LocaleInfo.fromString(locale.languageCode + '_' + locale.scriptCode);
88
      languageAndScriptToCountryCodes[key] ??= <String>{};
89 90 91 92
      languageAndScriptToCountryCodes[key].add(locale.countryCode);
    }
    languageToLocales[locale.languageCode] ??= <LocaleInfo>[];
    languageToLocales[locale.languageCode].add(locale);
93 94 95
    allResourceIdentifiers.addAll(localeToResources[locale].keys);
  }

96
  // We generate one class per supported language (e.g.
97 98
  // `MaterialLocalizationEn`). These implement everything that is needed by the
  // superclass (e.g. GlobalMaterialLocalizations).
99

100 101 102 103 104
  // We also generate one subclass for each locale with a script code (e.g.
  // `MaterialLocalizationZhHant`). Their superclasses are the aforementioned
  // language classes for the same locale but without a script code (e.g.
  // `MaterialLocalizationZh`).

105 106 107
  // We also generate one subclass for each locale with a country code (e.g.
  // `MaterialLocalizationEnGb`). Their superclasses are the aforementioned
  // language classes for the same locale but without a country code (e.g.
108 109 110 111 112 113 114 115 116 117 118
  // `MaterialLocalizationEn`).

  // If scriptCodes for a language are defined, we expect a scriptCode to be
  // defined for locales that contain a countryCode. The superclass becomes
  // the script sublcass (e.g. `MaterialLocalizationZhHant`) and the generated
  // subclass will also contain the script code (e.g. `MaterialLocalizationZhHantTW`).

  // When scriptCodes are not defined for languages that use scriptCodes to distinguish
  // between significantly differing scripts, we assume the scriptCodes in the
  // [LocaleInfo.fromString] factory and add it to the [LocaleInfo]. We then generate
  // the script classes based on the first locale that we assume to use the script.
119 120 121

  final List<String> allKeys = allResourceIdentifiers.toList()..sort();
  final List<String> languageCodes = languageToLocales.keys.toList()..sort();
122
  final LocaleInfo canonicalLocale = LocaleInfo.fromString('en');
123
  for (String languageName in languageCodes) {
124
    final LocaleInfo languageLocale = LocaleInfo.fromString(languageName);
125 126 127
    output.writeln(generateClassDeclaration(languageLocale, generatedClassPrefix, baseClass));
    output.writeln(generateConstructor(languageLocale));

128
    final Map<String, String> languageResources = localeToResources[languageLocale];
129
    for (String key in allKeys) {
130
      final Map<String, dynamic> attributes = localeToResourceAttributes[canonicalLocale][key];
131
      output.writeln(generateGetter(key, languageResources[key], attributes));
132
    }
133 134
    output.writeln('}');
    int countryCodeCount = 0;
135 136 137 138 139 140 141
    int scriptCodeCount = 0;
    if (languageToScriptCodes.containsKey(languageName)) {
      scriptCodeCount = languageToScriptCodes[languageName].length;
      // Language has scriptCodes, so we need to properly fallback countries to corresponding
      // script default values before language default values.
      for (String scriptCode in languageToScriptCodes[languageName]) {
        final LocaleInfo scriptBaseLocale = LocaleInfo.fromString(languageName + '_' + scriptCode);
142 143 144 145 146 147
        output.writeln(generateClassDeclaration(
          scriptBaseLocale,
          generatedClassPrefix,
          '$generatedClassPrefix${camelCase(languageLocale)}',
        ));
        output.writeln(generateConstructor(scriptBaseLocale));
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165
        final Map<String, String> scriptResources = localeToResources[scriptBaseLocale];
        for (String key in scriptResources.keys) {
          if (languageResources[key] == scriptResources[key])
            continue;
          final Map<String, dynamic> attributes = localeToResourceAttributes[canonicalLocale][key];
          output.writeln(generateGetter(key, scriptResources[key], attributes));
        }
        output.writeln('}');

        final List<LocaleInfo> localeCodes = languageToLocales[languageName]..sort();
        for (LocaleInfo locale in localeCodes) {
          if (locale.originalString == languageName)
            continue;
          if (locale.originalString == languageName + '_' + scriptCode)
            continue;
          if (locale.scriptCode != scriptCode)
            continue;
          countryCodeCount += 1;
166 167 168 169 170 171
          output.writeln(generateClassDeclaration(
            locale,
            generatedClassPrefix,
            '$generatedClassPrefix${camelCase(scriptBaseLocale)}',
          ));
          output.writeln(generateConstructor(locale));
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188
          final Map<String, String> localeResources = localeToResources[locale];
          for (String key in localeResources.keys) {
            // When script fallback contains the key, we compare to it instead of language fallback.
            if (scriptResources.containsKey(key) ? scriptResources[key] == localeResources[key] : languageResources[key] == localeResources[key])
              continue;
            final Map<String, dynamic> attributes = localeToResourceAttributes[canonicalLocale][key];
            output.writeln(generateGetter(key, localeResources[key], attributes));
          }
         output.writeln('}');
        }
      }
    } else {
      // No scriptCode. Here, we do not compare against script default (because it
      // doesn't exist).
      final List<LocaleInfo> localeCodes = languageToLocales[languageName]..sort();
      for (LocaleInfo locale in localeCodes) {
        if (locale.originalString == languageName)
189
          continue;
190 191
        countryCodeCount += 1;
        final Map<String, String> localeResources = localeToResources[locale];
192 193 194 195 196 197
        output.writeln(generateClassDeclaration(
          locale,
          generatedClassPrefix,
          '$generatedClassPrefix${camelCase(languageLocale)}',
        ));
        output.writeln(generateConstructor(locale));
198 199 200 201 202 203 204
        for (String key in localeResources.keys) {
          if (languageResources[key] == localeResources[key])
            continue;
          final Map<String, dynamic> attributes = localeToResourceAttributes[canonicalLocale][key];
          output.writeln(generateGetter(key, localeResources[key], attributes));
        }
       output.writeln('}');
205
      }
206
    }
207
    final String scriptCodeMessage = scriptCodeCount == 0 ? '' : ' and $scriptCodeCount script' + (scriptCodeCount == 1 ? '' : 's');
208
    if (countryCodeCount == 0) {
209 210 211 212 213
      if (scriptCodeCount == 0)
        supportedLocales.writeln('///  * `$languageName` - ${describeLocale(languageName)}');
      else
        supportedLocales.writeln('///  * `$languageName` - ${describeLocale(languageName)} (plus $scriptCodeCount script' + (scriptCodeCount == 1 ? '' : 's') + ')');

214
    } else if (countryCodeCount == 1) {
215
      supportedLocales.writeln('///  * `$languageName` - ${describeLocale(languageName)} (plus one country variation$scriptCodeMessage)');
216
    } else {
217
      supportedLocales.writeln('///  * `$languageName` - ${describeLocale(languageName)} (plus $countryCodeCount country variations$scriptCodeMessage)');
218 219 220
    }
  }

221 222
  // Generate the factory function. Given a Locale it returns the corresponding
  // base class implementation.
223 224
  output.writeln('''

225 226
/// The set of supported languages, as language code strings.
///
227
/// The [$baseClass.delegate] can generate localizations for
228 229 230 231 232 233 234
/// any [Locale] with a language code from this set, regardless of the region.
/// Some regions have specific support (e.g. `de` covers all forms of German,
/// but there is support for `de-CH` specifically to override some of the
/// translations for Switzerland).
///
/// See also:
///
235
///  * [getMaterialTranslation], whose documentation describes these values.
236
final Set<String> kSupportedLanguages = HashSet<String>.from(const <String>[
237
${languageCodes.map<String>((String value) => "  '$value', // ${describeLocale(value)}").join('\n')}
238 239
]);

240
/// Creates a [$baseClass] instance for the given `locale`.
241
///
242 243
/// All of the function's arguments except `locale` will be passed to the [
/// $baseClass] constructor. (The `localeName` argument of that
244 245 246 247 248 249 250 251 252
/// constructor is specified by the actual subclass constructor by this
/// function.)
///
/// The following locales are supported by this package:
///
/// {@template flutter.localizations.languages}
$supportedLocales/// {@endtemplate}
///
/// Generally speaking, this method is only intended to be used by
253 254
/// [$baseClass.delegate].
$factoryDeclaration
255 256
  switch (locale.languageCode) {''');
  for (String language in languageToLocales.keys) {
257
    // Only one instance of the language.
258 259
    if (languageToLocales[language].length == 1) {
      output.writeln('''
260
    case '$language':
261
      return $generatedClassPrefix${camelCase(languageToLocales[language][0])}($factoryArguments);''');
262
    } else if (!languageToScriptCodes.containsKey(language)) { // Does not distinguish between scripts. Switch on countryCode directly.
263
      output.writeln('''
264 265
    case '$language': {
      switch (locale.countryCode) {''');
266 267
      for (LocaleInfo locale in languageToLocales[language]) {
        if (locale.originalString == language)
268
          continue;
269 270
        assert(locale.length > 1);
        final String countryCode = locale.countryCode;
271
        output.writeln('''
272
        case '$countryCode':
273
          return $generatedClassPrefix${camelCase(locale)}($factoryArguments);''');
274 275 276
      }
      output.writeln('''
      }
277
      return $generatedClassPrefix${camelCase(LocaleInfo.fromString(language))}($factoryArguments);
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302
    }''');
    } else { // Language has scriptCode, add additional switch logic.
      bool hasCountryCode = false;
      output.writeln('''
    case '$language': {
      switch (locale.scriptCode) {''');
      for (String scriptCode in languageToScriptCodes[language]) {
        final LocaleInfo scriptLocale = LocaleInfo.fromString(language + '_' + scriptCode);
        output.writeln('''
        case '$scriptCode': {''');
        if (languageAndScriptToCountryCodes.containsKey(scriptLocale)) {
          output.writeln('''
          switch (locale.countryCode) {''');
          for (LocaleInfo locale in languageToLocales[language]) {
            if (locale.countryCode == null)
              continue;
            else
              hasCountryCode = true;
            if (locale.originalString == language)
              continue;
            if (locale.scriptCode != scriptCode && locale.scriptCode != null)
              continue;
            final String countryCode = locale.countryCode;
            output.writeln('''
            case '$countryCode':
303
              return $generatedClassPrefix${camelCase(locale)}($factoryArguments);''');
304 305 306 307 308 309 310 311 312 313 314
          }
        }
        // Return a fallback locale that matches scriptCode, but not countryCode.
        //
        // Explicitly defined scriptCode fallback:
        if (languageToLocales[language].contains(scriptLocale)) {
          if (languageAndScriptToCountryCodes.containsKey(scriptLocale)) {
            output.writeln('''
          }''');
          }
          output.writeln('''
315
          return $generatedClassPrefix${camelCase(scriptLocale)}($factoryArguments);
316 317 318 319 320 321 322 323 324 325 326 327
        }''');
        } else {
          // Not Explicitly defined, fallback to first locale with the same language and
          // script:
          for (LocaleInfo locale in languageToLocales[language]) {
            if (locale.scriptCode != scriptCode)
              continue;
            if (languageAndScriptToCountryCodes.containsKey(scriptLocale)) {
              output.writeln('''
          }''');
            }
            output.writeln('''
328
          return $generatedClassPrefix${camelCase(scriptLocale)}($factoryArguments);
329 330 331 332
        }''');
            break;
          }
        }
333 334
      }
      output.writeln('''
335 336 337 338 339 340 341 342 343 344 345 346 347
      }''');
      if (hasCountryCode) {
      output.writeln('''
      switch (locale.countryCode) {''');
        for (LocaleInfo locale in languageToLocales[language]) {
          if (locale.originalString == language)
            continue;
          assert(locale.length > 1);
          if (locale.countryCode == null)
            continue;
          final String countryCode = locale.countryCode;
          output.writeln('''
        case '$countryCode':
348
          return $generatedClassPrefix${camelCase(locale)}($factoryArguments);''');
349 350 351
        }
        output.writeln('''
      }''');
352
      }
353
      output.writeln('''
354
      return $generatedClassPrefix${camelCase(LocaleInfo.fromString(language))}($factoryArguments);
355 356 357 358
    }''');
    }
  }
  output.writeln('''
359
  }
360
  assert(false, 'getMaterialTranslation() called for unsupported locale "\$locale"');
361
  return null;
362
}''');
363 364 365 366

  return output.toString();
}

367 368 369 370 371 372 373 374 375 376
/// Returns the appropriate type for getters with the given attributes.
///
/// Typically "String", but some (e.g. "timeOfDayFormat") return enums.
///
/// Used by [generateGetter] below.
String generateType(Map<String, dynamic> attributes) {
  if (attributes != null) {
    switch (attributes['x-flutter-type']) {
      case 'icuShortTimePattern':
        return 'TimeOfDayFormat';
377 378
      case 'scriptCategory':
        return 'ScriptCategory';
379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413
    }
  }
  return 'String';
}

/// Returns the appropriate name for getters with the given attributes.
///
/// Typically this is the key unmodified, but some have parameters, and
/// the GlobalMaterialLocalizations class does the substitution, and for
/// those we have to therefore provide an alternate name.
///
/// Used by [generateGetter] below.
String generateKey(String key, Map<String, dynamic> attributes) {
  if (attributes != null) {
    if (attributes.containsKey('parameters'))
      return '${key}Raw';
    switch (attributes['x-flutter-type']) {
      case 'icuShortTimePattern':
        return '${key}Raw';
    }
  }
  return key;
}

const Map<String, String> _icuTimeOfDayToEnum = <String, String>{
  'HH:mm': 'TimeOfDayFormat.HH_colon_mm',
  'HH.mm': 'TimeOfDayFormat.HH_dot_mm',
  "HH 'h' mm": 'TimeOfDayFormat.frenchCanadian',
  'HH:mm .': 'TimeOfDayFormat.HH_colon_mm',
  'H:mm': 'TimeOfDayFormat.H_colon_mm',
  'h:mm a': 'TimeOfDayFormat.h_colon_mm_space_a',
  'a h:mm': 'TimeOfDayFormat.a_space_h_colon_mm',
  'ah:mm': 'TimeOfDayFormat.a_space_h_colon_mm',
};

414 415 416 417 418 419
const Map<String, String> _scriptCategoryToEnum = <String, String>{
  'English-like': 'ScriptCategory.englishLike',
  'dense': 'ScriptCategory.dense',
  'tall': 'ScriptCategory.tall',
};

420 421 422 423 424 425 426 427 428 429 430
/// Returns the literal that describes the value returned by getters
/// with the given attributes.
///
/// This handles cases like the value being a literal `null`, an enum, and so
/// on. The default is to treat the value as a string and escape it and quote
/// it.
///
/// Used by [generateGetter] below.
String generateValue(String value, Map<String, dynamic> attributes) {
  if (value == null)
    return null;
431
  // cupertino_en.arb doesn't use x-flutter-type.
432 433 434 435
  if (attributes != null) {
    switch (attributes['x-flutter-type']) {
      case 'icuShortTimePattern':
        if (!_icuTimeOfDayToEnum.containsKey(value)) {
436
          throw Exception(
437 438 439 440 441 442
            '"$value" is not one of the ICU short time patterns supported '
            'by the material library. Here is the list of supported '
            'patterns:\n  ' + _icuTimeOfDayToEnum.keys.join('\n  ')
          );
        }
        return _icuTimeOfDayToEnum[value];
443 444 445 446 447 448 449 450 451
      case 'scriptCategory':
        if (!_scriptCategoryToEnum.containsKey(value)) {
          throw Exception(
            '"$value" is not one of the scriptCategory values supported '
            'by the material library. Here is the list of supported '
            'values:\n  ' + _scriptCategoryToEnum.keys.join('\n  ')
          );
        }
        return _scriptCategoryToEnum[value];
452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469
    }
  }
  return generateString(value);
}

/// Combines [generateType], [generateKey], and [generateValue] to return
/// the source of getters for the GlobalMaterialLocalizations subclass.
String generateGetter(String key, String value, Map<String, dynamic> attributes) {
  final String type = generateType(attributes);
  key = generateKey(key, attributes);
  value = generateValue(value, attributes);
      return '''

  @override
  $type get $key => $value;''';
}

Future<void> main(List<String> rawArgs) async {
470 471
  checkCwdIsRepoRoot('gen_localizations');
  final GeneratorOptions options = parseArgs(rawArgs);
472 473 474 475 476

  // filenames are assumed to end in "prefix_lc.arb" or "prefix_lc_cc.arb", where prefix
  // is the 2nd command line argument, lc is a language code and cc is the country
  // code. In most cases both codes are just two characters.

477
  final Directory directory = Directory(path.join('packages', 'flutter_localizations', 'lib', 'src', 'l10n'));
478
  final RegExp materialFilenameRE = RegExp(r'material_(\w+)\.arb$');
479
  final RegExp cupertinoFilenameRE = RegExp(r'cupertino_(\w+)\.arb$');
480

481
  try {
482
    validateEnglishLocalizations(File(path.join(directory.path, 'material_en.arb')));
483
    validateEnglishLocalizations(File(path.join(directory.path, 'cupertino_en.arb')));
484 485 486 487 488
  } on ValidationError catch (exception) {
    exitWithError('$exception');
  }

  await precacheLanguageAndRegionTags();
489

490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512
  // Maps of locales to resource key/value pairs for Material ARBs.
  final Map<LocaleInfo, Map<String, String>> materialLocaleToResources = <LocaleInfo, Map<String, String>>{};
  // Maps of locales to resource key/attributes pairs for Material ARBs..
  // https://github.com/googlei18n/app-resource-bundle/wiki/ApplicationResourceBundleSpecification#resource-attributes
  final Map<LocaleInfo, Map<String, dynamic>> materialLocaleToResourceAttributes = <LocaleInfo, Map<String, dynamic>>{};
  // Maps of locales to resource key/value pairs for Cupertino ARBs.
  final Map<LocaleInfo, Map<String, String>> cupertinoLocaleToResources = <LocaleInfo, Map<String, String>>{};
  // Maps of locales to resource key/attributes pairs for Cupertino ARBs..
  // https://github.com/googlei18n/app-resource-bundle/wiki/ApplicationResourceBundleSpecification#resource-attributes
  final Map<LocaleInfo, Map<String, dynamic>> cupertinoLocaleToResourceAttributes = <LocaleInfo, Map<String, dynamic>>{};

  loadMatchingArbsIntoBundleMaps(
    directory: directory,
    filenamePattern: materialFilenameRE,
    localeToResources: materialLocaleToResources,
    localeToResourceAttributes: materialLocaleToResourceAttributes,
  );
  loadMatchingArbsIntoBundleMaps(
    directory: directory,
    filenamePattern: cupertinoFilenameRE,
    localeToResources: cupertinoLocaleToResources,
    localeToResourceAttributes: cupertinoLocaleToResourceAttributes,
  );
513

514
  try {
515 516
    validateLocalizations(materialLocaleToResources, materialLocaleToResourceAttributes);
    validateLocalizations(cupertinoLocaleToResources, cupertinoLocaleToResourceAttributes);
517 518 519
  } on ValidationError catch (exception) {
    exitWithError('$exception');
  }
520

521 522 523 524 525 526 527 528 529 530
  final String materialLocalizations = generateArbBasedLocalizationSubclasses(
    localeToResources: materialLocaleToResources,
    localeToResourceAttributes: materialLocaleToResourceAttributes,
    generatedClassPrefix: 'MaterialLocalization',
    baseClass: 'GlobalMaterialLocalizations',
    generateHeader: generateMaterialHeader,
    generateConstructor: generateMaterialConstructor,
    factoryDeclaration: materialFactoryDeclaration,
    factoryArguments: materialFactoryArguments,
  );
531 532

  if (options.writeToFile) {
533
    final File localizationsFile = File(path.join(directory.path, 'generated_material_localizations.dart'));
534
    localizationsFile.writeAsStringSync(materialLocalizations, flush: true);
535
  } else {
536
    stdout.write(materialLocalizations);
537
  }
538
}