• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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