// Copyright 2017 The Chromium 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:async'; import 'dart:convert'; import 'dart:io'; import 'package:args/args.dart' as argslib; import 'package:meta/meta.dart'; typedef HeaderGenerator = String Function(String regenerateInstructions); typedef ConstructorGenerator = String Function(LocaleInfo locale); /// Simple data class to hold parsed locale. Does not promise validity of any data. class LocaleInfo implements Comparable<LocaleInfo> { LocaleInfo({ this.languageCode, this.scriptCode, this.countryCode, this.length, this.originalString, }); /// Simple parser. Expects the locale string to be in the form of 'language_script_COUNTRY' /// where the langauge 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] != null && 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. @override bool operator ==(Object other) { if (!(other is LocaleInfo)) return false; final LocaleInfo otherLocale = other; return originalString == otherLocale.originalString; } @override int get hashCode { return 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, }) { assert(directory != null); assert(filenamePattern != null); assert(localeToResources != null); assert(localeToResourceAttributes != null); /// 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 (FileSystemEntity entity in directory.listSync()) { 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()); for (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]; } } // 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) { assert(errorMessage != null); 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}' ); } } String camelCase(LocaleInfo locale) { return locale.originalString .split('_') .map<String>((String part) => part.substring(0, 1).toUpperCase() + part.substring(1).toLowerCase()) .join(''); } GeneratorOptions parseArgs(List<String> rawArgs) { final argslib.ArgParser argParser = argslib.ArgParser() ..addFlag( 'overwrite', abbr: 'w', defaultsTo: false, ) ..addFlag( 'material', help: 'Whether to print the generated classes for the Material package only. Ignored when --overwrite is passed.', defaultsTo: false, ) ..addFlag( 'cupertino', help: 'Whether to print the generated classes for the Cupertino package only. Ignored when --overwrite is passed.', defaultsTo: false, ); final argslib.ArgResults args = argParser.parse(rawArgs); final bool writeToFile = args['overwrite']; final bool materialOnly = args['material']; final bool cupertinoOnly = args['cupertino']; return GeneratorOptions(writeToFile: writeToFile, materialOnly: materialOnly, cupertinoOnly: cupertinoOnly); } class GeneratorOptions { GeneratorOptions({ @required this.writeToFile, @required this.materialOnly, @required this.cupertinoOnly, }); final bool writeToFile; final bool materialOnly; final bool cupertinoOnly; } const String registry = 'https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry'; // 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>>{}; List<String> lastHeading; for (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. Future<void> precacheLanguageAndRegionTags() async { final HttpClient client = HttpClient(); final HttpClientRequest request = await client.getUrl(Uri.parse(registry)); final HttpClientResponse response = await request.close(); final String body = (await response.transform<String>(utf8.decoder).toList()).join(''); client.close(force: true); final List<Map<String, List<String>>> sections = body.split('%%').skip(1).map<Map<String, List<String>>>(_parseSection).toList(); for (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 = camelCase(locale); return ''' /// The translations for ${describeLocale(locale.originalString)} (`${locale.originalString}`). class $classNamePrefix$camelCaseName extends $superClass {'''; } /// Return `s` as a Dart-parseable raw string in single or double quotes. /// /// Double quotes are expanded: /// /// ``` /// foo => r'foo' /// foo "bar" => r'foo "bar"' /// foo 'bar' => r'foo ' "'" r'bar' "'" /// ``` String generateString(String s) { if (!s.contains("'")) return "r'$s'"; final StringBuffer output = StringBuffer(); bool started = false; // Have we started writing a raw string. for (int i = 0; i < s.length; i++) { if (s[i] == "'") { if (started) output.write("'"); output.write(' "\'" '); started = false; } else if (!started) { output.write("r'${s[i]}"); started = true; } else { output.write(s[i]); } } if (started) output.write("'"); return output.toString(); }