blob: 0959e16e00b4200553bc345ce9ed331e5a36984b [file] [log] [blame]
// 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 'dart:convert';
import 'dart:io';
import 'package:args/args.dart' as argslib;
import 'package:meta/meta.dart';
import 'language_subtag_registry.dart';
typedef HeaderGenerator = String Function(String regenerateInstructions);
typedef ConstructorGenerator = String Function(LocaleInfo locale);
int sortFilesByPath (FileSystemEntity a, FileSystemEntity 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,
this.scriptCode,
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;
String 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) {
switch (languageCode) {
case 'zh': {
if (countryCode == null) {
scriptCode = 'Hans';
}
switch (countryCode) {
case 'CN':
case 'SG':
scriptCode = 'Hans';
break;
case 'TW':
case 'HK':
case 'MO':
scriptCode = 'Hant';
break;
}
break;
}
case 'sr': {
if (countryCode == null) {
scriptCode = 'Cyrl';
}
break;
}
}
// 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);
}
}
/// Parse the data for a locale from a file, and store it in the [attributes]
/// and [resources] keys.
void loadMatchingArbsIntoBundleMaps({
required Directory directory,
required RegExp filenamePattern,
required Map<LocaleInfo, Map<String, String>> localeToResources,
required Map<LocaleInfo, Map<String, dynamic>> localeToResourceAttributes,
}) {
/// Set that holds the locales that were assumed from the existing locales.
///
/// For example, when the data lacks data for zh_Hant, we will use the data of
/// the first Hant Chinese locale as a default by repeating the data. If an
/// explicit match is later found, we can reference this set to see if we should
/// overwrite the existing assumed data.
final Set<LocaleInfo> assumedLocales = <LocaleInfo>{};
for (final FileSystemEntity entity in directory.listSync().toList()..sort(sortFilesByPath)) {
final String entityPath = entity.path;
if (FileSystemEntity.isFileSync(entityPath) && filenamePattern.hasMatch(entityPath)) {
final String localeString = filenamePattern.firstMatch(entityPath)![1]!;
final File arbFile = File(entityPath);
// Helper method to fill the maps with the correct data from file.
void populateResources(LocaleInfo locale, File file) {
final Map<String, String> resources = localeToResources[locale]!;
final Map<String, dynamic> attributes = localeToResourceAttributes[locale]!;
final Map<String, dynamic> bundle = json.decode(file.readAsStringSync()) as Map<String, dynamic>;
for (final String key in bundle.keys) {
// The ARB file resource "attributes" for foo are called @foo.
if (key.startsWith('@')) {
attributes[key.substring(1)] = bundle[key];
} else {
resources[key] = bundle[key] as String;
}
}
}
// Only pre-assume scriptCode if there is a country or script code to assume off of.
// When we assume scriptCode based on languageCode-only, we want this initial pass
// to use the un-assumed version as a base class.
LocaleInfo locale = LocaleInfo.fromString(localeString, deriveScriptCode: localeString.split('_').length > 1);
// Allow overwrite if the existing data is assumed.
if (assumedLocales.contains(locale)) {
localeToResources[locale] = <String, String>{};
localeToResourceAttributes[locale] = <String, dynamic>{};
assumedLocales.remove(locale);
} else {
localeToResources[locale] ??= <String, String>{};
localeToResourceAttributes[locale] ??= <String, dynamic>{};
}
populateResources(locale, arbFile);
// Add an assumed locale to default to when there is no info on scriptOnly locales.
locale = LocaleInfo.fromString(localeString, deriveScriptCode: true);
if (locale.scriptCode != null) {
final LocaleInfo scriptLocale = LocaleInfo.fromString('${locale.languageCode}_${locale.scriptCode}');
if (!localeToResources.containsKey(scriptLocale)) {
assumedLocales.add(scriptLocale);
localeToResources[scriptLocale] ??= <String, String>{};
localeToResourceAttributes[scriptLocale] ??= <String, dynamic>{};
populateResources(scriptLocale, arbFile);
}
}
}
}
}
void exitWithError(String errorMessage) {
stderr.writeln('fatal: $errorMessage');
exit(1);
}
void checkCwdIsRepoRoot(String commandName) {
final bool isRepoRoot = Directory('.git').existsSync();
if (!isRepoRoot) {
exitWithError(
'$commandName must be run from the root of the Flutter repository. The '
'current working directory is: ${Directory.current.path}'
);
}
}
GeneratorOptions parseArgs(List<String> rawArgs) {
final argslib.ArgParser argParser = argslib.ArgParser()
..addFlag(
'help',
abbr: 'h',
help: 'Print the usage message for this command',
)
..addFlag(
'overwrite',
abbr: 'w',
help: 'Overwrite existing localizations',
)
..addFlag(
'remove-undefined',
help: 'Remove any localizations that are not defined in the canonical locale.',
)
..addFlag(
'material',
help: 'Whether to print the generated classes for the Material package only. Ignored when --overwrite is passed.',
)
..addFlag(
'cupertino',
help: 'Whether to print the generated classes for the Cupertino package only. Ignored when --overwrite is passed.',
);
final argslib.ArgResults args = argParser.parse(rawArgs);
if (args.wasParsed('help') && args['help'] == true) {
stderr.writeln(argParser.usage);
exit(0);
}
final bool writeToFile = args['overwrite'] as bool;
final bool removeUndefined = args['remove-undefined'] as bool;
final bool materialOnly = args['material'] as bool;
final bool cupertinoOnly = args['cupertino'] as bool;
return GeneratorOptions(
writeToFile: writeToFile,
materialOnly: materialOnly,
cupertinoOnly: cupertinoOnly,
removeUndefined: removeUndefined,
);
}
class GeneratorOptions {
GeneratorOptions({
required this.writeToFile,
required this.removeUndefined,
required this.materialOnly,
required this.cupertinoOnly,
});
final bool writeToFile;
final bool removeUndefined;
final bool materialOnly;
final bool cupertinoOnly;
}
// See also //master/tools/gen_locale.dart in the engine repo.
Map<String, List<String>> _parseSection(String section) {
final Map<String, List<String>> 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 '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 Map<String, String> _languages = <String, String>{};
final Map<String, String> _regions = <String, String>{};
final Map<String, String> _scripts = <String, String>{};
const String kProvincePrefix = ', Province of ';
const String 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 Map<String, List<String>> 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;
break;
case 'region':
_regions[subtag] = description;
break;
case 'script':
_scripts[subtag] = description;
break;
}
}
}
}
String describeLocale(String tag) {
final List<String> subtags = tag.split('_');
assert(subtags.isNotEmpty);
assert(_languages.containsKey(subtags[0]));
final String language = _languages[subtags[0]]!;
String 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;
}
/// Writes the header of each class which corresponds to a locale.
String generateClassDeclaration(
LocaleInfo locale,
String classNamePrefix,
String superClass,
) {
final String camelCaseName = locale.camelCase();
return '''
/// The translations for ${describeLocale(locale.originalString)} (`${locale.originalString}`).
class $classNamePrefix$camelCaseName extends $superClass {''';
}
/// Return the input string as a Dart-parseable string.
///
/// ```
/// 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:
/// ```
/// 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) {
if (<String>['\n', '\f', '\t', '\r', '\b'].every((String pattern) => !value.contains(pattern))) {
final bool hasDollar = value.contains(r'$');
final bool hasBackslash = value.contains(r'\');
final bool hasQuote = value.contains("'");
final bool hasDoubleQuote = value.contains('"');
if (!hasQuote) {
return hasBackslash || hasDollar ? "r'$value'" : "'$value'";
}
if (!hasDoubleQuote) {
return hasBackslash || hasDollar ? 'r"$value"' : '"$value"';
}
}
const String 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'";
}
/// Only used to generate localization strings for the Kannada locale ('kn') because
/// some of the localized strings contain characters that can crash Emacs on Linux.
/// See packages/flutter_localizations/lib/src/l10n/README for more information.
String generateEncodedString(String? locale, String value) {
if (locale != 'kn' || value.runes.every((int code) => code <= 0xFF)) {
return generateString(value);
}
final String unicodeEscapes = value.runes.map((int code) => '\\u{${code.toRadixString(16)}}').join();
return "'$unicodeEscapes'";
}