1// Copyright 2017 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import 'dart:async'; 6import 'dart:convert'; 7import 'dart:io'; 8 9import 'package:args/args.dart' as argslib; 10import 'package:meta/meta.dart'; 11 12typedef HeaderGenerator = String Function(String regenerateInstructions); 13typedef ConstructorGenerator = String Function(LocaleInfo locale); 14 15/// Simple data class to hold parsed locale. Does not promise validity of any data. 16class LocaleInfo implements Comparable<LocaleInfo> { 17 LocaleInfo({ 18 this.languageCode, 19 this.scriptCode, 20 this.countryCode, 21 this.length, 22 this.originalString, 23 }); 24 25 /// Simple parser. Expects the locale string to be in the form of 'language_script_COUNTRY' 26 /// where the language is 2 characters, script is 4 characters with the first uppercase, 27 /// and country is 2-3 characters and all uppercase. 28 /// 29 /// 'language_COUNTRY' or 'language_script' are also valid. Missing fields will be null. 30 /// 31 /// When `deriveScriptCode` is true, if [scriptCode] was unspecified, it will 32 /// be derived from the [languageCode] and [countryCode] if possible. 33 factory LocaleInfo.fromString(String locale, { bool deriveScriptCode = false }) { 34 final List<String> codes = locale.split('_'); // [language, script, country] 35 assert(codes.isNotEmpty && codes.length < 4); 36 final String languageCode = codes[0]; 37 String scriptCode; 38 String countryCode; 39 int length = codes.length; 40 String originalString = locale; 41 if (codes.length == 2) { 42 scriptCode = codes[1].length >= 4 ? codes[1] : null; 43 countryCode = codes[1].length < 4 ? codes[1] : null; 44 } else if (codes.length == 3) { 45 scriptCode = codes[1].length > codes[2].length ? codes[1] : codes[2]; 46 countryCode = codes[1].length < codes[2].length ? codes[1] : codes[2]; 47 } 48 assert(codes[0] != null && codes[0].isNotEmpty); 49 assert(countryCode == null || countryCode.isNotEmpty); 50 assert(scriptCode == null || scriptCode.isNotEmpty); 51 52 /// Adds scriptCodes to locales where we are able to assume it to provide 53 /// finer granularity when resolving locales. 54 /// 55 /// The basis of the assumptions here are based off of known usage of scripts 56 /// across various countries. For example, we know Taiwan uses traditional (Hant) 57 /// script, so it is safe to apply (Hant) to Taiwanese languages. 58 if (deriveScriptCode && scriptCode == null) { 59 switch (languageCode) { 60 case 'zh': { 61 if (countryCode == null) { 62 scriptCode = 'Hans'; 63 } 64 switch (countryCode) { 65 case 'CN': 66 case 'SG': 67 scriptCode = 'Hans'; 68 break; 69 case 'TW': 70 case 'HK': 71 case 'MO': 72 scriptCode = 'Hant'; 73 break; 74 } 75 break; 76 } 77 case 'sr': { 78 if (countryCode == null) { 79 scriptCode = 'Cyrl'; 80 } 81 break; 82 } 83 } 84 // Increment length if we were able to assume a scriptCode. 85 if (scriptCode != null) { 86 length += 1; 87 } 88 // Update the base string to reflect assumed scriptCodes. 89 originalString = languageCode; 90 if (scriptCode != null) 91 originalString += '_' + scriptCode; 92 if (countryCode != null) 93 originalString += '_' + countryCode; 94 } 95 96 return LocaleInfo( 97 languageCode: languageCode, 98 scriptCode: scriptCode, 99 countryCode: countryCode, 100 length: length, 101 originalString: originalString, 102 ); 103 } 104 105 final String languageCode; 106 final String scriptCode; 107 final String countryCode; 108 final int length; // The number of fields. Ranges from 1-3. 109 final String originalString; // Original un-parsed locale string. 110 111 @override 112 bool operator ==(Object other) { 113 if (!(other is LocaleInfo)) 114 return false; 115 final LocaleInfo otherLocale = other; 116 return originalString == otherLocale.originalString; 117 } 118 119 @override 120 int get hashCode { 121 return originalString.hashCode; 122 } 123 124 @override 125 String toString() { 126 return originalString; 127 } 128 129 @override 130 int compareTo(LocaleInfo other) { 131 return originalString.compareTo(other.originalString); 132 } 133} 134 135/// Parse the data for a locale from a file, and store it in the [attributes] 136/// and [resources] keys. 137void loadMatchingArbsIntoBundleMaps({ 138 @required Directory directory, 139 @required RegExp filenamePattern, 140 @required Map<LocaleInfo, Map<String, String>> localeToResources, 141 @required Map<LocaleInfo, Map<String, dynamic>> localeToResourceAttributes, 142}) { 143 assert(directory != null); 144 assert(filenamePattern != null); 145 assert(localeToResources != null); 146 assert(localeToResourceAttributes != null); 147 148 /// Set that holds the locales that were assumed from the existing locales. 149 /// 150 /// For example, when the data lacks data for zh_Hant, we will use the data of 151 /// the first Hant Chinese locale as a default by repeating the data. If an 152 /// explicit match is later found, we can reference this set to see if we should 153 /// overwrite the existing assumed data. 154 final Set<LocaleInfo> assumedLocales = <LocaleInfo>{}; 155 156 for (FileSystemEntity entity in directory.listSync()) { 157 final String entityPath = entity.path; 158 if (FileSystemEntity.isFileSync(entityPath) && filenamePattern.hasMatch(entityPath)) { 159 final String localeString = filenamePattern.firstMatch(entityPath)[1]; 160 final File arbFile = File(entityPath); 161 162 // Helper method to fill the maps with the correct data from file. 163 void populateResources(LocaleInfo locale, File file) { 164 final Map<String, String> resources = localeToResources[locale]; 165 final Map<String, dynamic> attributes = localeToResourceAttributes[locale]; 166 final Map<String, dynamic> bundle = json.decode(file.readAsStringSync()); 167 for (String key in bundle.keys) { 168 // The ARB file resource "attributes" for foo are called @foo. 169 if (key.startsWith('@')) 170 attributes[key.substring(1)] = bundle[key]; 171 else 172 resources[key] = bundle[key]; 173 } 174 } 175 // Only pre-assume scriptCode if there is a country or script code to assume off of. 176 // When we assume scriptCode based on languageCode-only, we want this initial pass 177 // to use the un-assumed version as a base class. 178 LocaleInfo locale = LocaleInfo.fromString(localeString, deriveScriptCode: localeString.split('_').length > 1); 179 // Allow overwrite if the existing data is assumed. 180 if (assumedLocales.contains(locale)) { 181 localeToResources[locale] = <String, String>{}; 182 localeToResourceAttributes[locale] = <String, dynamic>{}; 183 assumedLocales.remove(locale); 184 } else { 185 localeToResources[locale] ??= <String, String>{}; 186 localeToResourceAttributes[locale] ??= <String, dynamic>{}; 187 } 188 populateResources(locale, arbFile); 189 // Add an assumed locale to default to when there is no info on scriptOnly locales. 190 locale = LocaleInfo.fromString(localeString, deriveScriptCode: true); 191 if (locale.scriptCode != null) { 192 final LocaleInfo scriptLocale = LocaleInfo.fromString(locale.languageCode + '_' + locale.scriptCode); 193 if (!localeToResources.containsKey(scriptLocale)) { 194 assumedLocales.add(scriptLocale); 195 localeToResources[scriptLocale] ??= <String, String>{}; 196 localeToResourceAttributes[scriptLocale] ??= <String, dynamic>{}; 197 populateResources(scriptLocale, arbFile); 198 } 199 } 200 } 201 } 202} 203 204void exitWithError(String errorMessage) { 205 assert(errorMessage != null); 206 stderr.writeln('fatal: $errorMessage'); 207 exit(1); 208} 209 210void checkCwdIsRepoRoot(String commandName) { 211 final bool isRepoRoot = Directory('.git').existsSync(); 212 213 if (!isRepoRoot) { 214 exitWithError( 215 '$commandName must be run from the root of the Flutter repository. The ' 216 'current working directory is: ${Directory.current.path}' 217 ); 218 } 219} 220 221String camelCase(LocaleInfo locale) { 222 return locale.originalString 223 .split('_') 224 .map<String>((String part) => part.substring(0, 1).toUpperCase() + part.substring(1).toLowerCase()) 225 .join(''); 226} 227 228GeneratorOptions parseArgs(List<String> rawArgs) { 229 final argslib.ArgParser argParser = argslib.ArgParser() 230 ..addFlag( 231 'overwrite', 232 abbr: 'w', 233 defaultsTo: false, 234 ) 235 ..addFlag( 236 'material', 237 help: 'Whether to print the generated classes for the Material package only. Ignored when --overwrite is passed.', 238 defaultsTo: false, 239 ) 240 ..addFlag( 241 'cupertino', 242 help: 'Whether to print the generated classes for the Cupertino package only. Ignored when --overwrite is passed.', 243 defaultsTo: false, 244 ); 245 final argslib.ArgResults args = argParser.parse(rawArgs); 246 final bool writeToFile = args['overwrite']; 247 final bool materialOnly = args['material']; 248 final bool cupertinoOnly = args['cupertino']; 249 250 return GeneratorOptions(writeToFile: writeToFile, materialOnly: materialOnly, cupertinoOnly: cupertinoOnly); 251} 252 253class GeneratorOptions { 254 GeneratorOptions({ 255 @required this.writeToFile, 256 @required this.materialOnly, 257 @required this.cupertinoOnly, 258 }); 259 260 final bool writeToFile; 261 final bool materialOnly; 262 final bool cupertinoOnly; 263} 264 265const String registry = 'https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry'; 266 267// See also //master/tools/gen_locale.dart in the engine repo. 268Map<String, List<String>> _parseSection(String section) { 269 final Map<String, List<String>> result = <String, List<String>>{}; 270 List<String> lastHeading; 271 for (String line in section.split('\n')) { 272 if (line == '') 273 continue; 274 if (line.startsWith(' ')) { 275 lastHeading[lastHeading.length - 1] = '${lastHeading.last}${line.substring(1)}'; 276 continue; 277 } 278 final int colon = line.indexOf(':'); 279 if (colon <= 0) 280 throw 'not sure how to deal with "$line"'; 281 final String name = line.substring(0, colon); 282 final String value = line.substring(colon + 2); 283 lastHeading = result.putIfAbsent(name, () => <String>[]); 284 result[name].add(value); 285 } 286 return result; 287} 288 289final Map<String, String> _languages = <String, String>{}; 290final Map<String, String> _regions = <String, String>{}; 291final Map<String, String> _scripts = <String, String>{}; 292const String kProvincePrefix = ', Province of '; 293const String kParentheticalPrefix = ' ('; 294 295/// Prepares the data for the [describeLocale] method below. 296/// 297/// The data is obtained from the official IANA registry. 298Future<void> precacheLanguageAndRegionTags() async { 299 final HttpClient client = HttpClient(); 300 final HttpClientRequest request = await client.getUrl(Uri.parse(registry)); 301 final HttpClientResponse response = await request.close(); 302 final String body = (await response.cast<List<int>>().transform<String>(utf8.decoder).toList()).join(''); 303 client.close(force: true); 304 final List<Map<String, List<String>>> sections = body.split('%%').skip(1).map<Map<String, List<String>>>(_parseSection).toList(); 305 for (Map<String, List<String>> section in sections) { 306 assert(section.containsKey('Type'), section.toString()); 307 final String type = section['Type'].single; 308 if (type == 'language' || type == 'region' || type == 'script') { 309 assert(section.containsKey('Subtag') && section.containsKey('Description'), section.toString()); 310 final String subtag = section['Subtag'].single; 311 String description = section['Description'].join(' '); 312 if (description.startsWith('United ')) 313 description = 'the $description'; 314 if (description.contains(kParentheticalPrefix)) 315 description = description.substring(0, description.indexOf(kParentheticalPrefix)); 316 if (description.contains(kProvincePrefix)) 317 description = description.substring(0, description.indexOf(kProvincePrefix)); 318 if (description.endsWith(' Republic')) 319 description = 'the $description'; 320 switch (type) { 321 case 'language': 322 _languages[subtag] = description; 323 break; 324 case 'region': 325 _regions[subtag] = description; 326 break; 327 case 'script': 328 _scripts[subtag] = description; 329 break; 330 } 331 } 332 } 333} 334 335String describeLocale(String tag) { 336 final List<String> subtags = tag.split('_'); 337 assert(subtags.isNotEmpty); 338 assert(_languages.containsKey(subtags[0])); 339 final String language = _languages[subtags[0]]; 340 String output = '$language'; 341 String region; 342 String script; 343 if (subtags.length == 2) { 344 region = _regions[subtags[1]]; 345 script = _scripts[subtags[1]]; 346 assert(region != null || script != null); 347 } else if (subtags.length >= 3) { 348 region = _regions[subtags[2]]; 349 script = _scripts[subtags[1]]; 350 assert(region != null && script != null); 351 } 352 if (region != null) 353 output += ', as used in $region'; 354 if (script != null) 355 output += ', using the $script script'; 356 return output; 357} 358 359/// Writes the header of each class which corresponds to a locale. 360String generateClassDeclaration( 361 LocaleInfo locale, 362 String classNamePrefix, 363 String superClass, 364) { 365 final String camelCaseName = camelCase(locale); 366 return ''' 367 368/// The translations for ${describeLocale(locale.originalString)} (`${locale.originalString}`). 369class $classNamePrefix$camelCaseName extends $superClass {'''; 370} 371 372/// Return `s` as a Dart-parseable raw string in single or double quotes. 373/// 374/// Double quotes are expanded: 375/// 376/// ``` 377/// foo => r'foo' 378/// foo "bar" => r'foo "bar"' 379/// foo 'bar' => r'foo ' "'" r'bar' "'" 380/// ``` 381String generateString(String s) { 382 if (!s.contains("'")) 383 return "r'$s'"; 384 385 final StringBuffer output = StringBuffer(); 386 bool started = false; // Have we started writing a raw string. 387 for (int i = 0; i < s.length; i++) { 388 if (s[i] == "'") { 389 if (started) 390 output.write("'"); 391 output.write(' "\'" '); 392 started = false; 393 } else if (!started) { 394 output.write("r'${s[i]}"); 395 started = true; 396 } else { 397 output.write(s[i]); 398 } 399 } 400 if (started) 401 output.write("'"); 402 return output.toString(); 403} 404 405/// Only used to generate localization strings for the Kannada locale ('kn') because 406/// some of the localized strings contain characters that can crash Emacs on Linux. 407/// See packages/flutter_localizations/lib/src/l10n/README for more information. 408String generateEncodedString(String s) { 409 if (s.runes.every((int code) => code <= 0xFF)) 410 return generateString(s); 411 412 final String unicodeEscapes = s.runes.map((int code) => '\\u{${code.toRadixString(16)}}').join(); 413 return "'$unicodeEscapes'"; 414} 415