flutter/packages/flutter_tools/lib/src/localizations/gen_l10n.dart
Ian Hickson 6205c110d6
Remove "note that" in our documentation (as per style guide) (#120842)
* lerp documentation

* Remove Note, Note That from repo

* Improve BorderSide documentation.

* apply review comments
2023-02-17 22:27:33 +00:00

1375 lines
54 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:meta/meta.dart';
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/logger.dart';
import '../convert.dart';
import '../flutter_manifest.dart';
import 'gen_l10n_templates.dart';
import 'gen_l10n_types.dart';
import 'localizations_utils.dart';
import 'message_parser.dart';
/// Run the localizations generation script with the configuration [options].
LocalizationsGenerator generateLocalizations({
required Directory projectDir,
Directory? dependenciesDir,
required LocalizationOptions options,
required Logger logger,
required FileSystem fileSystem,
}) {
// If generating a synthetic package, generate a warning if
// flutter: generate is not set.
final FlutterManifest? flutterManifest = FlutterManifest.createFromPath(
projectDir.childFile('pubspec.yaml').path,
fileSystem: projectDir.fileSystem,
logger: logger,
);
if (options.useSyntheticPackage && (flutterManifest == null || !flutterManifest.generateSyntheticPackage)) {
throwToolExit(
'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();
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';
LocalizationsGenerator generator;
try {
generator = LocalizationsGenerator(
fileSystem: fileSystem,
inputsAndOutputsListPath: dependenciesDir?.path,
projectPathString: projectDir.path,
inputPathString: inputPathString,
templateArbFileName: templateArbFileName,
outputFileString: outputFileString,
outputPathString: options.outputDirectory?.path,
classNameString: options.outputClass ?? 'AppLocalizations',
preferredSupportedLocales: options.preferredSupportedLocales,
headerString: options.header,
headerFile: options.headerFile?.toFilePath(),
useDeferredLoading: options.deferredLoading ?? false,
useSyntheticPackage: options.useSyntheticPackage,
areResourceAttributesRequired: options.areResourceAttributesRequired,
untranslatedMessagesFile: options.untranslatedMessagesFile?.toFilePath(),
usesNullableGetter: options.usesNullableGetter,
useEscaping: options.useEscaping,
logger: logger,
suppressWarnings: options.suppressWarnings,
)
..loadResources()
..writeOutputFiles(isFromYaml: true);
} on L10nException catch (e) {
throwToolExit(e.message);
}
return generator;
}
/// The path for the synthetic package.
String _defaultSyntheticPackagePath(FileSystem fileSystem) => fileSystem.path.join('.dart_tool', 'flutter_gen');
/// The default path used when the `_useSyntheticPackage` setting is set to true
/// in [LocalizationsGenerator].
///
/// See [LocalizationsGenerator.initialize] for where and how it is used by the
/// localizations tool.
String _syntheticL10nPackagePath(FileSystem fileSystem) => fileSystem.path.join(_defaultSyntheticPackagePath(fileSystem), 'gen_l10n');
// 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.
List<String> generateMethodParameters(Message message) {
return message.placeholders.values.map((Placeholder placeholder) {
return '${placeholder.type} ${placeholder.name}';
}).toList();
}
// Similar to above, but is used for passing arguments into helper functions.
List<String> generateMethodArguments(Message message) {
return message.placeholders.values.map((Placeholder placeholder) => placeholder.name).toList();
}
String generateDateFormattingLogic(Message message) {
if (message.placeholders.isEmpty || !message.placeholdersRequireFormatting) {
return '@(none)';
}
final Iterable<String> formatStatements = message.placeholders.values
.where((Placeholder placeholder) => placeholder.requiresDateFormatting)
.map((Placeholder placeholder) {
final String? placeholderFormat = placeholder.format;
if (placeholderFormat == null) {
throw L10nException(
'The placeholder, ${placeholder.name}, has its "type" resource attribute set to '
'the "${placeholder.type}" type. To properly resolve for the right '
'${placeholder.type} format, the "format" attribute needs to be set '
'to determine which DateFormat to use. \n'
"Check the intl library's DateFormat class constructors for allowed "
'date formats.'
);
}
final bool? isCustomDateFormat = placeholder.isCustomDateFormat;
if (!placeholder.hasValidDateFormat
&& (isCustomDateFormat == null || !isCustomDateFormat)) {
throw L10nException(
'Date format "$placeholderFormat" for placeholder '
'${placeholder.name} 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".'
);
}
if (placeholder.hasValidDateFormat) {
return dateFormatTemplate
.replaceAll('@(placeholder)', placeholder.name)
.replaceAll('@(format)', placeholderFormat);
}
return dateFormatCustomTemplate
.replaceAll('@(placeholder)', placeholder.name)
.replaceAll('@(format)', "'${generateString(placeholderFormat)}'");
});
return formatStatements.isEmpty ? '@(none)' : formatStatements.join();
}
String generateNumberFormattingLogic(Message message) {
if (message.placeholders.isEmpty || !message.placeholdersRequireFormatting) {
return '@(none)';
}
final Iterable<String> formatStatements = message.placeholders.values
.where((Placeholder placeholder) => placeholder.requiresNumFormatting)
.map((Placeholder placeholder) {
final String? placeholderFormat = placeholder.format;
if (!placeholder.hasValidNumberFormat || placeholderFormat == null) {
throw L10nException(
'Number format $placeholderFormat for the ${placeholder.name} '
'placeholder does not have a corresponding NumberFormat constructor.\n'
"Check the intl library's NumberFormat class constructors for allowed "
'number formats.'
);
}
final Iterable<String> parameters =
placeholder.optionalParameters.map<String>((OptionalParameter parameter) {
if (parameter.value is num) {
return '${parameter.name}: ${parameter.value}';
} else {
return "${parameter.name}: '${generateString(parameter.value.toString())}'";
}
},
);
if (placeholder.hasNumberFormatWithParameters) {
return numberFormatNamedTemplate
.replaceAll('@(placeholder)', placeholder.name)
.replaceAll('@(format)', placeholderFormat)
.replaceAll('@(parameters)', parameters.join(',\n '));
} else {
return numberFormatPositionalTemplate
.replaceAll('@(placeholder)', placeholder.name)
.replaceAll('@(format)', placeholderFormat);
}
});
return formatStatements.isEmpty ? '@(none)' : formatStatements.join();
}
/// 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',
};
String generateBaseClassMethod(Message message, LocaleInfo? templateArbLocale) {
final String comment = message
.description
?.split('\n')
.map((String line) => ' /// $line')
.join('\n') ?? ' /// No description provided for @${message.resourceId}.';
final String templateLocaleTranslationComment = '''
/// In $templateArbLocale, this message translates to:
/// **'${generateString(message.value)}'**''';
if (message.placeholders.isNotEmpty) {
return baseClassMethodTemplate
.replaceAll('@(comment)', comment)
.replaceAll('@(templateLocaleTranslationComment)', templateLocaleTranslationComment)
.replaceAll('@(name)', message.resourceId)
.replaceAll('@(parameters)', generateMethodParameters(message).join(', '));
}
return baseClassGetterTemplate
.replaceAll('@(comment)', comment)
.replaceAll('@(templateLocaleTranslationComment)', templateLocaleTranslationComment)
.replaceAll('@(name)', message.resourceId);
}
// 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');
}
String _generateLookupByAllCodes(
AppResourceBundleCollection allBundles,
String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
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) {
return generateSwitchClauseTemplate(locale)
.replaceAll('@(case)', locale.toString());
});
return allCodesLookupTemplate.replaceAll(
'@(allCodesSwitchClauses)',
switchClauses.join('\n '),
);
}
String _generateLookupByScriptCode(
AppResourceBundleCollection allBundles,
String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
final Iterable<String> switchClauses = allBundles.languages.map((String language) {
final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
final Iterable<LocaleInfo> localesWithScriptCodes = locales.where((LocaleInfo locale) {
return locale.scriptCode != null && locale.countryCode == null;
});
if (localesWithScriptCodes.isEmpty) {
return null;
}
return _addSpaces(nestedSwitchTemplate
.replaceAll('@(languageCode)', language)
.replaceAll('@(code)', 'scriptCode')
.replaceAll('@(switchClauses)',
_addSpaces(
localesWithScriptCodes.map((LocaleInfo locale) {
return generateSwitchClauseTemplate(locale)
.replaceAll('@(case)', locale.scriptCode!);
}).join('\n'),
spaces: 8,
),
),
spaces: 4,
);
}).whereType<String>();
if (switchClauses.isEmpty) {
return '';
}
return languageCodeSwitchTemplate
.replaceAll('@(comment)', '// Lookup logic when language+script codes are specified.')
.replaceAll('@(switchClauses)', switchClauses.join('\n '),
);
}
String _generateLookupByCountryCode(
AppResourceBundleCollection allBundles,
String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
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;
});
if (localesWithCountryCodes.isEmpty) {
return null;
}
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,
);
}).whereType<String>();
if (switchClauses.isEmpty) {
return '';
}
return languageCodeSwitchTemplate
.replaceAll('@(comment)', '// Lookup logic when language+country codes are specified.')
.replaceAll('@(switchClauses)', switchClauses.join('\n '));
}
String _generateLookupByLanguageCode(
AppResourceBundleCollection allBundles,
String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
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;
});
if (localesWithLanguageCode.isEmpty) {
return null;
}
return localesWithLanguageCode.map((LocaleInfo locale) {
return generateSwitchClauseTemplate(locale)
.replaceAll('@(case)', locale.languageCode);
}).join('\n ');
}).whereType<String>();
if (switchClauses.isEmpty) {
return '';
}
return languageCodeSwitchTemplate
.replaceAll('@(comment)', '// Lookup logic when only language code is specified.')
.replaceAll('@(switchClauses)', switchClauses.join('\n '));
}
String _generateLookupBody(
AppResourceBundleCollection allBundles,
String className,
bool useDeferredLoading,
String fileName,
) {
String generateSwitchClauseTemplate(LocaleInfo locale) {
return (useDeferredLoading ?
switchClauseDeferredLoadingTemplate : switchClauseTemplate)
.replaceAll('@(localeClass)', '$className${locale.camelCase()}')
.replaceAll('@(appClass)', className)
.replaceAll('@(library)', '${fileName}_${locale.languageCode}');
}
return lookupBodyTemplate
.replaceAll('@(lookupAllCodesSpecified)', _generateLookupByAllCodes(
allBundles,
generateSwitchClauseTemplate,
))
.replaceAll('@(lookupScriptCodeSpecified)', _generateLookupByScriptCode(
allBundles,
generateSwitchClauseTemplate,
))
.replaceAll('@(lookupCountryCodeSpecified)', _generateLookupByCountryCode(
allBundles,
generateSwitchClauseTemplate,
))
.replaceAll('@(lookupLanguageCodeSpecified)', _generateLookupByLanguageCode(
allBundles,
generateSwitchClauseTemplate,
));
}
String _generateDelegateClass({
required AppResourceBundleCollection allBundles,
required String className,
required Set<String> supportedLanguageCodes,
required bool useDeferredLoading,
required String fileName,
}) {
final String lookupBody = _generateLookupBody(
allBundles,
className,
useDeferredLoading,
fileName,
);
final String loadBody = (
useDeferredLoading ? loadBodyDeferredLoadingTemplate : loadBodyTemplate
)
.replaceAll('@(class)', className)
.replaceAll('@(lookupName)', 'lookup$className');
final String lookupFunction = (useDeferredLoading ?
lookupFunctionDeferredLoadingTemplate : lookupFunctionTemplate)
.replaceAll('@(class)', className)
.replaceAll('@(lookupName)', 'lookup$className')
.replaceAll('@(lookupBody)', lookupBody);
return delegateClassTemplate
.replaceAll('@(class)', className)
.replaceAll('@(loadBody)', loadBody)
.replaceAll('@(supportedLanguageCodes)', supportedLanguageCodes.join(', '))
.replaceAll('@(lookupFunction)', lookupFunction);
}
class LocalizationsGenerator {
/// 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({
required FileSystem fileSystem,
required String inputPathString,
String? outputPathString,
required String templateArbFileName,
required String outputFileString,
required String classNameString,
List<String>? preferredSupportedLocales,
String? headerString,
String? headerFile,
bool useDeferredLoading = false,
String? inputsAndOutputsListPath,
bool useSyntheticPackage = true,
String? projectPathString,
bool areResourceAttributesRequired = false,
String? untranslatedMessagesFile,
bool usesNullableGetter = true,
bool useEscaping = false,
required Logger logger,
bool suppressWarnings = false,
}) {
final Directory? projectDirectory = projectDirFromPath(fileSystem, projectPathString);
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),
useDeferredLoading: useDeferredLoading,
untranslatedMessagesFile: _untranslatedMessagesFileFromPath(fileSystem, untranslatedMessagesFile),
inputsAndOutputsListFile: _inputsAndOutputsListFileFromPath(fileSystem, inputsAndOutputsListPath),
areResourceAttributesRequired: areResourceAttributesRequired,
useEscaping: useEscaping,
logger: logger,
suppressWarnings: suppressWarnings,
);
}
/// Creates an instance of the localizations generator class.
///
/// It takes in a [FileSystem] representation that the class will act upon.
LocalizationsGenerator._(this._fs, {
required this.inputDirectory,
required this.outputDirectory,
required this.templateArbFile,
required this.baseOutputFile,
required this.className,
this.preferredSupportedLocales = const <LocaleInfo>[],
this.header = '',
this.useDeferredLoading = false,
required this.inputsAndOutputsListFile,
this.useSyntheticPackage = true,
this.projectDirectory,
this.areResourceAttributesRequired = false,
this.untranslatedMessagesFile,
this.usesNullableGetter = true,
required this.logger,
this.useEscaping = false,
this.suppressWarnings = false,
});
final FileSystem _fs;
List<Message> _allMessages = <Message>[];
late final AppResourceBundleCollection _allBundles = AppResourceBundleCollection(inputDirectory);
late final AppResourceBundle _templateBundle = AppResourceBundle(templateArbFile);
late final Map<LocaleInfo, String> _inputFileNames = Map<LocaleInfo, String>.fromEntries(
_allBundles.bundles.map((AppResourceBundle bundle) => MapEntry<LocaleInfo, String>(bundle.locale, bundle.file.basename))
);
late final LocaleInfo _templateArbLocale = _templateBundle.locale;
@visibleForTesting
final bool useSyntheticPackage;
// Used to decide if the generated code is nullable or not
// (whether AppLocalizations? or AppLocalizations is returned from
// `static {name}Localizations{?} of (BuildContext context))`
@visibleForTesting
final bool usesNullableGetter;
/// The directory that contains the project's arb files, as well as the
/// header file, if specified.
///
/// It is assumed that all input files (e.g. [templateArbFile], arb files
/// for translated messages, header file templates) will reside here.
final Directory inputDirectory;
/// The Flutter project's root directory.
final Directory? projectDirectory;
/// The directory to generate the project's localizations files in.
///
/// It is assumed that all output files (e.g. The localizations
/// [outputFile], `messages_<locale>.dart` and `messages_all.dart`)
/// will reside here.
final Directory outputDirectory;
/// The input arb file which defines all of the messages that will be
/// exported by the generated class that's written to [outputFile].
final File templateArbFile;
/// 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.
final File baseOutputFile;
/// 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.
final String className;
/// 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'.
final List<LocaleInfo> preferredSupportedLocales;
// Whether we need to import intl or not. This flag is updated after parsing
// all of the messages.
bool requiresIntlImport = false;
// Whether we want to use escaping for ICU messages.
bool useEscaping = false;
/// Whether any errors were caught. This is set after encountering any errors
/// from calling [_generateMethod].
bool hadErrors = false;
/// The list of all arb path strings in [inputDirectory].
List<String> get arbPathStrings {
return _allBundles.bundles.map((AppResourceBundle bundle) => bundle.file.path).toList();
}
List<String> get outputFileList {
return _outputFileList;
}
/// The supported language codes as found in the arb files located in
/// [inputDirectory].
final Set<String> supportedLanguageCodes = <String>{};
/// The supported locales as found in the arb files located in
/// [inputDirectory].
final Set<LocaleInfo> supportedLocales = <LocaleInfo>{};
/// The header to be prepended to the generated Dart localization file.
final String header;
final Map<LocaleInfo, List<String>> _unimplementedMessages = <LocaleInfo, List<String>>{};
/// 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 apps 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.
///
/// This flag does not affect other platforms such as mobile or desktop.
final bool useDeferredLoading;
/// Contains a map of each output language file to its corresponding content in
/// string format.
final Map<File, String> _languageFileMap = <File, String>{};
/// A generated file that will contain the list of messages for each locale
/// that do not have a translation yet.
final File? untranslatedMessagesFile;
/// The file that contains the list of inputs and outputs for generating
/// localizations.
@visibleForTesting
final File? inputsAndOutputsListFile;
final List<String> _inputFileList = <String>[];
final List<String> _outputFileList = <String>[];
/// Whether or not resource attributes are required for each corresponding
/// resource id.
///
/// Resource attributes provide metadata about the message.
@visibleForTesting
final bool areResourceAttributesRequired;
/// Logger to be used during the execution of the script.
Logger logger;
/// Whether or not to suppress warnings or not.
final bool suppressWarnings;
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');
}
@visibleForTesting
static Directory? projectDirFromPath(FileSystem fileSystem, String? projectPathString) {
if (projectPathString == null) {
return null;
}
final Directory directory = fileSystem.directory(projectPathString);
if (!directory.existsSync()) {
throw L10nException(
'Directory does not exist: $directory.\n'
"Please select a directory that contains the project's localizations "
'resource files.'
);
}
return directory;
}
/// Sets the reference [Directory] for [inputDirectory].
@visibleForTesting
static Directory inputDirectoryFromPath(FileSystem fileSystem, String inputPathString, Directory? projectDirectory) {
final Directory inputDirectory = fileSystem.directory(
projectDirectory != null
? _getAbsoluteProjectPath(inputPathString, projectDirectory)
: inputPathString
);
if (!inputDirectory.existsSync()) {
throw L10nException(
"The 'arb-dir' directory, '$inputDirectory', does not exist.\n"
'Make sure that the correct path was provided.'
);
}
final FileStat fileStat = inputDirectory.statSync();
if (_isNotReadable(fileStat) || _isNotWritable(fileStat)) {
throw L10nException(
"The 'arb-dir' directory, '$inputDirectory', doesn't allow reading and writing.\n"
'Please ensure that the user has read and write permissions.'
);
}
return inputDirectory;
}
/// Sets the reference [Directory] for [outputDirectory].
@visibleForTesting
static Directory outputDirectoryFromPath(FileSystem fileSystem, String outputPathString, bool useSyntheticPackage, Directory? projectDirectory) {
Directory outputDirectory;
if (useSyntheticPackage) {
outputDirectory = fileSystem.directory(
projectDirectory != null
? _getAbsoluteProjectPath(_syntheticL10nPackagePath(fileSystem), projectDirectory)
: _syntheticL10nPackagePath(fileSystem)
);
} else {
outputDirectory = fileSystem.directory(
projectDirectory != null
? _getAbsoluteProjectPath(outputPathString, projectDirectory)
: outputPathString
);
}
return outputDirectory;
}
/// Sets the reference [File] for [templateArbFile].
@visibleForTesting
static File templateArbFileFromFileName(String templateArbFileName, Directory inputDirectory) {
final File templateArbFile = inputDirectory.childFile(templateArbFileName);
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();
if (templateArbFileStatModeString[0] == '-' && templateArbFileStatModeString[3] == '-') {
throw L10nException(
"The 'template-arb-file', $templateArbFile, is not readable.\n"
'Please ensure that the user has read permissions.'
);
}
return templateArbFile;
}
static bool _isValidClassName(String className) {
// Public Dart class name cannot begin with an underscore
if (className[0] == '_') {
return false;
}
// Dart class name cannot contain non-alphanumeric symbols
if (className.contains(RegExp(r'[^a-zA-Z_\d]'))) {
return false;
}
// Dart class name must start with upper case character
if (className[0].contains(RegExp(r'[a-z]'))) {
return false;
}
// Dart class name cannot start with a number
if (className[0].contains(RegExp(r'\d'))) {
return false;
}
return true;
}
/// Sets the [className] for the localizations and localizations delegate
/// classes.
@visibleForTesting
static String classNameFromString(String classNameString) {
if (classNameString.isEmpty) {
throw L10nException('classNameString argument cannot be empty');
}
if (!_isValidClassName(classNameString)) {
throw L10nException(
"The 'output-class', $classNameString, is not a valid public Dart class name.\n"
);
}
return classNameString;
}
/// Sets [preferredSupportedLocales] so that this particular list of locales
/// will take priority over the other locales.
@visibleForTesting
static List<LocaleInfo> preferredSupportedLocalesFromLocales(List<String>? inputLocales) {
if (inputLocales == null || inputLocales.isEmpty) {
return const <LocaleInfo>[];
}
return inputLocales.map((String localeString) {
return LocaleInfo.fromString(localeString);
}).toList();
}
static String headerFromFile(String? headerString, String? headerFile, Directory inputDirectory) {
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) {
return headerString;
} else if (headerFile != null) {
try {
return inputDirectory.childFile(headerFile).readAsStringSync();
} on FileSystemException catch (error) {
throw L10nException (
'Failed to read header file: "$headerFile". \n'
'FileSystemException: ${error.message}'
);
}
}
return '';
}
static String _getAbsoluteProjectPath(String relativePath, Directory projectDirectory) =>
projectDirectory.fileSystem.path.join(projectDirectory.path, relativePath);
static File? _untranslatedMessagesFileFromPath(FileSystem fileSystem, String? untranslatedMessagesFileString) {
if (untranslatedMessagesFileString == null || untranslatedMessagesFileString.isEmpty) {
return null;
}
untranslatedMessagesFileString = untranslatedMessagesFileString.replaceAll(r'\', fileSystem.path.separator);
return fileSystem.file(untranslatedMessagesFileString);
}
static File? _inputsAndOutputsListFileFromPath(FileSystem fileSystem, String? inputsAndOutputsListPath) {
if (inputsAndOutputsListPath == null) {
return null;
}
return fileSystem.file(
fileSystem.path.join(inputsAndOutputsListPath, 'gen_l10n_inputs_and_outputs.json'),
);
}
static bool _isValidGetterAndMethodName(String name) {
if (name.isEmpty) {
return false;
}
// Public Dart method name must not start with an underscore
if (name[0] == '_') {
return false;
}
// Dart identifiers can only use letters, numbers, underscores, and `$`
if (name.contains(RegExp(r'[^a-zA-Z_$\d]'))) {
return false;
}
// Dart getter and method name should start with lower case character
if (name[0].contains(RegExp(r'[A-Z]'))) {
return false;
}
// Dart getter and method name cannot start with a number
if (name[0].contains(RegExp(r'\d'))) {
return false;
}
return true;
}
// Load _allMessages from templateArbFile and _allBundles from all of the ARB
// files in inputDirectory. Also initialized: supportedLocales.
void loadResources() {
for (final String resourceId in _templateBundle.resourceIds) {
if (!_isValidGetterAndMethodName(resourceId)) {
throw L10nException(
'Invalid ARB resource name "$resourceId" in $templateArbFile.\n'
'Resources names must be valid Dart method names: they have to be '
'camel case, cannot start with a number or underscore, and cannot '
'contain non-alphanumeric characters.'
);
}
}
// 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();
hadErrors = _allMessages.any((Message message) => message.hadErrors);
if (inputsAndOutputsListFile != null) {
_inputFileList.addAll(_allBundles.bundles.map((AppResourceBundle bundle) {
return bundle.file.absolute.path;
}));
}
final List<LocaleInfo> allLocales = List<LocaleInfo>.from(_allBundles.locales);
for (final LocaleInfo preferredLocale in preferredSupportedLocales) {
final int index = allLocales.indexOf(preferredLocale);
if (index == -1) {
throw L10nException(
"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.'
);
}
allLocales.removeAt(index);
allLocales.insertAll(0, preferredSupportedLocales);
}
supportedLocales.addAll(allLocales);
}
void _addUnimplementedMessage(LocaleInfo locale, String message) {
if (_unimplementedMessages.containsKey(locale)) {
_unimplementedMessages[locale]!.add(message);
} else {
_unimplementedMessages.putIfAbsent(locale, () => <String>[message]);
}
}
String _generateBaseClassFile(
String className,
String fileName,
String header,
final LocaleInfo locale,
) {
final Iterable<String> methods = _allMessages.map((Message message) {
LocaleInfo localeWithFallback = locale;
if (message.messages[locale] == null) {
_addUnimplementedMessage(locale, message.resourceId);
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 '';
}
return _generateMethod(
message,
localeWithFallback,
);
});
return classFileTemplate
.replaceAll('@(header)', header.isEmpty ? '' : '$header\n\n')
.replaceAll('@(language)', describeLocale(locale.toString()))
.replaceAll('@(baseClass)', className)
.replaceAll('@(fileName)', fileName)
.replaceAll('@(class)', '$className${locale.camelCase()}')
.replaceAll('@(localeName)', locale.toString())
.replaceAll('@(methods)', methods.join('\n\n'))
.replaceAll('@(requiresIntlImport)', requiresIntlImport ? "import 'package:intl/intl.dart' as intl;\n\n" : '');
}
String _generateSubclass(
String className,
AppResourceBundle bundle,
) {
final LocaleInfo locale = bundle.locale;
final String baseClassName = '$className${LocaleInfo.fromString(locale.languageCode).camelCase()}';
_allMessages
.where((Message message) => message.messages[locale] == null)
.forEach((Message message) {
_addUnimplementedMessage(locale, message.resourceId);
});
final Iterable<String> methods = _allMessages
.where((Message message) => message.parsedMessages[locale] != null)
.map((Message message) => _generateMethod(message, locale));
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'));
}
// Generate the AppLocalizations class, its LocalizationsDelegate subclass,
// and all AppLocalizations subclasses for every locale. This method by
// itself does not generate the output files.
String _generateCode() {
bool isBaseClassLocale(LocaleInfo locale, String language) {
return locale.languageCode == language
&& locale.countryCode == null
&& 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();
}
final String directory = _fs.path.basename(outputDirectory.path);
final String outputFileName = _fs.path.basename(baseOutputFile.path);
if (!outputFileName.endsWith('.dart')) {
throw L10nException(
"The 'output-localization-file', $outputFileName, is invalid.\n"
'The file name must have a .dart extension.'
);
}
final Iterable<String> supportedLocalesCode = supportedLocales.map((LocaleInfo locale) {
final String languageCode = locale.languageCode;
final String? countryCode = locale.countryCode;
final String? scriptCode = locale.scriptCode;
if (countryCode == null && scriptCode == null) {
return "Locale('$languageCode')";
} else if (countryCode != null && scriptCode == null) {
return "Locale('$languageCode', '$countryCode')";
} else if (countryCode != null && scriptCode != null) {
return "Locale.fromSubtags(languageCode: '$languageCode', countryCode: '$countryCode', scriptCode: '$scriptCode')";
} else {
return "Locale.fromSubtags(languageCode: '$languageCode', scriptCode: '$scriptCode')";
}
});
final Set<String> supportedLanguageCodes = Set<String>.from(
_allBundles.locales.map<String>((LocaleInfo locale) => "'${locale.languageCode}'")
);
final List<LocaleInfo> allLocales = _allBundles.locales.toList()..sort();
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);
for (final LocaleInfo locale in allLocales) {
if (isBaseClassLocale(locale, locale.languageCode)) {
final File languageMessageFile = outputDirectory.childFile('${fileName}_$locale.$fileExtension');
// 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.
final String languageBaseClassFile = _generateBaseClassFile(
className,
outputFileName,
header,
locale,
);
// 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) {
return _generateSubclass(
className,
_allBundles.bundleFor(locale)!,
);
});
_languageFileMap.putIfAbsent(languageMessageFile, () {
return languageBaseClassFile.replaceAll('@(subclasses)', subclasses.join());
});
}
}
final List<String> sortedClassImports = supportedLocales
.where((LocaleInfo locale) => isBaseClassLocale(locale, locale.languageCode))
.map((LocaleInfo locale) {
final String library = '${fileName}_$locale';
if (useDeferredLoading) {
return "import '$library.$fileExtension' deferred as $library;";
} else {
return "import '$library.$fileExtension';";
}
})
.toList()
..sort();
final String delegateClass = _generateDelegateClass(
allBundles: _allBundles,
className: className,
supportedLanguageCodes: supportedLanguageCodes,
useDeferredLoading: useDeferredLoading,
fileName: fileName,
);
return fileTemplate
.replaceAll('@(header)', header.isEmpty ? '' : '$header\n')
.replaceAll('@(class)', className)
.replaceAll('@(methods)', _allMessages.map((Message message) => generateBaseClassMethod(message, _templateArbLocale)).join('\n'))
.replaceAll('@(importFile)', '$directory/$outputFileName')
.replaceAll('@(supportedLocales)', supportedLocalesCode.join(',\n '))
.replaceAll('@(supportedLanguageCodes)', supportedLanguageCodes.join(', '))
.replaceAll('@(messageClassImports)', sortedClassImports.join('\n'))
.replaceAll('@(delegateClass)', delegateClass)
.replaceAll('@(requiresFoundationImport)', useDeferredLoading ? '' : "import 'package:flutter/foundation.dart';")
.replaceAll('@(requiresIntlImport)', requiresIntlImport ? "import 'package:intl/intl.dart' as intl;" : '')
.replaceAll('@(canBeNullable)', usesNullableGetter ? '?' : '')
.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');
}
String _generateMethod(Message message, LocaleInfo locale) {
try {
// Determine if we must import intl for date or number formatting.
if (message.placeholdersRequireFormatting) {
requiresIntlImport = true;
}
final String translationForMessage = message.messages[locale]!;
final Node node = message.parsedMessages[locale]!;
// If the placeholders list is empty, then return a getter method.
if (message.placeholders.isEmpty) {
// 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())}'");
}
final List<String> tempVariables = <String>[];
// Get a unique temporary variable name.
int variableCount = 0;
String getTempVariableName() {
return '_temp${variableCount++}';
}
// 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 }) {
switch (node.type) {
case ST.message:
final List<String> expressions = node.children.map<String>((Node node) {
if (node.type == ST.string) {
return node.value!;
}
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';
}
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(
'''
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,';
} else if (!suppressWarnings) {
logger.printWarning('''
[${_inputFileNames[locale]}:${message.resourceId}] ICU Syntax Warning: The plural part specified below is overridden by a later plural part.
$translationForMessage
${Parser.indentForError(pluralPart.positionInMessage)}''');
}
}
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';
// ignore: no_default_cases
default:
throw Exception('Cannot call "generateHelperMethod" on node type ${node.type}');
}
}
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());
hadErrors = true;
return '';
}
}
List<String> writeOutputFiles({ bool isFromYaml = false }) {
// First, generate the string contents of all necessary files.
final String generatedLocalizationsFile = _generateCode();
// If there were any syntax errors, don't write to files.
if (hadErrors) {
throw L10nException('Found syntax errors.');
}
// A pubspec.yaml file is required when using a synthetic package. If it does not
// exist, create a blank one.
if (useSyntheticPackage) {
final Directory syntheticPackageDirectory = projectDirectory != null
? projectDirectory!.childDirectory(_defaultSyntheticPackagePath(_fs))
: _fs.directory(_defaultSyntheticPackagePath(_fs));
syntheticPackageDirectory.createSync(recursive: true);
final File flutterGenPubspec = syntheticPackageDirectory.childFile('pubspec.yaml');
if (!flutterGenPubspec.existsSync()) {
flutterGenPubspec.writeAsStringSync(emptyPubspecTemplate);
}
}
// Since all validity checks have passed up to this point,
// write the contents into the directory.
outputDirectory.createSync(recursive: true);
// Ensure that the created directory has read/write permissions.
final FileStat fileStat = outputDirectory.statSync();
if (_isNotReadable(fileStat) || _isNotWritable(fileStat)) {
throw L10nException(
"The 'output-dir' directory, $outputDirectory, doesn't allow reading and writing.\n"
'Please ensure that the user has read and write permissions.'
);
}
// Generate the required files for localizations.
_languageFileMap.forEach((File file, String contents) {
file.writeAsStringSync(contents);
_outputFileList.add(file.absolute.path);
});
baseOutputFile.writeAsStringSync(generatedLocalizationsFile);
final File? messagesFile = untranslatedMessagesFile;
if (messagesFile != null) {
_generateUntranslatedMessagesFile(logger, messagesFile);
} else if (_unimplementedMessages.isNotEmpty) {
_unimplementedMessages.forEach((LocaleInfo locale, List<String> messages) {
logger.printStatus('"$locale": ${messages.length} untranslated message(s).');
});
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'
);
}
logger.printStatus(
'This will generate a JSON format file containing all messages that \n'
'need to be translated.'
);
}
final File? inputsAndOutputsListFileLocal = inputsAndOutputsListFile;
_outputFileList.add(baseOutputFile.absolute.path);
if (inputsAndOutputsListFileLocal != null) {
// Generate a JSON file containing the inputs and outputs of the gen_l10n script.
if (!inputsAndOutputsListFileLocal.existsSync()) {
inputsAndOutputsListFileLocal.createSync(recursive: true);
}
inputsAndOutputsListFileLocal.writeAsStringSync(
json.encode(<String, Object> {
'inputs': _inputFileList,
'outputs': _outputFileList,
}),
);
}
return _outputFileList;
}
void _generateUntranslatedMessagesFile(Logger logger, File untranslatedMessagesFile) {
if (_unimplementedMessages.isEmpty) {
untranslatedMessagesFile.writeAsStringSync('{}');
_outputFileList.add(untranslatedMessagesFile.absolute.path);
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';
untranslatedMessagesFile.writeAsStringSync(resultingFile);
_outputFileList.add(untranslatedMessagesFile.absolute.path);
}
}