gen_l10n.dart 22.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

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

8 9
import 'package:file/file.dart' as file;
import 'package:meta/meta.dart';
10 11
import 'package:path/path.dart' as path;

12 13
import 'gen_l10n_templates.dart';
import 'gen_l10n_types.dart';
14 15
import 'localizations_utils.dart';

16 17 18
List<String> generateMethodParameters(Message message) {
  assert(message.placeholders.isNotEmpty);
  final Placeholder countPlaceholder = message.isPlural ? message.getCountPlaceholder() : null;
19
  return message.placeholders.map((Placeholder placeholder) {
20 21
    final String type = placeholder == countPlaceholder ? 'int' : placeholder.type;
    return '$type ${placeholder.name}';
22 23 24
  }).toList();
}

25
String generateDateFormattingLogic(Message message) {
26 27
  if (message.placeholders.isEmpty || !message.placeholdersRequireFormatting)
    return '@(none)';
28

29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
  final Iterable<String> formatStatements = message.placeholders
    .where((Placeholder placeholder) => placeholder.isDate)
    .map((Placeholder placeholder) {
      if (placeholder.format == null) {
        throw L10nException(
          'The placeholder, ${placeholder.name}, has its "type" resource attribute set to '
          'the "${placeholder.type}" type. To properly resolve for the right '
          '${placeholder.type} format, the "format" attribute needs to be set '
          'to determine which DateFormat to use. \n'
          'Check the intl library\'s DateFormat class constructors for allowed '
          'date formats.'
        );
      }
      if (!placeholder.hasValidDateFormat) {
        throw L10nException(
          'Date format "${placeholder.format}" for placeholder '
          '${placeholder.name} does not have a corresponding DateFormat '
          'constructor\n. Check the intl library\'s DateFormat class '
          'constructors for allowed date formats.'
        );
      }
      return dateFormatTemplate
        .replaceAll('@(placeholder)', placeholder.name)
        .replaceAll('@(format)', placeholder.format);
    });
54

55
  return formatStatements.isEmpty ? '@(none)' : formatStatements.join('');
56 57
}

58 59 60
String generateNumberFormattingLogic(Message message) {
  if (message.placeholders.isEmpty || !message.placeholdersRequireFormatting) {
    return '@(none)';
61
  }
62

63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
  final Iterable<String> formatStatements = message.placeholders
    .where((Placeholder placeholder) => placeholder.isNumber)
    .map((Placeholder placeholder) {
      if (!placeholder.hasValidNumberFormat) {
        throw L10nException(
          'Number format ${placeholder.format} for the ${placeholder.name} '
          'placeholder does not have a corresponding NumberFormat constructor.\n'
          'Check the intl library\'s NumberFormat class constructors for allowed '
          'number formats.'
        );
      }
      final Iterable<String> parameters =
        placeholder.optionalParameters.map<String>((OptionalParameter parameter) {
          return '${parameter.name}: ${parameter.value}';
        },
      );
      return numberFormatTemplate
        .replaceAll('@(placeholder)', placeholder.name)
        .replaceAll('@(format)', placeholder.format)
        .replaceAll('@(parameters)', parameters.join(',    \n'));
    });
84

85
  return formatStatements.isEmpty ? '@(none)' : formatStatements.join('');
86 87
}

88 89
String generatePluralMethod(Message message) {
  if (message.placeholders.isEmpty) {
90
    throw L10nException(
91
      'Unable to find placeholders for the plural message: ${message.resourceId}.\n'
92 93 94
      'Check to see if the plural message is in the proper ICU syntax format '
      'and ensure that placeholders are properly specified.'
    );
95
  }
96 97 98

  // To make it easier to parse the plurals message, temporarily replace each
  // "{placeholder}" parameter with "#placeholder#".
99 100 101
  String easyMessage = message.value;
  for (final Placeholder placeholder in message.placeholders)
    easyMessage = easyMessage.replaceAll('{${placeholder.name}}', '#${placeholder.name}#');
102

103 104 105 106 107 108 109 110 111
  final Placeholder countPlaceholder = message.getCountPlaceholder();
  if (countPlaceholder == null) {
    throw L10nException(
      'Unable to find the count placeholder for the plural message: ${message.resourceId}.\n'
      'Check to see if the plural message is in the proper ICU syntax format '
      'and ensure that placeholders are properly specified.'
    );
  }

112 113 114 115 116 117 118 119
  const Map<String, String> pluralIds = <String, String>{
    '=0': 'zero',
    '=1': 'one',
    '=2': 'two',
    'few': 'few',
    'many': 'many',
    'other': 'other'
  };
120

121
  final List<String> pluralLogicArgs = <String>[];
122
  for (final String pluralKey in pluralIds.keys) {
123
    final RegExp expRE = RegExp('($pluralKey)\\s*{([^}]+)}');
124
    final RegExpMatch match = expRE.firstMatch(easyMessage);
125
    if (match != null && match.groupCount == 2) {
126
      String argValue = match.group(2);
127
      for (final Placeholder placeholder in message.placeholders) {
128
        if (placeholder != countPlaceholder && placeholder.requiresFormatting) {
129
          argValue = argValue.replaceAll('#${placeholder.name}#', '\${${placeholder.name}String}');
130
        } else {
131
          argValue = argValue.replaceAll('#${placeholder.name}#', '\${${placeholder.name}}');
132 133
        }
      }
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164
      pluralLogicArgs.add("      ${pluralIds[pluralKey]}: '$argValue'");
    }
  }

  final List<String> parameters = message.placeholders.map((Placeholder placeholder) {
    final String placeholderType = placeholder == countPlaceholder ? 'int' : placeholder.type;
    return '$placeholderType ${placeholder.name}';
  }).toList();

  final String comment = message.description ?? 'No description provided in @${message.resourceId}';

  return pluralMethodTemplate
    .replaceAll('@(comment)', comment)
    .replaceAll('@(name)', message.resourceId)
    .replaceAll('@(parameters)', parameters.join(', '))
    .replaceAll('@(dateFormatting)', generateDateFormattingLogic(message))
    .replaceAll('@(numberFormatting)', generateNumberFormattingLogic(message))
    .replaceAll('@(count)', countPlaceholder.name)
    .replaceAll('@(pluralLogicArgs)', pluralLogicArgs.join(',\n'))
    .replaceAll('@(none)\n', '');
}

String generateMethod(Message message, AppResourceBundle bundle) {
  String generateMessage() {
    String messageValue = bundle.translationFor(message);
    for (final Placeholder placeholder in message.placeholders) {
      if (placeholder.requiresFormatting) {
        messageValue = messageValue.replaceAll('{${placeholder.name}}', '\${${placeholder.name}String}');
      } else {
        messageValue = messageValue.replaceAll('{${placeholder.name}}', '\${${placeholder.name}}');
      }
165
    }
166 167 168 169 170

    // Escape single and double quotes.
    messageValue = messageValue.replaceAll("'", '\\\'');
    messageValue = messageValue.replaceAll('"', '\\\"');

171
    return "'$messageValue'";
172 173
  }

174 175
  if (message.isPlural) {
    return generatePluralMethod(message);
176 177
  }

178
  if (message.placeholdersRequireFormatting) {
179 180 181
    return formatMethodTemplate
      .replaceAll('@(name)', message.resourceId)
      .replaceAll('@(parameters)', generateMethodParameters(message).join(', '))
182 183
      .replaceAll('@(dateFormatting)', generateDateFormattingLogic(message))
      .replaceAll('@(numberFormatting)', generateNumberFormattingLogic(message))
184 185
      .replaceAll('@(message)', generateMessage())
      .replaceAll('@(none)\n', '');
186
  }
187

188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252
  if (message.placeholders.isNotEmpty) {
    return methodTemplate
      .replaceAll('@(name)', message.resourceId)
      .replaceAll('@(parameters)', generateMethodParameters(message).join(', '))
      .replaceAll('@(message)', generateMessage());
  }

  return getterTemplate
    .replaceAll('@(name)', message.resourceId)
    .replaceAll('@(message)', generateMessage());
}

String generateClass(String className, AppResourceBundle bundle, Iterable<Message> messages) {
  final LocaleInfo locale = bundle.locale;

  String baseClassName = className;
  if (locale.countryCode != null) {
    baseClassName = '$className${LocaleInfo.fromString(locale.languageCode).camelCase()}';
  }

  final Iterable<String> methods = messages
    .where((Message message) => bundle.translationFor(message) != null)
    .map((Message message) => generateMethod(message, bundle));

  return classTemplate
    .replaceAll('@(language)', describeLocale(locale.toString()))
    .replaceAll('@(baseClass)', baseClassName)
    .replaceAll('@(class)', '$className${locale.camelCase()}')
    .replaceAll('@(localeName)', locale.toString())
    .replaceAll('@(methods)', methods.join('\n\n'));
}

String generateBaseClassMethod(Message message) {
  final String comment = message.description ?? 'No description provided in @${message.resourceId}';
  if (message.placeholders.isNotEmpty) {
    return baseClassMethodTemplate
      .replaceAll('@(comment)', comment)
      .replaceAll('@(name)', message.resourceId)
      .replaceAll('@(parameters)', generateMethodParameters(message).join(', '));
  }
  return baseClassGetterTemplate
    .replaceAll('@(comment)', comment)
    .replaceAll('@(name)', message.resourceId);
}

String generateLookupBody(AppResourceBundleCollection allBundles, String className) {
  final Iterable<String> switchClauses = allBundles.languages.map((String language) {
    final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
    if (locales.length == 1) {
      return switchClauseTemplate
        .replaceAll('@(case)', language)
        .replaceAll('@(class)', '$className${locales.first.camelCase()}');
    }

    final Iterable<LocaleInfo> localesWithCountryCodes = locales.where((LocaleInfo locale) => locale.countryCode != null);
    return countryCodeSwitchTemplate
      .replaceAll('@(languageCode)', language)
      .replaceAll('@(class)', '$className${LocaleInfo.fromString(language).camelCase()}')
      .replaceAll('@(switchClauses)', localesWithCountryCodes.map((LocaleInfo locale) {
          return switchClauseTemplate
            .replaceAll('@(case)', locale.countryCode)
            .replaceAll('@(class)', '$className${locale.camelCase()}');
        }).join('\n        '));
  });
  return switchClauses.join('\n    ');
253 254
}

255 256 257 258 259
class LocalizationsGenerator {
  /// Creates an instance of the localizations generator class.
  ///
  /// It takes in a [FileSystem] representation that the class will act upon.
  LocalizationsGenerator(this._fs);
260

261
  final file.FileSystem _fs;
262 263 264
  Iterable<Message> _allMessages;
  AppResourceBundleCollection _allBundles;

265 266 267 268 269 270 271 272 273
  /// The reference to the project's l10n directory.
  ///
  /// It is assumed that all input files (e.g. [templateArbFile], arb files
  /// for translated messages) and output files (e.g. The localizations
  /// [outputFile], `messages_<locale>.dart` and `messages_all.dart`)
  /// will reside here.
  ///
  /// This directory is specified with the [initialize] method.
  Directory l10nDirectory;
274

275 276 277 278 279
  /// The input arb file which defines all of the messages that will be
  /// exported by the generated class that's written to [outputFile].
  ///
  /// This file is specified with the [initialize] method.
  File templateArbFile;
280

281 282 283 284 285 286 287 288 289 290 291 292 293 294
  /// The file to write the generated localizations and localizations delegate
  /// classes to.
  ///
  /// This file is specified with the [initialize] method.
  File outputFile;

  /// The class name to be used for the localizations class in [outputFile].
  ///
  /// For example, if 'AppLocalizations' is passed in, a class named
  /// AppLocalizations will be used for localized message lookups.
  ///
  /// The class name is specified with the [initialize] method.
  String get className => _className;
  String _className;
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309

  /// The list of preferred supported locales.
  ///
  /// By default, the list of supported locales in the localizations class
  /// will be sorted in alphabetical order. However, this option
  /// allows for a set of preferred locales to appear at the top of the
  /// list.
  ///
  /// The order of locales in this list will also be the order of locale
  /// priority. For example, if a device supports 'en' and 'es' and
  /// ['es', 'en'] is passed in, the 'es' locale will take priority over 'en'.
  ///
  /// The list of preferred locales is specified with the [initialize] method.
  List<LocaleInfo> get preferredSupportedLocales => _preferredSupportedLocales;
  List<LocaleInfo> _preferredSupportedLocales;
310

311
  /// The list of all arb path strings in [l10nDirectory].
312 313 314
  List<String> get arbPathStrings {
    return _allBundles.bundles.map((AppResourceBundle bundle) => bundle.file.path).toList();
  }
315 316 317

  /// The supported language codes as found in the arb files located in
  /// [l10nDirectory].
318
  final Set<String> supportedLanguageCodes = <String>{};
319 320 321

  /// The supported locales as found in the arb files located in
  /// [l10nDirectory].
322 323
  final Set<LocaleInfo> supportedLocales = <LocaleInfo>{};

324 325 326
  /// The header to be prepended to the generated Dart localization file.
  String header = '';

327 328 329 330 331 332 333 334 335 336 337 338
  /// Initializes [l10nDirectory], [templateArbFile], [outputFile] and [className].
  ///
  /// Throws an [L10nException] when a provided configuration is not allowed
  /// by [LocalizationsGenerator].
  ///
  /// Throws a [FileSystemException] when a file operation necessary for setting
  /// up the [LocalizationsGenerator] cannot be completed.
  void initialize({
    String l10nDirectoryPath,
    String templateArbFileName,
    String outputFileString,
    String classNameString,
339
    String preferredSupportedLocaleString,
340 341
    String headerString,
    String headerFile,
342 343 344 345
  }) {
    setL10nDirectory(l10nDirectoryPath);
    setTemplateArbFile(templateArbFileName);
    setOutputFile(outputFileString);
346
    setPreferredSupportedLocales(preferredSupportedLocaleString);
347
    _setHeader(headerString, headerFile);
348 349
    className = classNameString;
  }
350

351 352 353 354 355 356 357 358 359 360 361 362 363 364
  static bool _isNotReadable(FileStat fileStat) {
    final String rawStatString = fileStat.modeString();
    // Removes potential prepended permission bits, such as '(suid)' and '(guid)'.
    final String statString = rawStatString.substring(rawStatString.length - 9);
    return !(statString[0] == 'r' || statString[3] == 'r' || statString[6] == 'r');
  }

  static bool _isNotWritable(FileStat fileStat) {
    final String rawStatString = fileStat.modeString();
    // Removes potential prepended permission bits, such as '(suid)' and '(guid)'.
    final String statString = rawStatString.substring(rawStatString.length - 9);
    return !(statString[1] == 'w' || statString[4] == 'w' || statString[7] == 'w');
  }

365 366 367 368 369 370 371 372 373 374 375 376 377 378 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
  /// Sets the reference [Directory] for [l10nDirectory].
  @visibleForTesting
  void setL10nDirectory(String arbPathString) {
    if (arbPathString == null)
      throw L10nException('arbPathString argument cannot be null');
    l10nDirectory = _fs.directory(arbPathString);
    if (!l10nDirectory.existsSync())
      throw FileSystemException(
        "The 'arb-dir' directory, $l10nDirectory, does not exist.\n"
        'Make sure that the correct path was provided.'
      );

    final FileStat fileStat = l10nDirectory.statSync();
    if (_isNotReadable(fileStat) || _isNotWritable(fileStat))
      throw FileSystemException(
        "The 'arb-dir' directory, $l10nDirectory, doesn't allow reading and writing.\n"
        'Please ensure that the user has read and write permissions.'
      );
  }

  /// Sets the reference [File] for [templateArbFile].
  @visibleForTesting
  void setTemplateArbFile(String templateArbFileName) {
    if (templateArbFileName == null)
      throw L10nException('templateArbFileName argument cannot be null');
    if (l10nDirectory == null)
      throw L10nException('l10nDirectory cannot be null when setting template arb file');

    templateArbFile = _fs.file(path.join(l10nDirectory.path, templateArbFileName));
    final String templateArbFileStatModeString = templateArbFile.statSync().modeString();
    if (templateArbFileStatModeString[0] == '-' && templateArbFileStatModeString[3] == '-')
      throw FileSystemException(
        "The 'template-arb-file', $templateArbFile, is not readable.\n"
        'Please ensure that the user has read permissions.'
      );
  }

  /// Sets the reference [File] for the localizations delegate [outputFile].
  @visibleForTesting
  void setOutputFile(String outputFileString) {
    if (outputFileString == null)
      throw L10nException('outputFileString argument cannot be null');
    outputFile = _fs.file(path.join(l10nDirectory.path, outputFileString));
  }

410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425
  static bool _isValidClassName(String className) {
    // Public Dart class name cannot begin with an underscore
    if (className[0] == '_')
      return false;
    // Dart class name cannot contain non-alphanumeric symbols
    if (className.contains(RegExp(r'[^a-zA-Z_\d]')))
      return false;
    // Dart class name must start with upper case character
    if (className[0].contains(RegExp(r'[a-z]')))
      return false;
    // Dart class name cannot start with a number
    if (className[0].contains(RegExp(r'\d')))
      return false;
    return true;
  }

426 427
  /// Sets the [className] for the localizations and localizations delegate
  /// classes.
428 429
  @visibleForTesting
  set className(String classNameString) {
430 431
    if (classNameString == null || classNameString.isEmpty)
      throw L10nException('classNameString argument cannot be null or empty');
432 433
    if (!_isValidClassName(classNameString))
      throw L10nException(
434
        "The 'output-class', $classNameString, is not a valid public Dart class name.\n"
435 436 437 438
      );
    _className = classNameString;
  }

439 440 441 442
  /// Sets [preferredSupportedLocales] so that this particular list of locales
  /// will take priority over the other locales.
  @visibleForTesting
  void setPreferredSupportedLocales(String inputLocales) {
443 444 445
    if (inputLocales == null || inputLocales.trim().isEmpty) {
      _preferredSupportedLocales = const <LocaleInfo>[];
    } else {
446 447 448 449 450 451 452 453 454 455
      final List<dynamic> preferredLocalesStringList = json.decode(inputLocales) as List<dynamic>;
      _preferredSupportedLocales = preferredLocalesStringList.map((dynamic localeString) {
        if (localeString.runtimeType != String) {
          throw L10nException('Incorrect runtime type for $localeString');
        }
        return LocaleInfo.fromString(localeString.toString());
      }).toList();
    }
  }

456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477
  void _setHeader(String headerString, String headerFile) {
    if (headerString != null && headerFile != null) {
      throw L10nException(
        'Cannot accept both header and header file arguments. \n'
        'Please make sure to define only one or the other. '
      );
    }

    if (headerString != null) {
      header = headerString;
    } else if (headerFile != null) {
      try {
        header = _fs.file(path.join(l10nDirectory.path, headerFile)).readAsStringSync();
      } on FileSystemException catch (error) {
        throw L10nException (
          'Failed to read header file: "$headerFile". \n'
          'FileSystemException: ${error.message}'
        );
      }
    }
  }

478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493
  static bool _isValidGetterAndMethodName(String name) {
    // Public Dart method name must not start with an underscore
    if (name[0] == '_')
      return false;
    // Dart getter and method name cannot contain non-alphanumeric symbols
    if (name.contains(RegExp(r'[^a-zA-Z_\d]')))
      return false;
    // Dart method name must start with lower case character
    if (name[0].contains(RegExp(r'[A-Z]')))
      return false;
    // Dart class name cannot start with a number
    if (name[0].contains(RegExp(r'\d')))
      return false;
    return true;
  }

494 495 496 497 498 499 500 501 502 503 504 505 506 507
  // Load _allMessages from templateArbFile and _allBundles from all of the ARB
  // files in l10nDirectory. Also initialized: supportedLocales.
  void loadResources() {
    final AppResourceBundle templateBundle = AppResourceBundle(templateArbFile);
    _allMessages = templateBundle.resourceIds.map((String id) => Message(templateBundle.resources, id));
    for (final String resourceId in templateBundle.resourceIds)
      if (!_isValidGetterAndMethodName(resourceId)) {
        throw L10nException(
          'Invalid ARB resource name "$resourceId" in $templateArbFile.\n'
          'Resources names must be valid Dart method names: they have to be '
          'camel case, cannot start with a number or underscore, and cannot '
          'contain non-alphanumeric characters.'
        );
      }
508

509
    _allBundles = AppResourceBundleCollection(l10nDirectory);
510

511 512 513 514
    final List<LocaleInfo> allLocales = List<LocaleInfo>.from(_allBundles.locales);
    for (final LocaleInfo preferredLocale in preferredSupportedLocales) {
      final int index = allLocales.indexOf(preferredLocale);
      if (index == -1) {
515
        throw L10nException(
516 517 518 519
          "The preferred supported locale, '$preferredLocale', cannot be "
          'added. Please make sure that there is a corresponding ARB file '
          'with translations for the locale, or remove the locale from the '
          'preferred supported locale list.'
520
        );
521
      }
522 523
      allLocales.removeAt(index);
      allLocales.insertAll(0, preferredSupportedLocales);
524
    }
525
    supportedLocales.addAll(allLocales);
526 527
  }

528 529
  // Generate the AppLocalizations class, its LocalizationsDelegate subclass.
  String generateCode() {
530 531
    final String directory = path.basename(l10nDirectory.path);
    final String outputFileName = path.basename(outputFile.path);
532 533 534 535 536 537 538 539 540

    final Iterable<String> supportedLocalesCode = supportedLocales.map((LocaleInfo locale) {
      final String country = locale.countryCode;
      final String countryArg = country == null ? '' : ', $country';
      return 'Locale(\'${locale.languageCode}$countryArg\')';
    });

    final Set<String> supportedLanguageCodes = Set<String>.from(
      _allBundles.locales.map<String>((LocaleInfo locale) => '\'${locale.languageCode}\'')
541
    );
542 543 544 545 546 547 548 549 550 551 552 553 554

    final StringBuffer allMessagesClasses = StringBuffer();
    final List<LocaleInfo> allLocales = _allBundles.locales.toList()..sort();
    for (final LocaleInfo locale in allLocales) {
      allMessagesClasses.writeln();
      allMessagesClasses.writeln(
        generateClass(className, _allBundles.bundleFor(locale), _allMessages)
      );
    }

    final String lookupBody = generateLookupBody(_allBundles, className);

    return fileTemplate
555
      .replaceAll('@(header)', header)
556 557 558 559 560 561 562 563 564 565 566 567
      .replaceAll('@(class)', className)
      .replaceAll('@(methods)', _allMessages.map(generateBaseClassMethod).join('\n'))
      .replaceAll('@(importFile)', '$directory/$outputFileName')
      .replaceAll('@(supportedLocales)', supportedLocalesCode.join(',\n    '))
      .replaceAll('@(supportedLanguageCodes)', supportedLanguageCodes.join(', '))
      .replaceAll('@(allMessagesClasses)', allMessagesClasses.toString().trim())
      .replaceAll('@(lookupName)', '_lookup$className')
      .replaceAll('@(lookupBody)', lookupBody);
  }

  void writeOutputFile() {
    outputFile.writeAsStringSync(generateCode());
568
  }
569
}