gen_l10n.dart 57.1 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
import 'package:process/process.dart';
7

8
import '../artifacts.dart';
9
import '../base/common.dart';
10
import '../base/file_system.dart';
11
import '../base/io.dart';
12 13
import '../base/logger.dart';
import '../convert.dart';
14
import '../flutter_manifest.dart';
15 16
import 'gen_l10n_templates.dart';
import 'gen_l10n_types.dart';
17
import 'localizations_utils.dart';
18
import 'message_parser.dart';
19

20
/// Run the localizations generation script with the configuration [options].
21
Future<LocalizationsGenerator> generateLocalizations({
22 23 24 25 26
  required Directory projectDir,
  Directory? dependenciesDir,
  required LocalizationOptions options,
  required Logger logger,
  required FileSystem fileSystem,
27 28 29
  required Artifacts artifacts,
  required ProcessManager processManager,
}) async {
30 31
  // If generating a synthetic package, generate a warning if
  // flutter: generate is not set.
32
  final FlutterManifest? flutterManifest = FlutterManifest.createFromPath(
33 34 35 36
    projectDir.childFile('pubspec.yaml').path,
    fileSystem: projectDir.fileSystem,
    logger: logger,
  );
37
  if (options.syntheticPackage && (flutterManifest == null || !flutterManifest.generateSyntheticPackage)) {
38
    throwToolExit(
39 40 41 42 43 44 45 46 47 48 49
      '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();

50 51 52
  // Use \r\n if project's pubspec file contains \r\n.
  final bool useCRLF = fileSystem.file('pubspec.yaml').readAsStringSync().contains('\r\n');

53
  LocalizationsGenerator generator;
54
  try {
55 56 57 58
    generator = LocalizationsGenerator(
      fileSystem: fileSystem,
      inputsAndOutputsListPath: dependenciesDir?.path,
      projectPathString: projectDir.path,
59 60 61 62 63
      inputPathString: options.arbDir,
      templateArbFileName: options.templateArbFile,
      outputFileString: options.outputLocalizationFile,
      outputPathString: options.outputDir,
      classNameString: options.outputClass,
64 65
      preferredSupportedLocales: options.preferredSupportedLocales,
      headerString: options.header,
66 67 68 69 70 71
      headerFile: options.headerFile,
      useDeferredLoading: options.useDeferredLoading,
      useSyntheticPackage: options.syntheticPackage,
      areResourceAttributesRequired: options.requiredResourceAttributes,
      untranslatedMessagesFile: options.untranslatedMessagesFile,
      usesNullableGetter: options.nullableGetter,
72
      useEscaping: options.useEscaping,
73
      logger: logger,
74
      suppressWarnings: options.suppressWarnings,
75
      useRelaxedSyntax: options.relaxSyntax,
76
    )
77
      ..loadResources()
78
      ..writeOutputFiles(isFromYaml: true, useCRLF: useCRLF);
79
  } on L10nException catch (e) {
80
    throwToolExit(e.message);
81
  }
82 83

  if (options.format) {
84 85 86 87
    // Only format Dart files using `dart format`.
    final List<String> formatFileList = generator.outputFileList
        .where((String e) => e.endsWith('.dart'))
        .toList(growable: false);
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
    if (formatFileList.isEmpty) {
      return generator;
    }
    final String dartBinary = artifacts.getArtifactPath(Artifact.engineDartBinary);
    final List<String> command = <String>[dartBinary, 'format', ...formatFileList];
    final ProcessResult result = await processManager.run(command);
    if (result.exitCode != 0) {
      throw ProcessException(
        dartBinary,
        command,
        '''
`dart format` failed with exit code ${result.exitCode}

stdout:\n${result.stdout}\n
stderr:\n${result.stderr}''',
        result.exitCode,
      );
    }
  }

108
  return generator;
109 110
}

111
/// The path for the synthetic package.
112
String _defaultSyntheticPackagePath(FileSystem fileSystem) => fileSystem.path.join('.dart_tool', 'flutter_gen');
113 114

/// The default path used when the `_useSyntheticPackage` setting is set to true
115 116 117 118
/// in [LocalizationsGenerator].
///
/// See [LocalizationsGenerator.initialize] for where and how it is used by the
/// localizations tool.
119
String _syntheticL10nPackagePath(FileSystem fileSystem) => fileSystem.path.join(_defaultSyntheticPackagePath(fileSystem), 'gen_l10n');
120

121 122 123 124
// 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.
125
List<String> generateMethodParameters(Message message) {
126
  return message.placeholders.values.map((Placeholder placeholder) {
127
    return '${placeholder.type} ${placeholder.name}';
128 129 130
  }).toList();
}

131 132
// Similar to above, but is used for passing arguments into helper functions.
List<String> generateMethodArguments(Message message) {
133
  return message.placeholders.values.map((Placeholder placeholder) => placeholder.name).toList();
134 135
}

136
String generateDateFormattingLogic(Message message) {
137
  if (message.placeholders.isEmpty || !message.placeholdersRequireFormatting) {
138
    return '@(none)';
139
  }
140

141
  final Iterable<String> formatStatements = message.placeholders.values
142
    .where((Placeholder placeholder) => placeholder.requiresDateFormatting)
143
    .map((Placeholder placeholder) {
144 145
      final String? placeholderFormat = placeholder.format;
      if (placeholderFormat == null) {
146 147 148 149 150
        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'
151
          "Check the intl library's DateFormat class constructors for allowed "
152 153 154
          'date formats.'
        );
      }
155 156 157
      final bool? isCustomDateFormat = placeholder.isCustomDateFormat;
      if (!placeholder.hasValidDateFormat
          && (isCustomDateFormat == null || !isCustomDateFormat)) {
158
        throw L10nException(
159
          'Date format "$placeholderFormat" for placeholder '
160
          '${placeholder.name} does not have a corresponding DateFormat '
161
          "constructor\n. Check the intl library's DateFormat class "
162 163
          'constructors for allowed date formats, or set "isCustomDateFormat" attribute '
          'to "true".'
164 165
        );
      }
166 167 168 169 170 171
      if (placeholder.hasValidDateFormat) {
        return dateFormatTemplate
          .replaceAll('@(placeholder)', placeholder.name)
          .replaceAll('@(format)', placeholderFormat);
      }
      return dateFormatCustomTemplate
172
        .replaceAll('@(placeholder)', placeholder.name)
173
        .replaceAll('@(format)', "'${generateString(placeholderFormat)}'");
174
    });
175

176
  return formatStatements.isEmpty ? '@(none)' : formatStatements.join();
177 178
}

179 180 181
String generateNumberFormattingLogic(Message message) {
  if (message.placeholders.isEmpty || !message.placeholdersRequireFormatting) {
    return '@(none)';
182
  }
183

184
  final Iterable<String> formatStatements = message.placeholders.values
185
    .where((Placeholder placeholder) => placeholder.requiresNumFormatting)
186
    .map((Placeholder placeholder) {
187 188
      final String? placeholderFormat = placeholder.format;
      if (!placeholder.hasValidNumberFormat || placeholderFormat == null) {
189
        throw L10nException(
190
          'Number format $placeholderFormat for the ${placeholder.name} '
191
          'placeholder does not have a corresponding NumberFormat constructor.\n'
192
          "Check the intl library's NumberFormat class constructors for allowed "
193 194 195 196 197
          'number formats.'
        );
      }
      final Iterable<String> parameters =
        placeholder.optionalParameters.map<String>((OptionalParameter parameter) {
198 199 200
          if (parameter.value is num) {
            return '${parameter.name}: ${parameter.value}';
          } else {
201
            return "${parameter.name}: '${generateString(parameter.value.toString())}'";
202
          }
203 204
        },
      );
205 206 207 208

      if (placeholder.hasNumberFormatWithParameters) {
        return numberFormatNamedTemplate
            .replaceAll('@(placeholder)', placeholder.name)
209
            .replaceAll('@(format)', placeholderFormat)
210 211 212 213
            .replaceAll('@(parameters)', parameters.join(',\n      '));
      } else {
        return numberFormatPositionalTemplate
            .replaceAll('@(placeholder)', placeholder.name)
214
            .replaceAll('@(format)', placeholderFormat);
215
      }
216
    });
217

218
  return formatStatements.isEmpty ? '@(none)' : formatStatements.join();
219 220
}

221 222 223 224 225 226 227 228 229 230 231 232
/// 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',
};
233

234
String generateBaseClassMethod(Message message, LocaleInfo? templateArbLocale) {
235 236 237 238 239
  final String comment = message
    .description
    ?.split('\n')
    .map((String line) => '  /// $line')
    .join('\n') ?? '  /// No description provided for @${message.resourceId}.';
240 241
  final String templateLocaleTranslationComment = '''
  /// In $templateArbLocale, this message translates to:
242
  /// **'${generateString(message.value)}'**''';
243

244 245 246
  if (message.placeholders.isNotEmpty) {
    return baseClassMethodTemplate
      .replaceAll('@(comment)', comment)
247
      .replaceAll('@(templateLocaleTranslationComment)', templateLocaleTranslationComment)
248 249 250 251 252
      .replaceAll('@(name)', message.resourceId)
      .replaceAll('@(parameters)', generateMethodParameters(message).join(', '));
  }
  return baseClassGetterTemplate
    .replaceAll('@(comment)', comment)
253
    .replaceAll('@(templateLocaleTranslationComment)', templateLocaleTranslationComment)
254 255 256
    .replaceAll('@(name)', message.resourceId);
}

257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272
// 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');
}

273 274 275 276
String _generateLookupByAllCodes(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
277 278 279 280 281 282 283 284 285
  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) {
286 287
    return generateSwitchClauseTemplate(locale)
      .replaceAll('@(case)', locale.toString());
288 289 290 291
  });

  return allCodesLookupTemplate.replaceAll(
    '@(allCodesSwitchClauses)',
292
    switchClauses.join('\n        '),
293 294 295
  );
}

296 297 298 299
String _generateLookupByScriptCode(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
300 301
  final Iterable<String> switchClauses = allBundles.languages.map((String language) {
    final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
302 303 304 305
    final Iterable<LocaleInfo> localesWithScriptCodes = locales.where((LocaleInfo locale) {
      return locale.scriptCode != null && locale.countryCode == null;
    });

306
    if (localesWithScriptCodes.isEmpty) {
307
      return null;
308
    }
309

310
    return _addSpaces(nestedSwitchTemplate
311
      .replaceAll('@(languageCode)', language)
312
      .replaceAll('@(code)', 'scriptCode')
313 314 315 316 317 318 319 320 321 322 323
      .replaceAll('@(switchClauses)',
        _addSpaces(
          localesWithScriptCodes.map((LocaleInfo locale) {
            return generateSwitchClauseTemplate(locale)
              .replaceAll('@(case)', locale.scriptCode!);
          }).join('\n'),
          spaces: 8,
        ),
      ),
      spaces: 4,
    );
324
  }).whereType<String>();
325 326 327 328 329 330 331

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

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

336 337 338 339
String _generateLookupByCountryCode(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
340 341 342 343 344 345
  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;
    });

346
    if (localesWithCountryCodes.isEmpty) {
347
      return null;
348
    }
349

350 351 352 353 354 355 356 357 358 359 360 361
    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,
    );
362
  }).whereType<String>();
363 364 365 366 367 368 369 370 371 372

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

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

373 374 375 376
String _generateLookupByLanguageCode(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
377 378 379 380 381 382
  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;
    });

383
    if (localesWithLanguageCode.isEmpty) {
384
      return null;
385
    }
386 387

    return localesWithLanguageCode.map((LocaleInfo locale) {
388 389
      return generateSwitchClauseTemplate(locale)
        .replaceAll('@(case)', locale.languageCode);
390
    }).join('\n      ');
391
  }).whereType<String>();
392 393 394 395 396 397 398 399 400 401

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

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

402 403 404 405 406 407
String _generateLookupBody(
  AppResourceBundleCollection allBundles,
  String className,
  bool useDeferredLoading,
  String fileName,
) {
408
  String generateSwitchClauseTemplate(LocaleInfo locale) {
409 410 411 412 413
    return (useDeferredLoading ?
      switchClauseDeferredLoadingTemplate : switchClauseTemplate)
      .replaceAll('@(localeClass)', '$className${locale.camelCase()}')
      .replaceAll('@(appClass)', className)
      .replaceAll('@(library)', '${fileName}_${locale.languageCode}');
414
  }
415
  return lookupBodyTemplate
416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434
    .replaceAll('@(lookupAllCodesSpecified)', _generateLookupByAllCodes(
      allBundles,
      generateSwitchClauseTemplate,
    ))
    .replaceAll('@(lookupScriptCodeSpecified)', _generateLookupByScriptCode(
      allBundles,
      generateSwitchClauseTemplate,
    ))
    .replaceAll('@(lookupCountryCodeSpecified)', _generateLookupByCountryCode(
      allBundles,
      generateSwitchClauseTemplate,
    ))
    .replaceAll('@(lookupLanguageCodeSpecified)', _generateLookupByLanguageCode(
      allBundles,
      generateSwitchClauseTemplate,
    ));
}

String _generateDelegateClass({
435 436 437 438 439
  required AppResourceBundleCollection allBundles,
  required String className,
  required Set<String> supportedLanguageCodes,
  required bool useDeferredLoading,
  required String fileName,
440 441 442 443 444 445 446 447 448 449 450 451
}) {

  final String lookupBody = _generateLookupBody(
    allBundles,
    className,
    useDeferredLoading,
    fileName,
  );
  final String loadBody = (
    useDeferredLoading ? loadBodyDeferredLoadingTemplate : loadBodyTemplate
  )
    .replaceAll('@(class)', className)
452
    .replaceAll('@(lookupName)', 'lookup$className');
453 454 455
  final String lookupFunction = (useDeferredLoading ?
  lookupFunctionDeferredLoadingTemplate : lookupFunctionTemplate)
    .replaceAll('@(class)', className)
456
    .replaceAll('@(lookupName)', 'lookup$className')
457 458 459 460 461 462
    .replaceAll('@(lookupBody)', lookupBody);
  return delegateClassTemplate
    .replaceAll('@(class)', className)
    .replaceAll('@(loadBody)', loadBody)
    .replaceAll('@(supportedLanguageCodes)', supportedLanguageCodes.join(', '))
    .replaceAll('@(lookupFunction)', lookupFunction);
463 464
}

465
class LocalizationsGenerator {
466 467 468 469 470 471 472 473 474
  /// 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({
475 476 477 478 479 480 481 482 483
    required FileSystem fileSystem,
    required String inputPathString,
    String? outputPathString,
    required String templateArbFileName,
    required String outputFileString,
    required String classNameString,
    List<String>? preferredSupportedLocales,
    String? headerString,
    String? headerFile,
484
    bool useDeferredLoading = false,
485
    String? inputsAndOutputsListPath,
486
    bool useSyntheticPackage = true,
487
    String? projectPathString,
488
    bool areResourceAttributesRequired = false,
489
    String? untranslatedMessagesFile,
490
    bool usesNullableGetter = true,
491
    bool useEscaping = false,
492
    required Logger logger,
493
    bool suppressWarnings = false,
494
    bool useRelaxedSyntax = false,
495
  }) {
496
    final Directory? projectDirectory = projectDirFromPath(fileSystem, projectPathString);
497 498 499 500 501 502 503 504 505 506 507 508 509 510
    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),
511
      useDeferredLoading: useDeferredLoading,
512 513 514
      untranslatedMessagesFile: _untranslatedMessagesFileFromPath(fileSystem, untranslatedMessagesFile),
      inputsAndOutputsListFile: _inputsAndOutputsListFileFromPath(fileSystem, inputsAndOutputsListPath),
      areResourceAttributesRequired: areResourceAttributesRequired,
515
      useEscaping: useEscaping,
516
      logger: logger,
517
      suppressWarnings: suppressWarnings,
518
      useRelaxedSyntax: useRelaxedSyntax,
519 520 521
    );
  }

522 523 524
  /// Creates an instance of the localizations generator class.
  ///
  /// It takes in a [FileSystem] representation that the class will act upon.
525 526 527 528 529 530
  LocalizationsGenerator._(this._fs, {
    required this.inputDirectory,
    required this.outputDirectory,
    required this.templateArbFile,
    required this.baseOutputFile,
    required this.className,
531 532 533
    this.preferredSupportedLocales = const <LocaleInfo>[],
    this.header = '',
    this.useDeferredLoading = false,
534
    required this.inputsAndOutputsListFile,
535 536 537 538 539
    this.useSyntheticPackage = true,
    this.projectDirectory,
    this.areResourceAttributesRequired = false,
    this.untranslatedMessagesFile,
    this.usesNullableGetter = true,
540
    required this.logger,
541
    this.useEscaping = false,
542
    this.suppressWarnings = false,
543
    this.useRelaxedSyntax = false,
544
  });
545

546
  final FileSystem _fs;
547
  List<Message> _allMessages = <Message>[];
548 549
  late final AppResourceBundleCollection _allBundles = AppResourceBundleCollection(inputDirectory);
  late final AppResourceBundle _templateBundle = AppResourceBundle(templateArbFile);
550 551 552
  late final Map<LocaleInfo, String> _inputFileNames = Map<LocaleInfo, String>.fromEntries(
    _allBundles.bundles.map((AppResourceBundle bundle) => MapEntry<LocaleInfo, String>(bundle.locale, bundle.file.basename))
  );
553
  late final LocaleInfo _templateArbLocale = _templateBundle.locale;
554 555 556 557

  @visibleForTesting
  final bool useSyntheticPackage;

558 559 560
  // Used to decide if the generated code is nullable or not
  // (whether AppLocalizations? or AppLocalizations is returned from
  // `static {name}Localizations{?} of (BuildContext context))`
561 562
  @visibleForTesting
  final bool usesNullableGetter;
563

564 565
  /// The directory that contains the project's arb files, as well as the
  /// header file, if specified.
566 567
  ///
  /// It is assumed that all input files (e.g. [templateArbFile], arb files
568
  /// for translated messages, header file templates) will reside here.
569
  final Directory inputDirectory;
570

571
  /// The Flutter project's root directory.
572
  final Directory? projectDirectory;
573

574 575 576
  /// The directory to generate the project's localizations files in.
  ///
  /// It is assumed that all output files (e.g. The localizations
577 578
  /// [outputFile], `messages_<locale>.dart` and `messages_all.dart`)
  /// will reside here.
579
  final Directory outputDirectory;
580

581 582
  /// The input arb file which defines all of the messages that will be
  /// exported by the generated class that's written to [outputFile].
583
  final File templateArbFile;
584

585 586 587 588
  /// 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.
589
  final File baseOutputFile;
590 591 592 593 594

  /// 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.
595
  final String className;
596 597 598 599 600 601 602 603 604 605 606

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

609 610 611 612
  // Whether we need to import intl or not. This flag is updated after parsing
  // all of the messages.
  bool requiresIntlImport = false;

613 614 615
  // Whether we want to use escaping for ICU messages.
  bool useEscaping = false;

616 617 618 619
  /// Whether any errors were caught. This is set after encountering any errors
  /// from calling [_generateMethod].
  bool hadErrors = false;

620 621 622
  /// Whether to use relaxed syntax.
  bool useRelaxedSyntax = false;

623
  /// The list of all arb path strings in [inputDirectory].
624 625 626
  List<String> get arbPathStrings {
    return _allBundles.bundles.map((AppResourceBundle bundle) => bundle.file.path).toList();
  }
627

628 629 630 631
  List<String> get outputFileList {
    return _outputFileList;
  }

632
  /// The supported language codes as found in the arb files located in
633
  /// [inputDirectory].
634
  final Set<String> supportedLanguageCodes = <String>{};
635 636

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

640
  /// The header to be prepended to the generated Dart localization file.
641
  final String header;
642

643 644
  final Map<LocaleInfo, List<String>> _unimplementedMessages = <LocaleInfo, List<String>>{};

645 646 647 648 649 650 651 652 653 654 655 656
  /// 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.
  ///
657
  /// This flag does not affect other platforms such as mobile or desktop.
658
  final bool useDeferredLoading;
659

660 661 662 663
  /// Contains a map of each output language file to its corresponding content in
  /// string format.
  final Map<File, String> _languageFileMap = <File, String>{};

664 665
  /// A generated file that will contain the list of messages for each locale
  /// that do not have a translation yet.
666
  final File? untranslatedMessagesFile;
667

668 669
  /// The file that contains the list of inputs and outputs for generating
  /// localizations.
670
  @visibleForTesting
671
  final File? inputsAndOutputsListFile;
672 673
  final List<String> _inputFileList = <String>[];
  final List<String> _outputFileList = <String>[];
674

675 676 677 678
  /// Whether or not resource attributes are required for each corresponding
  /// resource id.
  ///
  /// Resource attributes provide metadata about the message.
679 680
  @visibleForTesting
  final bool areResourceAttributesRequired;
681

682 683 684
  /// Logger to be used during the execution of the script.
  Logger logger;

685 686 687
  /// Whether or not to suppress warnings or not.
  final bool suppressWarnings;

688 689 690 691 692 693 694 695 696 697 698 699 700 701
  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');
  }

702
  @visibleForTesting
703
  static Directory? projectDirFromPath(FileSystem fileSystem, String? projectPathString) {
704
    if (projectPathString == null) {
705
      return null;
706 707
    }

708
    final Directory directory = fileSystem.directory(projectPathString);
709 710 711
    if (!directory.existsSync()) {
      throw L10nException(
        'Directory does not exist: $directory.\n'
712
        "Please select a directory that contains the project's localizations "
713 714 715
        'resource files.'
      );
    }
716
    return directory;
717 718
  }

719
  /// Sets the reference [Directory] for [inputDirectory].
720
  @visibleForTesting
721
  static Directory inputDirectoryFromPath(FileSystem fileSystem, String inputPathString, Directory? projectDirectory) {
722
    final Directory inputDirectory = fileSystem.directory(
723
      projectDirectory != null
724
        ? _getAbsoluteProjectPath(inputPathString, projectDirectory)
725 726 727
        : inputPathString
    );

728 729
    if (!inputDirectory.existsSync()) {
      throw L10nException(
730
        "The 'arb-dir' directory, '$inputDirectory', does not exist.\n"
731 732
        'Make sure that the correct path was provided.'
      );
733
    }
734

735
    final FileStat fileStat = inputDirectory.statSync();
736 737
    if (_isNotReadable(fileStat) || _isNotWritable(fileStat)) {
      throw L10nException(
738
        "The 'arb-dir' directory, '$inputDirectory', doesn't allow reading and writing.\n"
739 740
        'Please ensure that the user has read and write permissions.'
      );
741
    }
742
    return inputDirectory;
743 744
  }

745 746
  /// Sets the reference [Directory] for [outputDirectory].
  @visibleForTesting
747
  static Directory outputDirectoryFromPath(FileSystem fileSystem, String outputPathString, bool useSyntheticPackage, Directory? projectDirectory) {
748 749 750
    Directory outputDirectory;
    if (useSyntheticPackage) {
      outputDirectory = fileSystem.directory(
751
        projectDirectory != null
752 753
          ? _getAbsoluteProjectPath(_syntheticL10nPackagePath(fileSystem), projectDirectory)
          : _syntheticL10nPackagePath(fileSystem)
754 755
      );
    } else {
756
      outputDirectory = fileSystem.directory(
757
        projectDirectory != null
758
          ? _getAbsoluteProjectPath(outputPathString, projectDirectory)
759 760 761
          : outputPathString
      );
    }
762
    return outputDirectory;
763 764
  }

765 766
  /// Sets the reference [File] for [templateArbFile].
  @visibleForTesting
767 768
  static File templateArbFileFromFileName(String templateArbFileName, Directory inputDirectory) {
    final File templateArbFile = inputDirectory.childFile(templateArbFileName);
769 770 771 772 773 774 775
    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();
776 777
    if (templateArbFileStatModeString[0] == '-' && templateArbFileStatModeString[3] == '-') {
      throw L10nException(
778 779 780
        "The 'template-arb-file', $templateArbFile, is not readable.\n"
        'Please ensure that the user has read permissions.'
      );
781
    }
782
    return templateArbFile;
783 784
  }

785 786
  static bool _isValidClassName(String className) {
    // Public Dart class name cannot begin with an underscore
787
    if (className[0] == '_') {
788
      return false;
789
    }
790
    // Dart class name cannot contain non-alphanumeric symbols
791
    if (className.contains(RegExp(r'[^a-zA-Z_\d]'))) {
792
      return false;
793
    }
794
    // Dart class name must start with upper case character
795
    if (className[0].contains(RegExp(r'[a-z]'))) {
796
      return false;
797
    }
798
    // Dart class name cannot start with a number
799
    if (className[0].contains(RegExp(r'\d'))) {
800
      return false;
801
    }
802 803 804
    return true;
  }

805 806
  /// Sets the [className] for the localizations and localizations delegate
  /// classes.
807
  @visibleForTesting
808
  static String classNameFromString(String classNameString) {
809 810
    if (classNameString.isEmpty) {
      throw L10nException('classNameString argument cannot be empty');
811 812
    }
    if (!_isValidClassName(classNameString)) {
813
      throw L10nException(
814
        "The 'output-class', $classNameString, is not a valid public Dart class name.\n"
815
      );
816
    }
817
    return classNameString;
818 819
  }

820 821 822
  /// Sets [preferredSupportedLocales] so that this particular list of locales
  /// will take priority over the other locales.
  @visibleForTesting
823
  static List<LocaleInfo> preferredSupportedLocalesFromLocales(List<String>? inputLocales) {
824
    if (inputLocales == null || inputLocales.isEmpty) {
825
      return const <LocaleInfo>[];
826
    }
827 828 829
    return inputLocales.map((String localeString) {
      return LocaleInfo.fromString(localeString);
    }).toList();
830 831
  }

832
  static String headerFromFile(String? headerString, String? headerFile, Directory inputDirectory) {
833 834 835 836 837 838 839 840
    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) {
841
      return headerString;
842 843
    } else if (headerFile != null) {
      try {
844
        return inputDirectory.childFile(headerFile).readAsStringSync();
845 846 847 848 849 850 851
      } on FileSystemException catch (error) {
        throw L10nException (
          'Failed to read header file: "$headerFile". \n'
          'FileSystemException: ${error.message}'
        );
      }
    }
852
    return '';
853 854
  }

855 856
  static String _getAbsoluteProjectPath(String relativePath, Directory projectDirectory) =>
      projectDirectory.fileSystem.path.join(projectDirectory.path, relativePath);
857

858
  static File? _untranslatedMessagesFileFromPath(FileSystem fileSystem, String? untranslatedMessagesFileString) {
859
    if (untranslatedMessagesFileString == null || untranslatedMessagesFileString.isEmpty) {
860
      return null;
861
    }
862
    untranslatedMessagesFileString = untranslatedMessagesFileString.replaceAll(r'\', fileSystem.path.separator);
863
    return fileSystem.file(untranslatedMessagesFileString);
864 865
  }

866
  static File? _inputsAndOutputsListFileFromPath(FileSystem fileSystem, String? inputsAndOutputsListPath) {
867
    if (inputsAndOutputsListPath == null) {
868
      return null;
869
    }
870

871 872
    return fileSystem.file(
      fileSystem.path.join(inputsAndOutputsListPath, 'gen_l10n_inputs_and_outputs.json'),
873 874 875
    );
  }

876
  static bool _isValidGetterAndMethodName(String name) {
877 878 879
    if (name.isEmpty) {
      return false;
    }
880
    // Public Dart method name must not start with an underscore
881
    if (name[0] == '_') {
882
      return false;
883
    }
884 885
    // Dart identifiers can only use letters, numbers, underscores, and `$`
    if (name.contains(RegExp(r'[^a-zA-Z_$\d]'))) {
886
      return false;
887
    }
888
    // Dart getter and method name should start with lower case character
889
    if (name[0].contains(RegExp(r'[A-Z]'))) {
890
      return false;
891
    }
892
    // Dart getter and method name cannot start with a number
893
    if (name[0].contains(RegExp(r'\d'))) {
894
      return false;
895
    }
896 897 898
    return true;
  }

899
  // Load _allMessages from templateArbFile and _allBundles from all of the ARB
900
  // files in inputDirectory. Also initialized: supportedLocales.
901
  void loadResources() {
902
    for (final String resourceId in _templateBundle.resourceIds) {
903 904 905 906 907 908 909 910
      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.'
        );
      }
911
    }
912 913
    // 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(
914 915 916 917 918 919 920
      _templateBundle,
      _allBundles,
      id,
      areResourceAttributesRequired,
      useEscaping: useEscaping,
      logger: logger,
      useRelaxedSyntax: useRelaxedSyntax,
921
    )).toList();
922
    hadErrors = _allMessages.any((Message message) => message.hadErrors);
923
    if (inputsAndOutputsListFile != null) {
924 925 926 927
      _inputFileList.addAll(_allBundles.bundles.map((AppResourceBundle bundle) {
        return bundle.file.absolute.path;
      }));
    }
928

929 930 931 932
    final List<LocaleInfo> allLocales = List<LocaleInfo>.from(_allBundles.locales);
    for (final LocaleInfo preferredLocale in preferredSupportedLocales) {
      final int index = allLocales.indexOf(preferredLocale);
      if (index == -1) {
933
        throw L10nException(
934 935 936 937
          "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.'
938
        );
939
      }
940 941
      allLocales.removeAt(index);
      allLocales.insertAll(0, preferredSupportedLocales);
942
    }
943
    supportedLocales.addAll(allLocales);
944 945
  }

946 947
  void _addUnimplementedMessage(LocaleInfo locale, String message) {
    if (_unimplementedMessages.containsKey(locale)) {
948
      _unimplementedMessages[locale]!.add(message);
949 950 951 952 953 954 955 956 957
    } else {
      _unimplementedMessages.putIfAbsent(locale, () => <String>[message]);
    }
  }

  String _generateBaseClassFile(
    String className,
    String fileName,
    String header,
958
    final LocaleInfo locale,
959
  ) {
960
    final Iterable<String> methods = _allMessages.map((Message message) {
961
      LocaleInfo localeWithFallback = locale;
962
      if (message.messages[locale] == null) {
963
        _addUnimplementedMessage(locale, message.resourceId);
964 965 966 967 968 969
        localeWithFallback = _templateArbLocale;
      }
      if (message.parsedMessages[localeWithFallback] == null) {
        // The message exists, but parsedMessages[locale] is null due to a syntax error.
        // This means that we have already set hadErrors = true while constructing the Message.
        return '';
970
      }
971
      return _generateMethod(
972
        message,
973
        localeWithFallback,
974 975 976 977
      );
    });

    return classFileTemplate
978
      .replaceAll('@(header)', header.isEmpty ? '' : '$header\n\n')
979 980 981 982 983
      .replaceAll('@(language)', describeLocale(locale.toString()))
      .replaceAll('@(baseClass)', className)
      .replaceAll('@(fileName)', fileName)
      .replaceAll('@(class)', '$className${locale.camelCase()}')
      .replaceAll('@(localeName)', locale.toString())
984
      .replaceAll('@(methods)', methods.join('\n\n'))
985
      .replaceAll('@(requiresIntlImport)', requiresIntlImport ? "import 'package:intl/intl.dart' as intl;\n\n" : '');
986 987 988 989 990 991 992 993 994
  }

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

995 996
    _allMessages
      .where((Message message) => message.messages[locale] == null)
997 998 999 1000
      .forEach((Message message) {
        _addUnimplementedMessage(locale, message.resourceId);
      });

1001
    final Iterable<String> methods = _allMessages
1002
      .where((Message message) => message.parsedMessages[locale] != null)
1003
      .map((Message message) => _generateMethod(message, locale));
1004 1005 1006 1007 1008 1009 1010 1011 1012

    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'));
  }

1013
  // Generate the AppLocalizations class, its LocalizationsDelegate subclass,
1014 1015
  // and all AppLocalizations subclasses for every locale. This method by
  // itself does not generate the output files.
1016
  String _generateCode() {
1017 1018
    bool isBaseClassLocale(LocaleInfo locale, String language) {
      return locale.languageCode == language
1019
          && locale.countryCode == null
1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032
          && 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();
    }

1033 1034
    final String directory = _fs.path.basename(outputDirectory.path);
    final String outputFileName = _fs.path.basename(baseOutputFile.path);
1035 1036 1037 1038 1039 1040
    if (!outputFileName.endsWith('.dart')) {
      throw L10nException(
        "The 'output-localization-file', $outputFileName, is invalid.\n"
        'The file name must have a .dart extension.'
      );
    }
1041 1042

    final Iterable<String> supportedLocalesCode = supportedLocales.map((LocaleInfo locale) {
1043
      final String languageCode = locale.languageCode;
1044 1045
      final String? countryCode = locale.countryCode;
      final String? scriptCode = locale.scriptCode;
1046 1047

      if (countryCode == null && scriptCode == null) {
1048
        return "Locale('$languageCode')";
1049
      } else if (countryCode != null && scriptCode == null) {
1050
        return "Locale('$languageCode', '$countryCode')";
1051
      } else if (countryCode != null && scriptCode != null) {
1052
        return "Locale.fromSubtags(languageCode: '$languageCode', countryCode: '$countryCode', scriptCode: '$scriptCode')";
1053
      } else {
1054
        return "Locale.fromSubtags(languageCode: '$languageCode', scriptCode: '$scriptCode')";
1055
      }
1056 1057 1058
    });

    final Set<String> supportedLanguageCodes = Set<String>.from(
1059
      _allBundles.locales.map<String>((LocaleInfo locale) => "'${locale.languageCode}'")
1060
    );
1061 1062

    final List<LocaleInfo> allLocales = _allBundles.locales.toList()..sort();
1063 1064 1065 1066 1067 1068 1069 1070 1071
    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);
1072
    for (final LocaleInfo locale in allLocales) {
1073
      if (isBaseClassLocale(locale, locale.languageCode)) {
1074
        final File languageMessageFile = outputDirectory.childFile('${fileName}_$locale.$fileExtension');
1075 1076 1077 1078

        // 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.
1079
        final String languageBaseClassFile = _generateBaseClassFile(
1080 1081 1082
          className,
          outputFileName,
          header,
1083
          locale,
1084 1085 1086 1087 1088 1089 1090
        );

        // 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) {
1091 1092
          return _generateSubclass(
            className,
1093
            _allBundles.bundleFor(locale)!,
1094 1095 1096
          );
        });

1097 1098 1099
        _languageFileMap.putIfAbsent(languageMessageFile, () {
          return languageBaseClassFile.replaceAll('@(subclasses)', subclasses.join());
        });
1100
      }
1101 1102
    }

1103
    final List<String> sortedClassImports = supportedLocales
1104 1105
      .where((LocaleInfo locale) => isBaseClassLocale(locale, locale.languageCode))
      .map((LocaleInfo locale) {
1106
        final String library = '${fileName}_$locale';
1107
        if (useDeferredLoading) {
1108
          return "import '$library.$fileExtension' deferred as $library;";
1109
        } else {
1110
          return "import '$library.$fileExtension';";
1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122
        }
      })
      .toList()
      ..sort();

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

1124
    return fileTemplate
1125
      .replaceAll('@(header)', header.isEmpty ? '' : '$header\n')
1126
      .replaceAll('@(class)', className)
1127
      .replaceAll('@(methods)', _allMessages.map((Message message) => generateBaseClassMethod(message, _templateArbLocale)).join('\n'))
1128 1129 1130
      .replaceAll('@(importFile)', '$directory/$outputFileName')
      .replaceAll('@(supportedLocales)', supportedLocalesCode.join(',\n    '))
      .replaceAll('@(supportedLanguageCodes)', supportedLanguageCodes.join(', '))
1131
      .replaceAll('@(messageClassImports)', sortedClassImports.join('\n'))
1132
      .replaceAll('@(delegateClass)', delegateClass)
1133
      .replaceAll('@(requiresFoundationImport)', useDeferredLoading ? '' : "import 'package:flutter/foundation.dart';")
1134
      .replaceAll('@(requiresIntlImport)', requiresIntlImport ? "import 'package:intl/intl.dart' as intl;" : '')
1135
      .replaceAll('@(canBeNullable)', usesNullableGetter ? '?' : '')
1136 1137 1138 1139 1140
      .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');
1141 1142
  }

1143
  String _generateMethod(Message message, LocaleInfo locale) {
1144
    try {
1145 1146 1147 1148
      // Determine if we must import intl for date or number formatting.
      if (message.placeholdersRequireFormatting) {
        requiresIntlImport = true;
      }
1149

1150 1151
      final String translationForMessage = message.messages[locale]!;
      final Node node = message.parsedMessages[locale]!;
1152 1153
      // If the placeholders list is empty, then return a getter method.
      if (message.placeholders.isEmpty) {
1154 1155 1156 1157 1158
        // 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())}'");
      }
1159

1160 1161 1162 1163 1164 1165
      final List<String> tempVariables = <String>[];
      // Get a unique temporary variable name.
      int variableCount = 0;
      String getTempVariableName() {
        return '_temp${variableCount++}';
      }
1166

1167 1168 1169 1170
      // 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".
1171
      // When traversing through an argumentExpr node, return "$tempVarN" and add variable declaration in "tempVariables".
1172 1173 1174 1175 1176 1177
      // When traversing through a message node, return concatenation of all of "generateVariables(child)" for each child.
      String generateVariables(Node node, { bool isRoot = false }) {
        switch (node.type) {
          case ST.message:
            final List<String> expressions = node.children.map<String>((Node node) {
              if (node.type == ST.string) {
1178
                return generateString(node.value!);
1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189
              }
              return generateVariables(node);
            }).toList();
            return generateReturnExpr(expressions);

          case ST.placeholderExpr:
            assert(node.children[1].type == ST.identifier);
            final String identifier = node.children[1].value!;
            final Placeholder placeholder = message.placeholders[identifier]!;
            if (placeholder.requiresFormatting) {
              return '\$${node.children[1].value}String';
1190
            }
1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224
            return '\$${node.children[1].value}';

          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])) {
                final String pluralPartExpression = generateVariables(pluralMessage);
                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(
                    '''
1225 1226
The plural cases must be one of "=0", "=1", "=2", "zero", "one", "two", "few", "many", or "other.
    $pluralCase is not a valid plural case.''',
1227 1228 1229 1230 1231 1232 1233 1234 1235
                    _inputFileNames[locale]!,
                    message.resourceId,
                    translationForMessage,
                    pluralPart.positionInMessage,
                  );
                }
                pluralLogicArgs[transformedPluralCase] = '      ${pluralCases[pluralCase]}: $pluralPartExpression,';
              } else if (!suppressWarnings) {
                logger.printWarning('''
1236 1237 1238
[${_inputFileNames[locale]}:${message.resourceId}] ICU Syntax Warning: The plural part specified below is overridden by a later plural part.
    $translationForMessage
    ${Parser.indentForError(pluralPart.positionInMessage)}''');
1239
              }
1240
            }
1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273
            final String tempVarName = getTempVariableName();
            tempVariables.add(pluralVariableTemplate
              .replaceAll('@(varName)', tempVarName)
              .replaceAll('@(count)', identifier.value!)
              .replaceAll('@(pluralLogicArgs)', pluralLogicArgs.values.join('\n'))
            );
            return '\$$tempVarName';

          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];
              final String selectPartExpression = generateVariables(selectMessage);
              selectLogicArgs.add("        '$selectCase': $selectPartExpression,");
            }
            final String tempVarName = getTempVariableName();
            tempVariables.add(selectVariableTemplate
              .replaceAll('@(varName)', tempVarName)
              .replaceAll('@(choice)', identifier.value!)
              .replaceAll('@(selectCases)', selectLogicArgs.join('\n'))
            );
            return '\$$tempVarName';
1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301
          case ST.argumentExpr:
            requiresIntlImport = true;
            assert(node.children[1].type == ST.identifier);
            assert(node.children[3].type == ST.argType);
            assert(node.children[7].type == ST.identifier);
            final String identifierName = node.children[1].value!;
            final Node formatType = node.children[7];
            // Check that formatType is a valid intl.DateFormat.
            if (!validDateFormats.contains(formatType.value)) {
              throw L10nParserException(
                'Date format "${formatType.value!}" for placeholder '
                '$identifierName does not have a corresponding DateFormat '
                "constructor\n. Check the intl library's DateFormat class "
                'constructors for allowed date formats, or set "isCustomDateFormat" attribute '
                'to "true".',
                _inputFileNames[locale]!,
                message.resourceId,
                translationForMessage,
                formatType.positionInMessage,
              );
            }
            final String tempVarName = getTempVariableName();
            tempVariables.add(dateVariableTemplate
              .replaceAll('@(varName)', tempVarName)
              .replaceAll('@(formatType)', formatType.value!)
              .replaceAll('@(argument)', identifierName)
            );
            return '\$$tempVarName';
1302 1303 1304 1305
          // ignore: no_default_cases
          default:
            throw Exception('Cannot call "generateHelperMethod" on node type ${node.type}');
        }
1306
      }
1307 1308 1309 1310 1311 1312 1313 1314 1315 1316
      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', '');
1317 1318
    } on L10nParserException catch (error) {
      logger.printError(error.toString());
1319
      hadErrors = true;
1320 1321
      return '';
    }
1322
  }
1323

1324
  List<String> writeOutputFiles({ bool isFromYaml = false, bool useCRLF = false }) {
1325
    // First, generate the string contents of all necessary files.
1326
    final String generatedLocalizationsFile = _generateCode();
1327

1328
    // If there were any syntax errors, don't write to files.
1329
    if (hadErrors) {
1330 1331 1332
      throw L10nException('Found syntax errors.');
    }

1333 1334
    // A pubspec.yaml file is required when using a synthetic package. If it does not
    // exist, create a blank one.
1335
    if (useSyntheticPackage) {
1336 1337 1338
      final Directory syntheticPackageDirectory = projectDirectory != null
          ? projectDirectory!.childDirectory(_defaultSyntheticPackagePath(_fs))
          : _fs.directory(_defaultSyntheticPackagePath(_fs));
1339 1340 1341
      syntheticPackageDirectory.createSync(recursive: true);
      final File flutterGenPubspec = syntheticPackageDirectory.childFile('pubspec.yaml');
      if (!flutterGenPubspec.existsSync()) {
1342 1343 1344
        flutterGenPubspec.writeAsStringSync(
          useCRLF ? emptyPubspecTemplate.replaceAll('\n', '\r\n') : emptyPubspecTemplate
        );
1345 1346 1347
      }
    }

1348 1349
    // Since all validity checks have passed up to this point,
    // write the contents into the directory.
1350
    outputDirectory.createSync(recursive: true);
1351 1352 1353

    // Ensure that the created directory has read/write permissions.
    final FileStat fileStat = outputDirectory.statSync();
1354 1355
    if (_isNotReadable(fileStat) || _isNotWritable(fileStat)) {
      throw L10nException(
1356 1357 1358
        "The 'output-dir' directory, $outputDirectory, doesn't allow reading and writing.\n"
        'Please ensure that the user has read and write permissions.'
      );
1359
    }
1360 1361 1362

    // Generate the required files for localizations.
    _languageFileMap.forEach((File file, String contents) {
1363
      file.writeAsStringSync(useCRLF ? contents.replaceAll('\n', '\r\n') : contents);
1364
      _outputFileList.add(file.absolute.path);
1365
    });
1366

1367 1368 1369
    baseOutputFile.writeAsStringSync(
      useCRLF ? generatedLocalizationsFile.replaceAll('\n', '\r\n') : generatedLocalizationsFile
    );
1370 1371 1372
    final File? messagesFile = untranslatedMessagesFile;
    if (messagesFile != null) {
      _generateUntranslatedMessagesFile(logger, messagesFile);
1373 1374 1375 1376
    } else if (_unimplementedMessages.isNotEmpty) {
      _unimplementedMessages.forEach((LocaleInfo locale, List<String> messages) {
        logger.printStatus('"$locale": ${messages.length} untranslated message(s).');
      });
1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392
      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'
        );
      }

1393
      logger.printStatus(
1394 1395
        'This will generate a JSON format file containing all messages that \n'
        'need to be translated.'
1396 1397
      );
    }
1398
    final File? inputsAndOutputsListFileLocal = inputsAndOutputsListFile;
1399
    _outputFileList.add(baseOutputFile.absolute.path);
1400
    if (inputsAndOutputsListFileLocal != null) {
1401
      // Generate a JSON file containing the inputs and outputs of the gen_l10n script.
1402 1403
      if (!inputsAndOutputsListFileLocal.existsSync()) {
        inputsAndOutputsListFileLocal.createSync(recursive: true);
1404
      }
1405 1406 1407 1408
      final String filesListContent = json.encode(<String, Object> {
        'inputs': _inputFileList,
        'outputs': _outputFileList,
      });
1409
      inputsAndOutputsListFileLocal.writeAsStringSync(
1410
        useCRLF ? filesListContent.replaceAll('\n', '\r\n') : filesListContent,
1411 1412
      );
    }
1413 1414

    return _outputFileList;
1415
  }
1416

1417
  void _generateUntranslatedMessagesFile(Logger logger, File untranslatedMessagesFile) {
1418
    if (_unimplementedMessages.isEmpty) {
1419
      untranslatedMessagesFile.writeAsStringSync('{}');
1420
      _outputFileList.add(untranslatedMessagesFile.absolute.path);
1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446
      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';
1447
    untranslatedMessagesFile.writeAsStringSync(resultingFile);
1448
    _outputFileList.add(untranslatedMessagesFile.absolute.path);
1449
  }
1450
}