| // 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 'package:yaml/yaml.dart'; | |
| import '../base/common.dart'; | |
| import '../base/file_system.dart'; | |
| import '../base/logger.dart'; | |
| import '../globals.dart' as globals; | |
| import '../runner/flutter_command.dart'; | |
| import 'gen_l10n_types.dart'; | |
| import 'language_subtag_registry.dart'; | |
| typedef HeaderGenerator = String Function(String regenerateInstructions); | |
| typedef ConstructorGenerator = String Function(LocaleInfo locale); | |
| int sortFilesByPath(File a, File b) { | |
| return a.path.compareTo(b.path); | |
| } | |
| /// Simple data class to hold parsed locale. Does not promise validity of any data. | |
| @immutable | |
| class LocaleInfo implements Comparable<LocaleInfo> { | |
| const LocaleInfo({ | |
| required this.languageCode, | |
| required this.scriptCode, | |
| required this.countryCode, | |
| required this.length, | |
| required this.originalString, | |
| }); | |
| /// Simple parser. Expects the locale string to be in the form of 'language_script_COUNTRY' | |
| /// where the language is 2 characters, script is 4 characters with the first uppercase, | |
| /// and country is 2-3 characters and all uppercase. | |
| /// | |
| /// 'language_COUNTRY' or 'language_script' are also valid. Missing fields will be null. | |
| /// | |
| /// When `deriveScriptCode` is true, if [scriptCode] was unspecified, it will | |
| /// be derived from the [languageCode] and [countryCode] if possible. | |
| factory LocaleInfo.fromString(String locale, {bool deriveScriptCode = false}) { | |
| final List<String> codes = locale.split('_'); // [language, script, country] | |
| assert(codes.isNotEmpty && codes.length < 4); | |
| final String languageCode = codes[0]; | |
| String? scriptCode; | |
| String? countryCode; | |
| int length = codes.length; | |
| var originalString = locale; | |
| if (codes.length == 2) { | |
| scriptCode = codes[1].length >= 4 ? codes[1] : null; | |
| countryCode = codes[1].length < 4 ? codes[1] : null; | |
| } else if (codes.length == 3) { | |
| scriptCode = codes[1].length > codes[2].length ? codes[1] : codes[2]; | |
| countryCode = codes[1].length < codes[2].length ? codes[1] : codes[2]; | |
| } | |
| assert(codes[0].isNotEmpty); | |
| assert(countryCode == null || countryCode.isNotEmpty); | |
| assert(scriptCode == null || scriptCode.isNotEmpty); | |
| /// Adds scriptCodes to locales where we are able to assume it to provide | |
| /// finer granularity when resolving locales. | |
| /// | |
| /// The basis of the assumptions here are based off of known usage of scripts | |
| /// across various countries. For example, we know Taiwan uses traditional (Hant) | |
| /// script, so it is safe to apply (Hant) to Taiwanese languages. | |
| if (deriveScriptCode && scriptCode == null) { | |
| scriptCode = switch ((languageCode, countryCode)) { | |
| ('zh', 'CN' || 'SG' || null) => 'Hans', | |
| ('zh', 'TW' || 'HK' || 'MO') => 'Hant', | |
| ('sr', null) => 'Cyrl', | |
| _ => null, | |
| }; | |
| // Increment length if we were able to assume a scriptCode. | |
| if (scriptCode != null) { | |
| length += 1; | |
| } | |
| // Update the base string to reflect assumed scriptCodes. | |
| originalString = languageCode; | |
| if (scriptCode != null) { | |
| originalString += '_$scriptCode'; | |
| } | |
| if (countryCode != null) { | |
| originalString += '_$countryCode'; | |
| } | |
| } | |
| return LocaleInfo( | |
| languageCode: languageCode, | |
| scriptCode: scriptCode, | |
| countryCode: countryCode, | |
| length: length, | |
| originalString: originalString, | |
| ); | |
| } | |
| final String languageCode; | |
| final String? scriptCode; | |
| final String? countryCode; | |
| final int length; // The number of fields. Ranges from 1-3. | |
| final String originalString; // Original un-parsed locale string. | |
| String camelCase() { | |
| return originalString | |
| .split('_') | |
| .map<String>( | |
| (String part) => part.substring(0, 1).toUpperCase() + part.substring(1).toLowerCase(), | |
| ) | |
| .join(); | |
| } | |
| @override | |
| bool operator ==(Object other) { | |
| return other is LocaleInfo && other.originalString == originalString; | |
| } | |
| @override | |
| int get hashCode => originalString.hashCode; | |
| @override | |
| String toString() { | |
| return originalString; | |
| } | |
| @override | |
| int compareTo(LocaleInfo other) { | |
| return originalString.compareTo(other.originalString); | |
| } | |
| } | |
| // See also //master/tools/gen_locale.dart in the engine repo. | |
| Map<String, List<String>> _parseSection(String section) { | |
| final result = <String, List<String>>{}; | |
| late List<String> lastHeading; | |
| for (final String line in section.split('\n')) { | |
| if (line == '') { | |
| continue; | |
| } | |
| if (line.startsWith(' ')) { | |
| lastHeading[lastHeading.length - 1] = '${lastHeading.last}${line.substring(1)}'; | |
| continue; | |
| } | |
| final int colon = line.indexOf(':'); | |
| if (colon <= 0) { | |
| throw Exception('not sure how to deal with "$line"'); | |
| } | |
| final String name = line.substring(0, colon); | |
| final String value = line.substring(colon + 2); | |
| lastHeading = result.putIfAbsent(name, () => <String>[]); | |
| result[name]!.add(value); | |
| } | |
| return result; | |
| } | |
| final _languages = <String, String>{}; | |
| final _regions = <String, String>{}; | |
| final _scripts = <String, String>{}; | |
| const kProvincePrefix = ', Province of '; | |
| const kParentheticalPrefix = ' ('; | |
| /// Prepares the data for the [describeLocale] method below. | |
| /// | |
| /// The data is obtained from the official IANA registry. | |
| void precacheLanguageAndRegionTags() { | |
| final List<Map<String, List<String>>> sections = languageSubtagRegistry | |
| .split('%%') | |
| .skip(1) | |
| .map<Map<String, List<String>>>(_parseSection) | |
| .toList(); | |
| for (final section in sections) { | |
| assert(section.containsKey('Type'), section.toString()); | |
| final String type = section['Type']!.single; | |
| if (type == 'language' || type == 'region' || type == 'script') { | |
| assert( | |
| section.containsKey('Subtag') && section.containsKey('Description'), | |
| section.toString(), | |
| ); | |
| final String subtag = section['Subtag']!.single; | |
| String description = section['Description']!.join(' '); | |
| if (description.startsWith('United ')) { | |
| description = 'the $description'; | |
| } | |
| if (description.contains(kParentheticalPrefix)) { | |
| description = description.substring(0, description.indexOf(kParentheticalPrefix)); | |
| } | |
| if (description.contains(kProvincePrefix)) { | |
| description = description.substring(0, description.indexOf(kProvincePrefix)); | |
| } | |
| if (description.endsWith(' Republic')) { | |
| description = 'the $description'; | |
| } | |
| switch (type) { | |
| case 'language': | |
| _languages[subtag] = description; | |
| case 'region': | |
| _regions[subtag] = description; | |
| case 'script': | |
| _scripts[subtag] = description; | |
| } | |
| } | |
| } | |
| } | |
| String describeLocale(String tag) { | |
| final List<String> subtags = tag.split('_'); | |
| assert(subtags.isNotEmpty); | |
| final String languageCode = subtags[0]; | |
| if (!_languages.containsKey(languageCode)) { | |
| throw L10nException( | |
| '"$languageCode" is not a supported language code.\n' | |
| 'See https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry ' | |
| 'for the supported list.', | |
| ); | |
| } | |
| final String language = _languages[languageCode]!; | |
| var output = language; | |
| String? region; | |
| String? script; | |
| if (subtags.length == 2) { | |
| region = _regions[subtags[1]]; | |
| script = _scripts[subtags[1]]; | |
| assert(region != null || script != null); | |
| } else if (subtags.length >= 3) { | |
| region = _regions[subtags[2]]; | |
| script = _scripts[subtags[1]]; | |
| assert(region != null && script != null); | |
| } | |
| if (region != null) { | |
| output += ', as used in $region'; | |
| } | |
| if (script != null) { | |
| output += ', using the $script script'; | |
| } | |
| return output; | |
| } | |
| /// Return the input string as a Dart-parsable string. | |
| /// | |
| /// ```none | |
| /// foo => 'foo' | |
| /// foo "bar" => 'foo "bar"' | |
| /// foo 'bar' => "foo 'bar'" | |
| /// foo 'bar' "baz" => '''foo 'bar' "baz"''' | |
| /// ``` | |
| /// | |
| /// This function is used by tools that take in a JSON-formatted file to | |
| /// generate Dart code. For this reason, characters with special meaning | |
| /// in JSON files are escaped. For example, the backspace character (\b) | |
| /// has to be properly escaped by this function so that the generated | |
| /// Dart code correctly represents this character: | |
| /// ```none | |
| /// foo\bar => 'foo\\bar' | |
| /// foo\nbar => 'foo\\nbar' | |
| /// foo\\nbar => 'foo\\\\nbar' | |
| /// foo\\bar => 'foo\\\\bar' | |
| /// foo\ bar => 'foo\\ bar' | |
| /// foo$bar = 'foo\$bar' | |
| /// ``` | |
| String generateString(String value) { | |
| const backslash = '__BACKSLASH__'; | |
| assert( | |
| !value.contains(backslash), | |
| 'Input string cannot contain the sequence: ' | |
| '"__BACKSLASH__", as it is used as part of ' | |
| 'backslash character processing.', | |
| ); | |
| value = value | |
| // Replace backslashes with a placeholder for now to properly parse | |
| // other special characters. | |
| .replaceAll(r'\', backslash) | |
| .replaceAll(r'$', r'\$') | |
| .replaceAll("'", r"\'") | |
| .replaceAll('"', r'\"') | |
| .replaceAll('\n', r'\n') | |
| .replaceAll('\f', r'\f') | |
| .replaceAll('\t', r'\t') | |
| .replaceAll('\r', r'\r') | |
| .replaceAll('\b', r'\b') | |
| // Reintroduce escaped backslashes into generated Dart string. | |
| .replaceAll(backslash, r'\\'); | |
| return value; | |
| } | |
| /// Given a list of normal strings or interpolated variables, concatenate them | |
| /// into a single dart string to be returned. An example of a normal string | |
| /// would be "'Hello world!'" and an example of a interpolated variable would be | |
| /// "'$placeholder'". | |
| /// | |
| /// Each of the strings in [expressions] should be a raw string, which, if it | |
| /// were to be added to a dart file, would be a properly formatted dart string | |
| /// with escapes and/or interpolation. The purpose of this function is to | |
| /// concatenate these dart strings into a single dart string which can be | |
| /// returned in the generated localization files. | |
| /// | |
| /// The following rules describe the kinds of string expressions that can be | |
| /// handled: | |
| /// 1. If [expressions] is empty, return the empty string "''". | |
| /// 2. If [expressions] has only one [String] which is an interpolated variable, | |
| /// it is converted to the variable itself e.g. ["'$expr'"] -> "expr". | |
| /// 3. If one string in [expressions] is an interpolation and the next begins | |
| /// with an alphanumeric character, then the former interpolation should be | |
| /// wrapped in braces e.g. ["'$expr1'", "'another'"] -> "'${expr1}another'". | |
| String generateReturnExpr(List<String> expressions, {bool isSingleStringVar = false}) { | |
| if (expressions.isEmpty) { | |
| return "''"; | |
| } else if (isSingleStringVar) { | |
| // If our expression is "$varName" where varName is a String, this is equivalent to just varName. | |
| return expressions[0].substring(1); | |
| } else { | |
| final String string = expressions.reversed.fold<String>('', (String string, String expression) { | |
| if (expression[0] != r'$') { | |
| return expression + string; | |
| } | |
| final alphanumeric = RegExp(r'^([0-9a-zA-Z]|_)+$'); | |
| if (alphanumeric.hasMatch(expression.substring(1)) && | |
| !(string.isNotEmpty && alphanumeric.hasMatch(string[0]))) { | |
| return '$expression$string'; | |
| } else { | |
| return '\${${expression.substring(1)}}$string'; | |
| } | |
| }); | |
| return "'$string'"; | |
| } | |
| } | |
| /// Typed configuration from the localizations config file. | |
| class LocalizationOptions { | |
| LocalizationOptions({ | |
| required this.arbDir, | |
| this.outputDir, | |
| String? templateArbFile, | |
| String? outputLocalizationFile, | |
| this.untranslatedMessagesFile, | |
| String? outputClass, | |
| this.preferredSupportedLocales, | |
| this.header, | |
| this.headerFile, | |
| bool? useDeferredLoading, | |
| this.genInputsAndOutputsList, | |
| this.projectDir, | |
| bool? requiredResourceAttributes, | |
| bool? nullableGetter, | |
| bool? format, | |
| bool? useEscaping, | |
| bool? suppressWarnings, | |
| bool? relaxSyntax, | |
| bool? useNamedParameters, | |
| }) : templateArbFile = templateArbFile ?? 'app_en.arb', | |
| outputLocalizationFile = outputLocalizationFile ?? 'app_localizations.dart', | |
| outputClass = outputClass ?? 'AppLocalizations', | |
| useDeferredLoading = useDeferredLoading ?? false, | |
| requiredResourceAttributes = requiredResourceAttributes ?? false, | |
| nullableGetter = nullableGetter ?? true, | |
| format = format ?? true, | |
| useEscaping = useEscaping ?? false, | |
| suppressWarnings = suppressWarnings ?? false, | |
| relaxSyntax = relaxSyntax ?? false, | |
| useNamedParameters = useNamedParameters ?? false; | |
| /// The `--arb-dir` argument. | |
| /// | |
| /// The directory where all input localization files should reside. | |
| final String arbDir; | |
| /// The `--output-dir` argument. | |
| /// | |
| /// The directory where all output localization files should be generated. | |
| final String? outputDir; | |
| /// The `--template-arb-file` argument. | |
| /// | |
| /// This path is relative to [arbDir]. | |
| final String templateArbFile; | |
| /// The `--output-localization-file` argument. | |
| /// | |
| /// This path is relative to [arbDir]. | |
| final String outputLocalizationFile; | |
| /// The `--untranslated-messages-file` argument. | |
| /// | |
| /// This path is relative to [arbDir]. | |
| final String? untranslatedMessagesFile; | |
| /// The `--output-class` argument. | |
| final String outputClass; | |
| /// The `--preferred-supported-locales` argument. | |
| final List<String>? preferredSupportedLocales; | |
| /// The `--header` argument. | |
| /// | |
| /// The header to prepend to the generated Dart localizations. | |
| final String? header; | |
| /// The `--header-file` argument. | |
| /// | |
| /// A file containing the header to prepend to the generated | |
| /// Dart localizations. | |
| final String? headerFile; | |
| /// The `--use-deferred-loading` argument. | |
| /// | |
| /// Whether to generate the Dart localization file with locales imported | |
| /// as deferred. | |
| final bool useDeferredLoading; | |
| /// The `--gen-inputs-and-outputs-list` argument. | |
| /// | |
| /// This path is relative to [arbDir]. | |
| final String? genInputsAndOutputsList; | |
| /// The `--project-dir` argument. | |
| /// | |
| /// This path is relative to [arbDir]. | |
| final String? projectDir; | |
| /// The `required-resource-attributes` argument. | |
| /// | |
| /// Whether to require all resource ids to contain a corresponding | |
| /// resource attribute. | |
| final bool requiredResourceAttributes; | |
| /// The `nullable-getter` argument. | |
| /// | |
| /// Whether or not the localizations class getter is nullable. | |
| final bool nullableGetter; | |
| /// The `format` argument. | |
| /// | |
| /// Whether or not to format the generated files. | |
| final bool format; | |
| /// The `use-escaping` argument. | |
| /// | |
| /// Whether or not the ICU escaping syntax is used. | |
| final bool useEscaping; | |
| /// The `suppress-warnings` argument. | |
| /// | |
| /// Whether or not to suppress warnings. | |
| final bool suppressWarnings; | |
| /// The `relax-syntax` argument. | |
| /// | |
| /// Whether or not to relax the syntax. When specified, the syntax will be | |
| /// relaxed so that the special character "{" is treated as a string if it is | |
| /// not followed by a valid placeholder and "}" is treated as a string if it | |
| /// does not close any previous "{" that is treated as a special character. | |
| /// This was added in for backward compatibility and is not recommended | |
| /// as it may mask errors. | |
| final bool relaxSyntax; | |
| /// The `use-named-parameters` argument. | |
| /// | |
| /// Whether or not to use named parameters for the generated localization | |
| /// methods. | |
| /// | |
| /// Defaults to `false`. | |
| final bool useNamedParameters; | |
| } | |
| /// Parse the localizations configuration options from [file]. | |
| /// | |
| /// Throws [Exception] if any of the contents are invalid. Returns a | |
| /// [LocalizationOptions] with all fields as `null` if the config file exists | |
| /// but is empty. | |
| LocalizationOptions parseLocalizationsOptionsFromYAML({ | |
| required File file, | |
| required Logger logger, | |
| required FileSystem fileSystem, | |
| required String defaultArbDir, | |
| }) { | |
| final String contents = file.readAsStringSync(); | |
| if (contents.trim().isEmpty) { | |
| return LocalizationOptions(arbDir: defaultArbDir); | |
| } | |
| final YamlNode yamlNode; | |
| try { | |
| yamlNode = loadYamlNode(file.readAsStringSync()); | |
| } on YamlException catch (err) { | |
| throwToolExit(err.message); | |
| } | |
| if (yamlNode is! YamlMap) { | |
| logger.printError('Expected ${file.path} to contain a map, instead was $yamlNode'); | |
| throw Exception(); | |
| } | |
| const kSyntheticPackage = 'synthetic-package'; | |
| const kFlutterGenNotice = 'http://flutter.dev/to/flutter-gen-deprecation'; | |
| final bool? syntheticPackage = _tryReadBool(yamlNode, kSyntheticPackage, logger); | |
| if (syntheticPackage != null) { | |
| if (syntheticPackage) { | |
| throwToolExit( | |
| '${file.path}: Cannot enable "$kSyntheticPackage", this feature has ' | |
| 'been removed. See $kFlutterGenNotice.', | |
| ); | |
| } else { | |
| logger.printWarning( | |
| '${file.path}: The argument "$kSyntheticPackage" no longer has any ' | |
| 'effect and should be removed. See $kFlutterGenNotice', | |
| ); | |
| } | |
| } | |
| return LocalizationOptions( | |
| arbDir: _tryReadFilePath(yamlNode, 'arb-dir', logger, fileSystem) ?? defaultArbDir, | |
| outputDir: _tryReadFilePath(yamlNode, 'output-dir', logger, fileSystem), | |
| templateArbFile: _tryReadFilePath(yamlNode, 'template-arb-file', logger, fileSystem), | |
| outputLocalizationFile: _tryReadFilePath( | |
| yamlNode, | |
| 'output-localization-file', | |
| logger, | |
| fileSystem, | |
| ), | |
| untranslatedMessagesFile: _tryReadFilePath( | |
| yamlNode, | |
| 'untranslated-messages-file', | |
| logger, | |
| fileSystem, | |
| ), | |
| outputClass: _tryReadString(yamlNode, 'output-class', logger), | |
| header: _tryReadString(yamlNode, 'header', logger), | |
| headerFile: _tryReadFilePath(yamlNode, 'header-file', logger, fileSystem), | |
| useDeferredLoading: _tryReadBool(yamlNode, 'use-deferred-loading', logger), | |
| preferredSupportedLocales: _tryReadStringList(yamlNode, 'preferred-supported-locales', logger), | |
| requiredResourceAttributes: _tryReadBool(yamlNode, 'required-resource-attributes', logger), | |
| nullableGetter: _tryReadBool(yamlNode, 'nullable-getter', logger), | |
| format: _tryReadBool(yamlNode, 'format', logger), | |
| useEscaping: _tryReadBool(yamlNode, 'use-escaping', logger), | |
| suppressWarnings: _tryReadBool(yamlNode, 'suppress-warnings', logger), | |
| relaxSyntax: _tryReadBool(yamlNode, 'relax-syntax', logger), | |
| useNamedParameters: _tryReadBool(yamlNode, 'use-named-parameters', logger), | |
| ); | |
| } | |
| /// Parse the localizations configuration from [FlutterCommand]. | |
| LocalizationOptions parseLocalizationsOptionsFromCommand({ | |
| required FlutterCommand command, | |
| required String defaultArbDir, | |
| }) { | |
| const kSyntheticPackage = 'synthetic-package'; | |
| const kFlutterGenNotice = 'http://flutter.dev/to/flutter-gen-deprecation'; | |
| if (command.argResults!.wasParsed(kSyntheticPackage)) { | |
| if (command.boolArg(kSyntheticPackage)) { | |
| throwToolExit( | |
| 'Cannot enable "$kSyntheticPackage", this feature has been removed. ' | |
| 'See $kFlutterGenNotice.', | |
| ); | |
| } else { | |
| globals.logger.printWarning( | |
| 'The argument "$kSyntheticPackage" no longer has any effect and should ' | |
| 'be removed. See $kFlutterGenNotice', | |
| ); | |
| } | |
| } | |
| return LocalizationOptions( | |
| arbDir: command.stringArg('arb-dir') ?? defaultArbDir, | |
| outputDir: command.stringArg('output-dir'), | |
| outputLocalizationFile: command.stringArg('output-localization-file'), | |
| templateArbFile: command.stringArg('template-arb-file'), | |
| untranslatedMessagesFile: command.stringArg('untranslated-messages-file'), | |
| outputClass: command.stringArg('output-class'), | |
| header: command.stringArg('header'), | |
| headerFile: command.stringArg('header-file'), | |
| useDeferredLoading: command.boolArg('use-deferred-loading'), | |
| genInputsAndOutputsList: command.stringArg('gen-inputs-and-outputs-list'), | |
| projectDir: command.stringArg('project-dir'), | |
| requiredResourceAttributes: command.boolArg('required-resource-attributes'), | |
| nullableGetter: command.boolArg('nullable-getter'), | |
| format: command.boolArg('format'), | |
| useEscaping: command.boolArg('use-escaping'), | |
| suppressWarnings: command.boolArg('suppress-warnings'), | |
| useNamedParameters: command.boolArg('use-named-parameters'), | |
| ); | |
| } | |
| // Try to read a `bool` value or null from `yamlMap`, otherwise throw. | |
| bool? _tryReadBool(YamlMap yamlMap, String key, Logger logger) { | |
| final Object? value = yamlMap[key]; | |
| if (value == null) { | |
| return null; | |
| } | |
| if (value is! bool) { | |
| logger.printError('Expected "$key" to have a bool value, instead was "$value"'); | |
| throw Exception(); | |
| } | |
| return value; | |
| } | |
| // Try to read a `String` value or null from `yamlMap`, otherwise throw. | |
| String? _tryReadString(YamlMap yamlMap, String key, Logger logger) { | |
| final Object? value = yamlMap[key]; | |
| if (value == null) { | |
| return null; | |
| } | |
| if (value is! String) { | |
| logger.printError('Expected "$key" to have a String value, instead was "$value"'); | |
| throw Exception(); | |
| } | |
| return value; | |
| } | |
| List<String>? _tryReadStringList(YamlMap yamlMap, String key, Logger logger) { | |
| final Object? value = yamlMap[key]; | |
| if (value == null) { | |
| return null; | |
| } | |
| if (value is String) { | |
| return <String>[value]; | |
| } | |
| if (value is Iterable) { | |
| return value.map((dynamic e) => e.toString()).toList(); | |
| } | |
| logger.printError('"$value" must be String or List.'); | |
| throw Exception(); | |
| } | |
| // Try to read a valid file `Uri` or null from `yamlMap` to file path, otherwise throw. | |
| String? _tryReadFilePath(YamlMap yamlMap, String key, Logger logger, FileSystem fileSystem) { | |
| final String? value = _tryReadString(yamlMap, key, logger); | |
| if (value == null) { | |
| return null; | |
| } | |
| final Uri? uri = Uri.tryParse(value); | |
| if (uri == null) { | |
| logger.printError('"$value" must be a relative file URI'); | |
| } | |
| return uri != null ? fileSystem.path.normalize(uri.path) : null; | |
| } |