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

5
import 'package:meta/meta.dart';
6

7
import '../base/common.dart';
8 9 10
import '../base/file_system.dart';
import '../base/logger.dart';
import '../convert.dart';
11
import '../flutter_manifest.dart';
12

13 14
import 'gen_l10n_templates.dart';
import 'gen_l10n_types.dart';
15
import 'localizations_utils.dart';
16
import 'message_parser.dart';
17

18
/// Run the localizations generation script with the configuration [options].
19
LocalizationsGenerator generateLocalizations({
20 21 22 23 24
  required Directory projectDir,
  Directory? dependenciesDir,
  required LocalizationOptions options,
  required Logger logger,
  required FileSystem fileSystem,
25 26 27
}) {
  // If generating a synthetic package, generate a warning if
  // flutter: generate is not set.
28
  final FlutterManifest? flutterManifest = FlutterManifest.createFromPath(
29 30 31 32
    projectDir.childFile('pubspec.yaml').path,
    fileSystem: projectDir.fileSystem,
    logger: logger,
  );
33
  if (options.useSyntheticPackage && (flutterManifest == null || !flutterManifest.generateSyntheticPackage)) {
34
    throwToolExit(
35 36 37 38 39 40 41 42 43 44 45
      'Attempted to generate localizations code without having '
      'the flutter: generate flag turned on.'
      '\n'
      'Check pubspec.yaml and ensure that flutter: generate: true has '
      'been added and rebuild the project. Otherwise, the localizations '
      'source code will not be importable.'
    );
  }

  precacheLanguageAndRegionTags();

46 47 48
  final String inputPathString = options.arbDirectory?.path ?? fileSystem.path.join('lib', 'l10n');
  final String templateArbFileName = options.templateArbFile?.toFilePath() ?? 'app_en.arb';
  final String outputFileString = options.outputLocalizationsFile?.toFilePath() ?? 'app_localizations.dart';
49
  LocalizationsGenerator generator;
50
  try {
51 52 53 54 55 56 57
    generator = LocalizationsGenerator(
      fileSystem: fileSystem,
      inputsAndOutputsListPath: dependenciesDir?.path,
      projectPathString: projectDir.path,
      inputPathString: inputPathString,
      templateArbFileName: templateArbFileName,
      outputFileString: outputFileString,
58
      outputPathString: options.outputDirectory?.path,
59 60 61
      classNameString: options.outputClass ?? 'AppLocalizations',
      preferredSupportedLocales: options.preferredSupportedLocales,
      headerString: options.header,
62
      headerFile: options.headerFile?.toFilePath(),
63
      useDeferredLoading: options.deferredLoading ?? false,
64 65 66 67
      useSyntheticPackage: options.useSyntheticPackage,
      areResourceAttributesRequired: options.areResourceAttributesRequired,
      untranslatedMessagesFile: options.untranslatedMessagesFile?.toFilePath(),
      usesNullableGetter: options.usesNullableGetter,
68
      useEscaping: options.useEscaping,
69
      logger: logger,
70
      suppressWarnings: options.suppressWarnings,
71
    )
72
      ..loadResources()
73
      ..writeOutputFiles(isFromYaml: true);
74
  } on L10nException catch (e) {
75
    throwToolExit(e.message);
76
  }
77
  return generator;
78 79
}

80
/// The path for the synthetic package.
81
String _defaultSyntheticPackagePath(FileSystem fileSystem) => fileSystem.path.join('.dart_tool', 'flutter_gen');
82 83

/// The default path used when the `_useSyntheticPackage` setting is set to true
84 85 86 87
/// in [LocalizationsGenerator].
///
/// See [LocalizationsGenerator.initialize] for where and how it is used by the
/// localizations tool.
88
String _syntheticL10nPackagePath(FileSystem fileSystem) => fileSystem.path.join(_defaultSyntheticPackagePath(fileSystem), 'gen_l10n');
89

90 91 92 93
// Generate method parameters and also infer the correct types from the usage of the placeholders
// For example, if placeholders are used for plurals and no type was specified, then the type will
// automatically set to 'num'. Similarly, if such placeholders are used for selects, then the type
// will be set to 'String'. For such placeholders that are used for both, we should throw an error.
94
List<String> generateMethodParameters(Message message) {
95
  return message.placeholders.values.map((Placeholder placeholder) {
96
    return '${placeholder.type} ${placeholder.name}';
97 98 99
  }).toList();
}

100 101
// Similar to above, but is used for passing arguments into helper functions.
List<String> generateMethodArguments(Message message) {
102
  return message.placeholders.values.map((Placeholder placeholder) => placeholder.name).toList();
103 104
}

105
String generateDateFormattingLogic(Message message) {
106
  if (message.placeholders.isEmpty || !message.placeholdersRequireFormatting) {
107
    return '@(none)';
108
  }
109

110
  final Iterable<String> formatStatements = message.placeholders.values
111
    .where((Placeholder placeholder) => placeholder.requiresDateFormatting)
112
    .map((Placeholder placeholder) {
113 114
      final String? placeholderFormat = placeholder.format;
      if (placeholderFormat == null) {
115 116 117 118 119
        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'
120
          "Check the intl library's DateFormat class constructors for allowed "
121 122 123
          'date formats.'
        );
      }
124 125 126
      final bool? isCustomDateFormat = placeholder.isCustomDateFormat;
      if (!placeholder.hasValidDateFormat
          && (isCustomDateFormat == null || !isCustomDateFormat)) {
127
        throw L10nException(
128
          'Date format "$placeholderFormat" for placeholder '
129
          '${placeholder.name} does not have a corresponding DateFormat '
130
          "constructor\n. Check the intl library's DateFormat class "
131 132
          'constructors for allowed date formats, or set "isCustomDateFormat" attribute '
          'to "true".'
133 134
        );
      }
135 136 137 138 139 140
      if (placeholder.hasValidDateFormat) {
        return dateFormatTemplate
          .replaceAll('@(placeholder)', placeholder.name)
          .replaceAll('@(format)', placeholderFormat);
      }
      return dateFormatCustomTemplate
141
        .replaceAll('@(placeholder)', placeholder.name)
142
        .replaceAll('@(format)', "'${generateString(placeholderFormat)}'");
143
    });
144

145
  return formatStatements.isEmpty ? '@(none)' : formatStatements.join();
146 147
}

148 149 150
String generateNumberFormattingLogic(Message message) {
  if (message.placeholders.isEmpty || !message.placeholdersRequireFormatting) {
    return '@(none)';
151
  }
152

153
  final Iterable<String> formatStatements = message.placeholders.values
154
    .where((Placeholder placeholder) => placeholder.requiresNumFormatting)
155
    .map((Placeholder placeholder) {
156 157
      final String? placeholderFormat = placeholder.format;
      if (!placeholder.hasValidNumberFormat || placeholderFormat == null) {
158
        throw L10nException(
159
          'Number format $placeholderFormat for the ${placeholder.name} '
160
          'placeholder does not have a corresponding NumberFormat constructor.\n'
161
          "Check the intl library's NumberFormat class constructors for allowed "
162 163 164 165 166
          'number formats.'
        );
      }
      final Iterable<String> parameters =
        placeholder.optionalParameters.map<String>((OptionalParameter parameter) {
167 168 169
          if (parameter.value is num) {
            return '${parameter.name}: ${parameter.value}';
          } else {
170
            return "${parameter.name}: '${generateString(parameter.value.toString())}'";
171
          }
172 173
        },
      );
174 175 176 177

      if (placeholder.hasNumberFormatWithParameters) {
        return numberFormatNamedTemplate
            .replaceAll('@(placeholder)', placeholder.name)
178
            .replaceAll('@(format)', placeholderFormat)
179 180 181 182
            .replaceAll('@(parameters)', parameters.join(',\n      '));
      } else {
        return numberFormatPositionalTemplate
            .replaceAll('@(placeholder)', placeholder.name)
183
            .replaceAll('@(format)', placeholderFormat);
184
      }
185
    });
186

187
  return formatStatements.isEmpty ? '@(none)' : formatStatements.join();
188 189
}

190 191 192 193 194 195 196 197 198 199 200 201
/// List of possible cases for plurals defined the ICU messageFormat syntax.
Map<String, String> pluralCases = <String, String>{
  '0': 'zero',
  '1': 'one',
  '2': 'two',
  'zero': 'zero',
  'one': 'one',
  'two': 'two',
  'few': 'few',
  'many': 'many',
  'other': 'other',
};
202

203
String generateBaseClassMethod(Message message, LocaleInfo? templateArbLocale) {
204 205 206 207 208
  final String comment = message
    .description
    ?.split('\n')
    .map((String line) => '  /// $line')
    .join('\n') ?? '  /// No description provided for @${message.resourceId}.';
209 210
  final String templateLocaleTranslationComment = '''
  /// In $templateArbLocale, this message translates to:
211
  /// **'${generateString(message.value)}'**''';
212

213 214 215
  if (message.placeholders.isNotEmpty) {
    return baseClassMethodTemplate
      .replaceAll('@(comment)', comment)
216
      .replaceAll('@(templateLocaleTranslationComment)', templateLocaleTranslationComment)
217 218 219 220 221
      .replaceAll('@(name)', message.resourceId)
      .replaceAll('@(parameters)', generateMethodParameters(message).join(', '));
  }
  return baseClassGetterTemplate
    .replaceAll('@(comment)', comment)
222
    .replaceAll('@(templateLocaleTranslationComment)', templateLocaleTranslationComment)
223 224 225
    .replaceAll('@(name)', message.resourceId);
}

226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241
// Add spaces to pad the start of each line. Skips the first line
// assuming that the padding is already present.
String _addSpaces(String message, {int spaces = 0}) {
  bool isFirstLine = true;
  return message
    .split('\n')
    .map((String value) {
      if (isFirstLine) {
        isFirstLine = false;
        return value;
      }
      return value.padLeft(spaces);
    })
    .join('\n');
}

242 243 244 245
String _generateLookupByAllCodes(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
246 247 248 249 250 251 252 253 254
  final Iterable<LocaleInfo> localesWithAllCodes = allBundles.locales.where((LocaleInfo locale) {
    return locale.scriptCode != null && locale.countryCode != null;
  });

  if (localesWithAllCodes.isEmpty) {
    return '';
  }

  final Iterable<String> switchClauses = localesWithAllCodes.map<String>((LocaleInfo locale) {
255 256
    return generateSwitchClauseTemplate(locale)
      .replaceAll('@(case)', locale.toString());
257 258 259 260
  });

  return allCodesLookupTemplate.replaceAll(
    '@(allCodesSwitchClauses)',
261
    switchClauses.join('\n        '),
262 263 264
  );
}

265 266 267 268
String _generateLookupByScriptCode(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
269 270
  final Iterable<String> switchClauses = allBundles.languages.map((String language) {
    final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
271 272 273 274
    final Iterable<LocaleInfo> localesWithScriptCodes = locales.where((LocaleInfo locale) {
      return locale.scriptCode != null && locale.countryCode == null;
    });

275
    if (localesWithScriptCodes.isEmpty) {
276
      return null;
277
    }
278

279
    return _addSpaces(nestedSwitchTemplate
280
      .replaceAll('@(languageCode)', language)
281
      .replaceAll('@(code)', 'scriptCode')
282 283 284 285 286 287 288 289 290 291 292
      .replaceAll('@(switchClauses)',
        _addSpaces(
          localesWithScriptCodes.map((LocaleInfo locale) {
            return generateSwitchClauseTemplate(locale)
              .replaceAll('@(case)', locale.scriptCode!);
          }).join('\n'),
          spaces: 8,
        ),
      ),
      spaces: 4,
    );
293
  }).whereType<String>();
294 295 296 297 298 299 300

  if (switchClauses.isEmpty) {
    return '';
  }

  return languageCodeSwitchTemplate
    .replaceAll('@(comment)', '// Lookup logic when language+script codes are specified.')
301
    .replaceAll('@(switchClauses)', switchClauses.join('\n      '),
302 303 304
  );
}

305 306 307 308
String _generateLookupByCountryCode(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
309 310 311 312 313 314
  final Iterable<String> switchClauses = allBundles.languages.map((String language) {
    final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
    final Iterable<LocaleInfo> localesWithCountryCodes = locales.where((LocaleInfo locale) {
      return locale.countryCode != null && locale.scriptCode == null;
    });

315
    if (localesWithCountryCodes.isEmpty) {
316
      return null;
317
    }
318

319 320 321 322 323 324 325 326 327 328 329 330
    return _addSpaces(
      nestedSwitchTemplate
        .replaceAll('@(languageCode)', language)
        .replaceAll('@(code)', 'countryCode')
        .replaceAll('@(switchClauses)', _addSpaces(
          localesWithCountryCodes.map((LocaleInfo locale) {
            return generateSwitchClauseTemplate(locale).replaceAll('@(case)', locale.countryCode!);
          }).join('\n'),
          spaces: 4,
        )),
      spaces: 4,
    );
331
  }).whereType<String>();
332 333 334 335 336 337 338 339 340 341

  if (switchClauses.isEmpty) {
    return '';
  }

  return languageCodeSwitchTemplate
    .replaceAll('@(comment)', '// Lookup logic when language+country codes are specified.')
    .replaceAll('@(switchClauses)', switchClauses.join('\n    '));
}

342 343 344 345
String _generateLookupByLanguageCode(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
346 347 348 349 350 351
  final Iterable<String> switchClauses = allBundles.languages.map((String language) {
    final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
    final Iterable<LocaleInfo> localesWithLanguageCode = locales.where((LocaleInfo locale) {
      return locale.countryCode == null && locale.scriptCode == null;
    });

352
    if (localesWithLanguageCode.isEmpty) {
353
      return null;
354
    }
355 356

    return localesWithLanguageCode.map((LocaleInfo locale) {
357 358
      return generateSwitchClauseTemplate(locale)
        .replaceAll('@(case)', locale.languageCode);
359
    }).join('\n      ');
360
  }).whereType<String>();
361 362 363 364 365 366 367 368 369 370

  if (switchClauses.isEmpty) {
    return '';
  }

  return languageCodeSwitchTemplate
    .replaceAll('@(comment)', '// Lookup logic when only language code is specified.')
    .replaceAll('@(switchClauses)', switchClauses.join('\n    '));
}

371 372 373 374 375 376
String _generateLookupBody(
  AppResourceBundleCollection allBundles,
  String className,
  bool useDeferredLoading,
  String fileName,
) {
377
  String generateSwitchClauseTemplate(LocaleInfo locale) {
378 379 380 381 382
    return (useDeferredLoading ?
      switchClauseDeferredLoadingTemplate : switchClauseTemplate)
      .replaceAll('@(localeClass)', '$className${locale.camelCase()}')
      .replaceAll('@(appClass)', className)
      .replaceAll('@(library)', '${fileName}_${locale.languageCode}');
383
  }
384
  return lookupBodyTemplate
385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403
    .replaceAll('@(lookupAllCodesSpecified)', _generateLookupByAllCodes(
      allBundles,
      generateSwitchClauseTemplate,
    ))
    .replaceAll('@(lookupScriptCodeSpecified)', _generateLookupByScriptCode(
      allBundles,
      generateSwitchClauseTemplate,
    ))
    .replaceAll('@(lookupCountryCodeSpecified)', _generateLookupByCountryCode(
      allBundles,
      generateSwitchClauseTemplate,
    ))
    .replaceAll('@(lookupLanguageCodeSpecified)', _generateLookupByLanguageCode(
      allBundles,
      generateSwitchClauseTemplate,
    ));
}

String _generateDelegateClass({
404 405 406 407 408
  required AppResourceBundleCollection allBundles,
  required String className,
  required Set<String> supportedLanguageCodes,
  required bool useDeferredLoading,
  required String fileName,
409 410 411 412 413 414 415 416 417 418 419 420
}) {

  final String lookupBody = _generateLookupBody(
    allBundles,
    className,
    useDeferredLoading,
    fileName,
  );
  final String loadBody = (
    useDeferredLoading ? loadBodyDeferredLoadingTemplate : loadBodyTemplate
  )
    .replaceAll('@(class)', className)
421
    .replaceAll('@(lookupName)', 'lookup$className');
422 423 424
  final String lookupFunction = (useDeferredLoading ?
  lookupFunctionDeferredLoadingTemplate : lookupFunctionTemplate)
    .replaceAll('@(class)', className)
425
    .replaceAll('@(lookupName)', 'lookup$className')
426 427 428 429 430 431
    .replaceAll('@(lookupBody)', lookupBody);
  return delegateClassTemplate
    .replaceAll('@(class)', className)
    .replaceAll('@(loadBody)', loadBody)
    .replaceAll('@(supportedLanguageCodes)', supportedLanguageCodes.join(', '))
    .replaceAll('@(lookupFunction)', lookupFunction);
432 433
}

434
class LocalizationsGenerator {
435 436 437 438 439 440 441 442 443
  /// Initializes [inputDirectory], [outputDirectory], [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.
  factory LocalizationsGenerator({
444 445 446 447 448 449 450 451 452
    required FileSystem fileSystem,
    required String inputPathString,
    String? outputPathString,
    required String templateArbFileName,
    required String outputFileString,
    required String classNameString,
    List<String>? preferredSupportedLocales,
    String? headerString,
    String? headerFile,
453
    bool useDeferredLoading = false,
454
    String? inputsAndOutputsListPath,
455
    bool useSyntheticPackage = true,
456
    String? projectPathString,
457
    bool areResourceAttributesRequired = false,
458
    String? untranslatedMessagesFile,
459
    bool usesNullableGetter = true,
460
    bool useEscaping = false,
461
    required Logger logger,
462
    bool suppressWarnings = false,
463
  }) {
464
    final Directory? projectDirectory = projectDirFromPath(fileSystem, projectPathString);
465 466 467 468 469 470 471 472 473 474 475 476 477 478
    final Directory inputDirectory = inputDirectoryFromPath(fileSystem, inputPathString, projectDirectory);
    final Directory outputDirectory = outputDirectoryFromPath(fileSystem, outputPathString ?? inputPathString, useSyntheticPackage, projectDirectory);
    return LocalizationsGenerator._(
      fileSystem,
      useSyntheticPackage: useSyntheticPackage,
      usesNullableGetter: usesNullableGetter,
      className: classNameFromString(classNameString),
      projectDirectory: projectDirectory,
      inputDirectory: inputDirectory,
      outputDirectory: outputDirectory,
      templateArbFile: templateArbFileFromFileName(templateArbFileName, inputDirectory),
      baseOutputFile: outputDirectory.childFile(outputFileString),
      preferredSupportedLocales: preferredSupportedLocalesFromLocales(preferredSupportedLocales),
      header: headerFromFile(headerString, headerFile, inputDirectory),
479
      useDeferredLoading: useDeferredLoading,
480 481 482
      untranslatedMessagesFile: _untranslatedMessagesFileFromPath(fileSystem, untranslatedMessagesFile),
      inputsAndOutputsListFile: _inputsAndOutputsListFileFromPath(fileSystem, inputsAndOutputsListPath),
      areResourceAttributesRequired: areResourceAttributesRequired,
483
      useEscaping: useEscaping,
484
      logger: logger,
485
      suppressWarnings: suppressWarnings,
486 487 488
    );
  }

489 490 491
  /// Creates an instance of the localizations generator class.
  ///
  /// It takes in a [FileSystem] representation that the class will act upon.
492 493 494 495 496 497
  LocalizationsGenerator._(this._fs, {
    required this.inputDirectory,
    required this.outputDirectory,
    required this.templateArbFile,
    required this.baseOutputFile,
    required this.className,
498 499 500
    this.preferredSupportedLocales = const <LocaleInfo>[],
    this.header = '',
    this.useDeferredLoading = false,
501
    required this.inputsAndOutputsListFile,
502 503 504 505 506
    this.useSyntheticPackage = true,
    this.projectDirectory,
    this.areResourceAttributesRequired = false,
    this.untranslatedMessagesFile,
    this.usesNullableGetter = true,
507
    required this.logger,
508
    this.useEscaping = false,
509
    this.suppressWarnings = false,
510
  });
511

512
  final FileSystem _fs;
513
  List<Message> _allMessages = <Message>[];
514 515
  late final AppResourceBundleCollection _allBundles = AppResourceBundleCollection(inputDirectory);
  late final AppResourceBundle _templateBundle = AppResourceBundle(templateArbFile);
516 517 518
  late final Map<LocaleInfo, String> _inputFileNames = Map<LocaleInfo, String>.fromEntries(
    _allBundles.bundles.map((AppResourceBundle bundle) => MapEntry<LocaleInfo, String>(bundle.locale, bundle.file.basename))
  );
519
  late final LocaleInfo _templateArbLocale = _templateBundle.locale;
520 521 522 523

  @visibleForTesting
  final bool useSyntheticPackage;

524 525 526
  // Used to decide if the generated code is nullable or not
  // (whether AppLocalizations? or AppLocalizations is returned from
  // `static {name}Localizations{?} of (BuildContext context))`
527 528
  @visibleForTesting
  final bool usesNullableGetter;
529

530 531
  /// The directory that contains the project's arb files, as well as the
  /// header file, if specified.
532 533
  ///
  /// It is assumed that all input files (e.g. [templateArbFile], arb files
534
  /// for translated messages, header file templates) will reside here.
535
  final Directory inputDirectory;
536

537
  /// The Flutter project's root directory.
538
  final Directory? projectDirectory;
539

540 541 542
  /// The directory to generate the project's localizations files in.
  ///
  /// It is assumed that all output files (e.g. The localizations
543 544
  /// [outputFile], `messages_<locale>.dart` and `messages_all.dart`)
  /// will reside here.
545
  final Directory outputDirectory;
546

547 548
  /// The input arb file which defines all of the messages that will be
  /// exported by the generated class that's written to [outputFile].
549
  final File templateArbFile;
550

551 552 553 554
  /// The file to write the generated abstract localizations and
  /// localizations delegate classes to. Separate localizations
  /// files will also be generated for each language using this
  /// filename as a prefix and the locale as the suffix.
555
  final File baseOutputFile;
556 557 558 559 560

  /// 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.
561
  final String className;
562 563 564 565 566 567 568 569 570 571 572

  /// 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'.
573
  final List<LocaleInfo> preferredSupportedLocales;
574

575 576 577 578
  // Whether we need to import intl or not. This flag is updated after parsing
  // all of the messages.
  bool requiresIntlImport = false;

579 580 581
  // Whether we want to use escaping for ICU messages.
  bool useEscaping = false;

582
  /// The list of all arb path strings in [inputDirectory].
583 584 585
  List<String> get arbPathStrings {
    return _allBundles.bundles.map((AppResourceBundle bundle) => bundle.file.path).toList();
  }
586

587 588 589 590
  List<String> get outputFileList {
    return _outputFileList;
  }

591
  /// The supported language codes as found in the arb files located in
592
  /// [inputDirectory].
593
  final Set<String> supportedLanguageCodes = <String>{};
594 595

  /// The supported locales as found in the arb files located in
596
  /// [inputDirectory].
597 598
  final Set<LocaleInfo> supportedLocales = <LocaleInfo>{};

599
  /// The header to be prepended to the generated Dart localization file.
600
  final String header;
601

602 603
  final Map<LocaleInfo, List<String>> _unimplementedMessages = <LocaleInfo, List<String>>{};

604 605 606 607 608 609 610 611 612 613 614 615 616 617
  /// Whether to generate the Dart localization file with locales imported as
  /// deferred, allowing for lazy loading of each locale in Flutter web.
  ///
  /// This can reduce a web app’s initial startup time by decreasing the size of
  /// the JavaScript bundle. When [_useDeferredLoading] is set to true, the
  /// messages for a particular locale are only downloaded and loaded by the
  /// Flutter app as they are needed. For projects with a lot of different
  /// locales and many localization strings, it can be an performance
  /// improvement to have deferred loading. For projects with a small number of
  /// locales, the difference is negligible, and might slow down the start up
  /// compared to bundling the localizations with the rest of the application.
  ///
  /// Note that this flag does not affect other platforms such as mobile or
  /// desktop.
618
  final bool useDeferredLoading;
619

620 621 622 623
  /// Contains a map of each output language file to its corresponding content in
  /// string format.
  final Map<File, String> _languageFileMap = <File, String>{};

624 625
  /// A generated file that will contain the list of messages for each locale
  /// that do not have a translation yet.
626
  @visibleForTesting
627
  final File? untranslatedMessagesFile;
628

629 630
  /// The file that contains the list of inputs and outputs for generating
  /// localizations.
631
  @visibleForTesting
632
  final File? inputsAndOutputsListFile;
633 634
  final List<String> _inputFileList = <String>[];
  final List<String> _outputFileList = <String>[];
635

636 637 638 639
  /// Whether or not resource attributes are required for each corresponding
  /// resource id.
  ///
  /// Resource attributes provide metadata about the message.
640 641
  @visibleForTesting
  final bool areResourceAttributesRequired;
642

643 644 645
  /// Logger to be used during the execution of the script.
  Logger logger;

646 647 648
  /// Whether or not to suppress warnings or not.
  final bool suppressWarnings;

649 650 651 652 653 654 655 656 657 658 659 660 661 662
  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');
  }

663
  @visibleForTesting
664
  static Directory? projectDirFromPath(FileSystem fileSystem, String? projectPathString) {
665
    if (projectPathString == null) {
666
      return null;
667 668
    }

669
    final Directory directory = fileSystem.directory(projectPathString);
670 671 672
    if (!directory.existsSync()) {
      throw L10nException(
        'Directory does not exist: $directory.\n'
673
        "Please select a directory that contains the project's localizations "
674 675 676
        'resource files.'
      );
    }
677
    return directory;
678 679
  }

680
  /// Sets the reference [Directory] for [inputDirectory].
681
  @visibleForTesting
682
  static Directory inputDirectoryFromPath(FileSystem fileSystem, String inputPathString, Directory? projectDirectory) {
683
    final Directory inputDirectory = fileSystem.directory(
684
      projectDirectory != null
685
        ? _getAbsoluteProjectPath(inputPathString, projectDirectory)
686 687 688
        : inputPathString
    );

689 690
    if (!inputDirectory.existsSync()) {
      throw L10nException(
691
        "The 'arb-dir' directory, '$inputDirectory', does not exist.\n"
692 693
        'Make sure that the correct path was provided.'
      );
694
    }
695

696
    final FileStat fileStat = inputDirectory.statSync();
697 698
    if (_isNotReadable(fileStat) || _isNotWritable(fileStat)) {
      throw L10nException(
699
        "The 'arb-dir' directory, '$inputDirectory', doesn't allow reading and writing.\n"
700 701
        'Please ensure that the user has read and write permissions.'
      );
702
    }
703
    return inputDirectory;
704 705
  }

706 707
  /// Sets the reference [Directory] for [outputDirectory].
  @visibleForTesting
708
  static Directory outputDirectoryFromPath(FileSystem fileSystem, String outputPathString, bool useSyntheticPackage, Directory? projectDirectory) {
709 710 711
    Directory outputDirectory;
    if (useSyntheticPackage) {
      outputDirectory = fileSystem.directory(
712
        projectDirectory != null
713 714
          ? _getAbsoluteProjectPath(_syntheticL10nPackagePath(fileSystem), projectDirectory)
          : _syntheticL10nPackagePath(fileSystem)
715 716
      );
    } else {
717
      outputDirectory = fileSystem.directory(
718
        projectDirectory != null
719
          ? _getAbsoluteProjectPath(outputPathString, projectDirectory)
720 721 722
          : outputPathString
      );
    }
723
    return outputDirectory;
724 725
  }

726 727
  /// Sets the reference [File] for [templateArbFile].
  @visibleForTesting
728 729
  static File templateArbFileFromFileName(String templateArbFileName, Directory inputDirectory) {
    final File templateArbFile = inputDirectory.childFile(templateArbFileName);
730 731 732 733 734 735 736
    final FileStat templateArbFileStat = templateArbFile.statSync();
    if (templateArbFileStat.type == FileSystemEntityType.notFound) {
      throw L10nException(
        "The 'template-arb-file', $templateArbFile, does not exist."
      );
    }
    final String templateArbFileStatModeString = templateArbFileStat.modeString();
737 738
    if (templateArbFileStatModeString[0] == '-' && templateArbFileStatModeString[3] == '-') {
      throw L10nException(
739 740 741
        "The 'template-arb-file', $templateArbFile, is not readable.\n"
        'Please ensure that the user has read permissions.'
      );
742
    }
743
    return templateArbFile;
744 745
  }

746 747
  static bool _isValidClassName(String className) {
    // Public Dart class name cannot begin with an underscore
748
    if (className[0] == '_') {
749
      return false;
750
    }
751
    // Dart class name cannot contain non-alphanumeric symbols
752
    if (className.contains(RegExp(r'[^a-zA-Z_\d]'))) {
753
      return false;
754
    }
755
    // Dart class name must start with upper case character
756
    if (className[0].contains(RegExp(r'[a-z]'))) {
757
      return false;
758
    }
759
    // Dart class name cannot start with a number
760
    if (className[0].contains(RegExp(r'\d'))) {
761
      return false;
762
    }
763 764 765
    return true;
  }

766 767
  /// Sets the [className] for the localizations and localizations delegate
  /// classes.
768
  @visibleForTesting
769
  static String classNameFromString(String classNameString) {
770 771
    if (classNameString.isEmpty) {
      throw L10nException('classNameString argument cannot be empty');
772 773
    }
    if (!_isValidClassName(classNameString)) {
774
      throw L10nException(
775
        "The 'output-class', $classNameString, is not a valid public Dart class name.\n"
776
      );
777
    }
778
    return classNameString;
779 780
  }

781 782 783
  /// Sets [preferredSupportedLocales] so that this particular list of locales
  /// will take priority over the other locales.
  @visibleForTesting
784
  static List<LocaleInfo> preferredSupportedLocalesFromLocales(List<String>? inputLocales) {
785
    if (inputLocales == null || inputLocales.isEmpty) {
786
      return const <LocaleInfo>[];
787
    }
788 789 790
    return inputLocales.map((String localeString) {
      return LocaleInfo.fromString(localeString);
    }).toList();
791 792
  }

793
  static String headerFromFile(String? headerString, String? headerFile, Directory inputDirectory) {
794 795 796 797 798 799 800 801
    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) {
802
      return headerString;
803 804
    } else if (headerFile != null) {
      try {
805
        return inputDirectory.childFile(headerFile).readAsStringSync();
806 807 808 809 810 811 812
      } on FileSystemException catch (error) {
        throw L10nException (
          'Failed to read header file: "$headerFile". \n'
          'FileSystemException: ${error.message}'
        );
      }
    }
813
    return '';
814 815
  }

816 817
  static String _getAbsoluteProjectPath(String relativePath, Directory projectDirectory) =>
      projectDirectory.fileSystem.path.join(projectDirectory.path, relativePath);
818

819
  static File? _untranslatedMessagesFileFromPath(FileSystem fileSystem, String? untranslatedMessagesFileString) {
820
    if (untranslatedMessagesFileString == null || untranslatedMessagesFileString.isEmpty) {
821
      return null;
822 823
    }

824
    return fileSystem.file(untranslatedMessagesFileString);
825 826
  }

827
  static File? _inputsAndOutputsListFileFromPath(FileSystem fileSystem, String? inputsAndOutputsListPath) {
828
    if (inputsAndOutputsListPath == null) {
829
      return null;
830
    }
831

832 833
    return fileSystem.file(
      fileSystem.path.join(inputsAndOutputsListPath, 'gen_l10n_inputs_and_outputs.json'),
834 835 836
    );
  }

837
  static bool _isValidGetterAndMethodName(String name) {
838 839 840
    if (name.isEmpty) {
      return false;
    }
841
    // Public Dart method name must not start with an underscore
842
    if (name[0] == '_') {
843
      return false;
844
    }
845 846
    // Dart identifiers can only use letters, numbers, underscores, and `$`
    if (name.contains(RegExp(r'[^a-zA-Z_$\d]'))) {
847
      return false;
848
    }
849
    // Dart getter and method name should start with lower case character
850
    if (name[0].contains(RegExp(r'[A-Z]'))) {
851
      return false;
852
    }
853
    // Dart getter and method name cannot start with a number
854
    if (name[0].contains(RegExp(r'\d'))) {
855
      return false;
856
    }
857 858 859
    return true;
  }

860
  // Load _allMessages from templateArbFile and _allBundles from all of the ARB
861
  // files in inputDirectory. Also initialized: supportedLocales.
862
  void loadResources() {
863
    for (final String resourceId in _templateBundle.resourceIds) {
864 865 866 867 868 869 870 871
      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.'
        );
      }
872
    }
873 874 875 876
    // The call to .toList() is absolutely necessary. Otherwise, it is an iterator and will call Message's constructor again.
    _allMessages = _templateBundle.resourceIds.map((String id) => Message(
       _templateBundle, _allBundles, id, areResourceAttributesRequired, useEscaping: useEscaping, logger: logger,
    )).toList();
877
    if (inputsAndOutputsListFile != null) {
878 879 880 881
      _inputFileList.addAll(_allBundles.bundles.map((AppResourceBundle bundle) {
        return bundle.file.absolute.path;
      }));
    }
882

883 884 885 886
    final List<LocaleInfo> allLocales = List<LocaleInfo>.from(_allBundles.locales);
    for (final LocaleInfo preferredLocale in preferredSupportedLocales) {
      final int index = allLocales.indexOf(preferredLocale);
      if (index == -1) {
887
        throw L10nException(
888 889 890 891
          "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.'
892
        );
893
      }
894 895
      allLocales.removeAt(index);
      allLocales.insertAll(0, preferredSupportedLocales);
896
    }
897
    supportedLocales.addAll(allLocales);
898 899
  }

900 901
  void _addUnimplementedMessage(LocaleInfo locale, String message) {
    if (_unimplementedMessages.containsKey(locale)) {
902
      _unimplementedMessages[locale]!.add(message);
903 904 905 906 907 908 909 910 911
    } else {
      _unimplementedMessages.putIfAbsent(locale, () => <String>[message]);
    }
  }

  String _generateBaseClassFile(
    String className,
    String fileName,
    String header,
912
    final LocaleInfo locale,
913
  ) {
914 915
    final Iterable<String> methods = _allMessages.map((Message message) {
      if (message.messages[locale] == null) {
916
        _addUnimplementedMessage(locale, message.resourceId);
917 918 919 920
        return _generateMethod(
          message,
          _templateArbLocale,
        );
921
      }
922
      return _generateMethod(
923
        message,
924
        locale,
925 926 927 928
      );
    });

    return classFileTemplate
929
      .replaceAll('@(header)', header.isEmpty ? '' : '$header\n\n')
930 931 932 933 934
      .replaceAll('@(language)', describeLocale(locale.toString()))
      .replaceAll('@(baseClass)', className)
      .replaceAll('@(fileName)', fileName)
      .replaceAll('@(class)', '$className${locale.camelCase()}')
      .replaceAll('@(localeName)', locale.toString())
935
      .replaceAll('@(methods)', methods.join('\n\n'))
936
      .replaceAll('@(requiresIntlImport)', requiresIntlImport ? "import 'package:intl/intl.dart' as intl;\n\n" : '');
937 938 939 940 941 942 943 944 945
  }

  String _generateSubclass(
    String className,
    AppResourceBundle bundle,
  ) {
    final LocaleInfo locale = bundle.locale;
    final String baseClassName = '$className${LocaleInfo.fromString(locale.languageCode).camelCase()}';

946 947
    _allMessages
      .where((Message message) => message.messages[locale] == null)
948 949 950 951
      .forEach((Message message) {
        _addUnimplementedMessage(locale, message.resourceId);
      });

952 953 954
    final Iterable<String> methods = _allMessages
      .where((Message message) => message.messages[locale] != null)
      .map((Message message) => _generateMethod(message, locale));
955 956 957 958 959 960 961 962 963

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

964
  // Generate the AppLocalizations class, its LocalizationsDelegate subclass,
965 966
  // and all AppLocalizations subclasses for every locale. This method by
  // itself does not generate the output files.
967
  String _generateCode() {
968 969
    bool isBaseClassLocale(LocaleInfo locale, String language) {
      return locale.languageCode == language
970
          && locale.countryCode == null
971 972 973 974 975 976 977 978 979 980 981 982 983
          && locale.scriptCode == null;
    }

    List<LocaleInfo> getLocalesForLanguage(String language) {
      return _allBundles.bundles
        // Return locales for the language specified, except for the base locale itself
        .where((AppResourceBundle bundle) {
          final LocaleInfo locale = bundle.locale;
          return !isBaseClassLocale(locale, language) && locale.languageCode == language;
        })
        .map((AppResourceBundle bundle) => bundle.locale).toList();
    }

984 985
    final String directory = _fs.path.basename(outputDirectory.path);
    final String outputFileName = _fs.path.basename(baseOutputFile.path);
986 987 988 989 990 991
    if (!outputFileName.endsWith('.dart')) {
      throw L10nException(
        "The 'output-localization-file', $outputFileName, is invalid.\n"
        'The file name must have a .dart extension.'
      );
    }
992 993

    final Iterable<String> supportedLocalesCode = supportedLocales.map((LocaleInfo locale) {
994
      final String languageCode = locale.languageCode;
995 996
      final String? countryCode = locale.countryCode;
      final String? scriptCode = locale.scriptCode;
997 998

      if (countryCode == null && scriptCode == null) {
999
        return "Locale('$languageCode')";
1000
      } else if (countryCode != null && scriptCode == null) {
1001
        return "Locale('$languageCode', '$countryCode')";
1002
      } else if (countryCode != null && scriptCode != null) {
1003
        return "Locale.fromSubtags(languageCode: '$languageCode', countryCode: '$countryCode', scriptCode: '$scriptCode')";
1004
      } else {
1005
        return "Locale.fromSubtags(languageCode: '$languageCode', scriptCode: '$scriptCode')";
1006
      }
1007 1008 1009
    });

    final Set<String> supportedLanguageCodes = Set<String>.from(
1010
      _allBundles.locales.map<String>((LocaleInfo locale) => "'${locale.languageCode}'")
1011
    );
1012 1013

    final List<LocaleInfo> allLocales = _allBundles.locales.toList()..sort();
1014 1015 1016 1017 1018 1019 1020 1021 1022
    final int extensionIndex = outputFileName.indexOf('.');
    if (extensionIndex <= 0) {
      throw L10nException(
        "The 'output-localization-file', $outputFileName, is invalid.\n"
        'The base name cannot be empty.'
      );
    }
    final String fileName = outputFileName.substring(0, extensionIndex);
    final String fileExtension = outputFileName.substring(extensionIndex + 1);
1023
    for (final LocaleInfo locale in allLocales) {
1024
      if (isBaseClassLocale(locale, locale.languageCode)) {
1025
        final File languageMessageFile = outputDirectory.childFile('${fileName}_$locale.$fileExtension');
1026 1027 1028 1029

        // Generate the template for the base class file. Further string
        // interpolation will be done to determine if there are
        // subclasses that extend the base class.
1030
        final String languageBaseClassFile = _generateBaseClassFile(
1031 1032 1033
          className,
          outputFileName,
          header,
1034
          locale,
1035 1036 1037 1038 1039 1040 1041
        );

        // Every locale for the language except the base class.
        final List<LocaleInfo> localesForLanguage = getLocalesForLanguage(locale.languageCode);

        // Generate every subclass that is needed for the particular language
        final Iterable<String> subclasses = localesForLanguage.map<String>((LocaleInfo locale) {
1042 1043
          return _generateSubclass(
            className,
1044
            _allBundles.bundleFor(locale)!,
1045 1046 1047
          );
        });

1048 1049 1050
        _languageFileMap.putIfAbsent(languageMessageFile, () {
          return languageBaseClassFile.replaceAll('@(subclasses)', subclasses.join());
        });
1051
      }
1052 1053
    }

1054
    final List<String> sortedClassImports = supportedLocales
1055 1056
      .where((LocaleInfo locale) => isBaseClassLocale(locale, locale.languageCode))
      .map((LocaleInfo locale) {
1057
        final String library = '${fileName}_$locale';
1058
        if (useDeferredLoading) {
1059
          return "import '$library.$fileExtension' deferred as $library;";
1060
        } else {
1061
          return "import '$library.$fileExtension';";
1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073
        }
      })
      .toList()
      ..sort();

    final String delegateClass = _generateDelegateClass(
      allBundles: _allBundles,
      className: className,
      supportedLanguageCodes: supportedLanguageCodes,
      useDeferredLoading: useDeferredLoading,
      fileName: fileName,
    );
1074

1075
    return fileTemplate
1076
      .replaceAll('@(header)', header.isEmpty ? '' : '$header\n')
1077
      .replaceAll('@(class)', className)
1078
      .replaceAll('@(methods)', _allMessages.map((Message message) => generateBaseClassMethod(message, _templateArbLocale)).join('\n'))
1079 1080 1081
      .replaceAll('@(importFile)', '$directory/$outputFileName')
      .replaceAll('@(supportedLocales)', supportedLocalesCode.join(',\n    '))
      .replaceAll('@(supportedLanguageCodes)', supportedLanguageCodes.join(', '))
1082
      .replaceAll('@(messageClassImports)', sortedClassImports.join('\n'))
1083
      .replaceAll('@(delegateClass)', delegateClass)
1084
      .replaceAll('@(requiresFoundationImport)', useDeferredLoading ? '' : "import 'package:flutter/foundation.dart';")
1085
      .replaceAll('@(requiresIntlImport)', requiresIntlImport ? "import 'package:intl/intl.dart' as intl;" : '')
1086
      .replaceAll('@(canBeNullable)', usesNullableGetter ? '?' : '')
1087 1088 1089 1090 1091
      .replaceAll('@(needsNullCheck)', usesNullableGetter ? '' : '!')
      // Removes all trailing whitespace from the generated file.
      .split('\n').map((String line) => line.trimRight()).join('\n')
      // Cleans out unnecessary newlines.
      .replaceAll('\n\n\n', '\n\n');
1092 1093
  }

1094
  String _generateMethod(Message message, LocaleInfo locale) {
1095
    try {
1096 1097 1098 1099 1100
    // Determine if we must import intl for date or number formatting.
    if (message.placeholdersRequireFormatting) {
      requiresIntlImport = true;
    }

1101 1102
    final String translationForMessage = message.messages[locale]!;
    final Node node = message.parsedMessages[locale]!;
1103 1104 1105 1106 1107 1108 1109 1110
    // If parse tree is only a string, then return a getter method.
    if (node.children.every((Node child) => child.type == ST.string)) {
      // Use the parsed translation to handle escaping with the same behavior.
      return getterTemplate
        .replaceAll('@(name)', message.resourceId)
        .replaceAll('@(message)', "'${generateString(node.children.map((Node child) => child.value!).join())}'");
    }

1111 1112 1113 1114 1115
    final List<String> tempVariables = <String>[];
    // Get a unique temporary variable name.
    int variableCount = 0;
    String getTempVariableName() {
      return '_temp${variableCount++}';
1116 1117
    }

1118 1119 1120 1121 1122 1123
    // Do a DFS post order traversal through placeholderExpr, pluralExpr, and selectExpr nodes.
    // When traversing through a placeholderExpr node, return "$placeholderName".
    // When traversing through a pluralExpr node, return "$tempVarN" and add variable declaration in "tempVariables".
    // When traversing through a selectExpr node, return "$tempVarN" and add variable declaration in "tempVariables".
    // When traversing through a message node, return concatenation of all of "generateVariables(child)" for each child.
    String generateVariables(Node node, { bool isRoot = false }) {
1124 1125
      switch (node.type) {
        case ST.message:
1126
          final List<String> expressions = node.children.map<String>((Node node) {
1127
            if (node.type == ST.string) {
1128
              return node.value!;
1129
            }
1130
            return generateVariables(node);
1131
          }).toList();
1132
          return generateReturnExpr(expressions);
1133 1134 1135

        case ST.placeholderExpr:
          assert(node.children[1].type == ST.identifier);
1136 1137 1138 1139
          final String identifier = node.children[1].value!;
          final Placeholder placeholder = message.placeholders[identifier]!;
          if (placeholder.requiresFormatting) {
            return '\$${node.children[1].value}String';
1140
          }
1141
          return '\$${node.children[1].value}';
1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168

        case ST.pluralExpr:
          requiresIntlImport = true;
          final Map<String, String> pluralLogicArgs = <String, String>{};
          // Recall that pluralExpr are of the form
          // pluralExpr := "{" ID "," "plural" "," pluralParts "}"
          assert(node.children[1].type == ST.identifier);
          assert(node.children[5].type == ST.pluralParts);

          final Node identifier = node.children[1];
          final Node pluralParts = node.children[5];

          for (final Node pluralPart in pluralParts.children.reversed) {
            String pluralCase;
            Node pluralMessage;
            if (pluralPart.children[0].value == '=') {
              assert(pluralPart.children[1].type == ST.number);
              assert(pluralPart.children[3].type == ST.message);
              pluralCase = pluralPart.children[1].value!;
              pluralMessage = pluralPart.children[3];
            } else {
              assert(pluralPart.children[0].type == ST.identifier || pluralPart.children[0].type == ST.other);
              assert(pluralPart.children[2].type == ST.message);
              pluralCase = pluralPart.children[0].value!;
              pluralMessage = pluralPart.children[2];
            }
            if (!pluralLogicArgs.containsKey(pluralCases[pluralCase])) {
1169
              final String pluralPartExpression = generateVariables(pluralMessage);
1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183
              final String? transformedPluralCase = pluralCases[pluralCase];
              // A valid plural case is one of "=0", "=1", "=2", "zero", "one", "two", "few", "many", or "other".
              if (transformedPluralCase == null) {
                throw L10nParserException(
                  '''
The plural cases must be one of "=0", "=1", "=2", "zero", "one", "two", "few", "many", or "other.
    $pluralCase is not a valid plural case.''',
                  _inputFileNames[locale]!,
                  message.resourceId,
                  translationForMessage,
                  pluralPart.positionInMessage,
                );
              }
              pluralLogicArgs[transformedPluralCase] = '      ${pluralCases[pluralCase]}: $pluralPartExpression,';
1184
            } else if (!suppressWarnings) {
1185
              logger.printWarning('''
1186 1187 1188
[${_inputFileNames[locale]}:${message.resourceId}] ICU Syntax Warning: The plural part specified below is overridden by a later plural part.
    $translationForMessage
    ${Parser.indentForError(pluralPart.positionInMessage)}''');
1189 1190
            }
          }
1191 1192 1193
          final String tempVarName = getTempVariableName();
          tempVariables.add(pluralVariableTemplate
            .replaceAll('@(varName)', tempVarName)
1194 1195 1196
            .replaceAll('@(count)', identifier.value!)
            .replaceAll('@(pluralLogicArgs)', pluralLogicArgs.values.join('\n'))
          );
1197
          return '\$$tempVarName';
1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213

        case ST.selectExpr:
          requiresIntlImport = true;
          // Recall that pluralExpr are of the form
          // pluralExpr := "{" ID "," "plural" "," pluralParts "}"
          assert(node.children[1].type == ST.identifier);
          assert(node.children[5].type == ST.selectParts);

          final Node identifier = node.children[1];
          final List<String> selectLogicArgs = <String>[];
          final Node selectParts = node.children[5];
          for (final Node selectPart in selectParts.children) {
            assert(selectPart.children[0].type == ST.identifier || selectPart.children[0].type == ST.other);
            assert(selectPart.children[2].type == ST.message);
            final String selectCase = selectPart.children[0].value!;
            final Node selectMessage = selectPart.children[2];
1214 1215
            final String selectPartExpression = generateVariables(selectMessage);
            selectLogicArgs.add("        '$selectCase': $selectPartExpression,");
1216
          }
1217 1218 1219
          final String tempVarName = getTempVariableName();
          tempVariables.add(selectVariableTemplate
            .replaceAll('@(varName)', tempVarName)
1220 1221 1222
            .replaceAll('@(choice)', identifier.value!)
            .replaceAll('@(selectCases)', selectLogicArgs.join('\n'))
          );
1223
          return '\$$tempVarName';
1224 1225 1226 1227 1228
        // ignore: no_default_cases
        default:
          throw Exception('Cannot call "generateHelperMethod" on node type ${node.type}');
      }
    }
1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242
    final String messageString = generateVariables(node, isRoot: true);
    final String tempVarLines = tempVariables.isEmpty ? '' : '${tempVariables.join('\n')}\n';
    return methodTemplate
              .replaceAll('@(name)', message.resourceId)
              .replaceAll('@(parameters)', generateMethodParameters(message).join(', '))
              .replaceAll('@(dateFormatting)', generateDateFormattingLogic(message))
              .replaceAll('@(numberFormatting)', generateNumberFormattingLogic(message))
              .replaceAll('@(tempVars)', tempVarLines)
              .replaceAll('@(message)', messageString)
              .replaceAll('@(none)\n', '');
    } on L10nParserException catch (error) {
      logger.printError(error.toString());
      return '';
    }
1243
  }
1244

1245
  List<String> writeOutputFiles({ bool isFromYaml = false }) {
1246
    // First, generate the string contents of all necessary files.
1247
    final String generatedLocalizationsFile = _generateCode();
1248

1249 1250 1251 1252 1253
    // If there were any syntax errors, don't write to files.
    if (logger.hadErrorOutput) {
      throw L10nException('Found syntax errors.');
    }

1254 1255
    // A pubspec.yaml file is required when using a synthetic package. If it does not
    // exist, create a blank one.
1256
    if (useSyntheticPackage) {
1257 1258 1259
      final Directory syntheticPackageDirectory = projectDirectory != null
          ? projectDirectory!.childDirectory(_defaultSyntheticPackagePath(_fs))
          : _fs.directory(_defaultSyntheticPackagePath(_fs));
1260 1261 1262 1263 1264 1265 1266
      syntheticPackageDirectory.createSync(recursive: true);
      final File flutterGenPubspec = syntheticPackageDirectory.childFile('pubspec.yaml');
      if (!flutterGenPubspec.existsSync()) {
        flutterGenPubspec.writeAsStringSync(emptyPubspecTemplate);
      }
    }

1267 1268
    // Since all validity checks have passed up to this point,
    // write the contents into the directory.
1269
    outputDirectory.createSync(recursive: true);
1270 1271 1272

    // Ensure that the created directory has read/write permissions.
    final FileStat fileStat = outputDirectory.statSync();
1273 1274
    if (_isNotReadable(fileStat) || _isNotWritable(fileStat)) {
      throw L10nException(
1275 1276 1277
        "The 'output-dir' directory, $outputDirectory, doesn't allow reading and writing.\n"
        'Please ensure that the user has read and write permissions.'
      );
1278
    }
1279 1280 1281 1282

    // Generate the required files for localizations.
    _languageFileMap.forEach((File file, String contents) {
      file.writeAsStringSync(contents);
1283
      _outputFileList.add(file.absolute.path);
1284
    });
1285

1286 1287 1288 1289
    baseOutputFile.writeAsStringSync(generatedLocalizationsFile);
    final File? messagesFile = untranslatedMessagesFile;
    if (messagesFile != null) {
      _generateUntranslatedMessagesFile(logger, messagesFile);
1290 1291 1292 1293
    } else if (_unimplementedMessages.isNotEmpty) {
      _unimplementedMessages.forEach((LocaleInfo locale, List<String> messages) {
        logger.printStatus('"$locale": ${messages.length} untranslated message(s).');
      });
1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309
      if (isFromYaml) {
        logger.printStatus(
          'To see a detailed report, use the untranslated-messages-file \n'
          'option in the l10n.yaml file:\n'
          'untranslated-messages-file: desiredFileName.txt\n'
          '<other option>: <other selection> \n\n'
        );
      } else {
        logger.printStatus(
          'To see a detailed report, use the --untranslated-messages-file \n'
          'option in the flutter gen-l10n tool:\n'
          'flutter gen-l10n --untranslated-messages-file=desiredFileName.txt\n'
          '<other options> \n\n'
        );
      }

1310
      logger.printStatus(
1311 1312
        'This will generate a JSON format file containing all messages that \n'
        'need to be translated.'
1313 1314
      );
    }
1315
    final File? inputsAndOutputsListFileLocal = inputsAndOutputsListFile;
1316
    _outputFileList.add(baseOutputFile.absolute.path);
1317
    if (inputsAndOutputsListFileLocal != null) {
1318
      // Generate a JSON file containing the inputs and outputs of the gen_l10n script.
1319 1320
      if (!inputsAndOutputsListFileLocal.existsSync()) {
        inputsAndOutputsListFileLocal.createSync(recursive: true);
1321 1322
      }

1323
      inputsAndOutputsListFileLocal.writeAsStringSync(
1324 1325 1326 1327 1328 1329
        json.encode(<String, Object> {
          'inputs': _inputFileList,
          'outputs': _outputFileList,
        }),
      );
    }
1330 1331

    return _outputFileList;
1332
  }
1333

1334
  void _generateUntranslatedMessagesFile(Logger logger, File untranslatedMessagesFile) {
1335
    if (_unimplementedMessages.isEmpty) {
1336
      untranslatedMessagesFile.writeAsStringSync('{}');
1337
      _outputFileList.add(untranslatedMessagesFile.absolute.path);
1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363
      return;
    }

    String resultingFile = '{\n';
    int count = 0;
    final int numberOfLocales = _unimplementedMessages.length;
    _unimplementedMessages.forEach((LocaleInfo locale, List<String> messages) {
      resultingFile += '  "$locale": [\n';

      for (int i = 0; i < messages.length; i += 1) {
        resultingFile += '    "${messages[i]}"';
        if (i != messages.length - 1) {
          resultingFile += ',';
        }
        resultingFile += '\n';
      }

      resultingFile += '  ]';
      count += 1;
      if (count < numberOfLocales) {
        resultingFile += ',\n';
      }
      resultingFile += '\n';
    });

    resultingFile += '}\n';
1364
    untranslatedMessagesFile.writeAsStringSync(resultingFile);
1365
    _outputFileList.add(untranslatedMessagesFile.absolute.path);
1366
  }
1367
}