mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00

* lerp documentation * Remove Note, Note That from repo * Improve BorderSide documentation. * apply review comments
1375 lines
54 KiB
Dart
1375 lines
54 KiB
Dart
// 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 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.
|
||
///
|
||
/// 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);
|
||
}
|
||
}
|