1// Copyright 2014 The Flutter 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 'package:meta/meta.dart';
6import 'package:yaml/yaml.dart';
7
8import '../base/common.dart';
9import '../base/file_system.dart';
10import '../base/logger.dart';
11import '../globals.dart' as globals;
12import '../runner/flutter_command.dart';
13import 'gen_l10n_types.dart';
14import 'language_subtag_registry.dart';
15
16typedef HeaderGenerator = String Function(String regenerateInstructions);
17typedef ConstructorGenerator = String Function(LocaleInfo locale);
18
19int sortFilesByPath(File a, File b) {
20 return a.path.compareTo(b.path);
21}
22
23/// Simple data class to hold parsed locale. Does not promise validity of any data.
24@immutable
25class LocaleInfo implements Comparable<LocaleInfo> {
26 const LocaleInfo({
27 required this.languageCode,
28 required this.scriptCode,
29 required this.countryCode,
30 required this.length,
31 required this.originalString,
32 });
33
34 /// Simple parser. Expects the locale string to be in the form of 'language_script_COUNTRY'
35 /// where the language is 2 characters, script is 4 characters with the first uppercase,
36 /// and country is 2-3 characters and all uppercase.
37 ///
38 /// 'language_COUNTRY' or 'language_script' are also valid. Missing fields will be null.
39 ///
40 /// When `deriveScriptCode` is true, if [scriptCode] was unspecified, it will
41 /// be derived from the [languageCode] and [countryCode] if possible.
42 factory LocaleInfo.fromString(String locale, {bool deriveScriptCode = false}) {
43 final List<String> codes = locale.split('_'); // [language, script, country]
44 assert(codes.isNotEmpty && codes.length < 4);
45 final String languageCode = codes[0];
46 String? scriptCode;
47 String? countryCode;
48 int length = codes.length;
49 var originalString = locale;
50 if (codes.length == 2) {
51 scriptCode = codes[1].length >= 4 ? codes[1] : null;
52 countryCode = codes[1].length < 4 ? codes[1] : null;
53 } else if (codes.length == 3) {
54 scriptCode = codes[1].length > codes[2].length ? codes[1] : codes[2];
55 countryCode = codes[1].length < codes[2].length ? codes[1] : codes[2];
56 }
57 assert(codes[0].isNotEmpty);
58 assert(countryCode == null || countryCode.isNotEmpty);
59 assert(scriptCode == null || scriptCode.isNotEmpty);
60
61 /// Adds scriptCodes to locales where we are able to assume it to provide
62 /// finer granularity when resolving locales.
63 ///
64 /// The basis of the assumptions here are based off of known usage of scripts
65 /// across various countries. For example, we know Taiwan uses traditional (Hant)
66 /// script, so it is safe to apply (Hant) to Taiwanese languages.
67 if (deriveScriptCode && scriptCode == null) {
68 scriptCode = switch ((languageCode, countryCode)) {
69 ('zh', 'CN' || 'SG' || null) => 'Hans',
70 ('zh', 'TW' || 'HK' || 'MO') => 'Hant',
71 ('sr', null) => 'Cyrl',
72 _ => null,
73 };
74 // Increment length if we were able to assume a scriptCode.
75 if (scriptCode != null) {
76 length += 1;
77 }
78 // Update the base string to reflect assumed scriptCodes.
79 originalString = languageCode;
80 if (scriptCode != null) {
81 originalString += '_$scriptCode';
82 }
83 if (countryCode != null) {
84 originalString += '_$countryCode';
85 }
86 }
87
88 return LocaleInfo(
89 languageCode: languageCode,
90 scriptCode: scriptCode,
91 countryCode: countryCode,
92 length: length,
93 originalString: originalString,
94 );
95 }
96
97 final String languageCode;
98 final String? scriptCode;
99 final String? countryCode;
100 final int length; // The number of fields. Ranges from 1-3.
101 final String originalString; // Original un-parsed locale string.
102
103 String camelCase() {
104 return originalString
105 .split('_')
106 .map<String>(
107 (String part) => part.substring(0, 1).toUpperCase() + part.substring(1).toLowerCase(),
108 )
109 .join();
110 }
111
112 @override
113 bool operator ==(Object other) {
114 return other is LocaleInfo && other.originalString == originalString;
115 }
116
117 @override
118 int get hashCode => originalString.hashCode;
119
120 @override
121 String toString() {
122 return originalString;
123 }
124
125 @override
126 int compareTo(LocaleInfo other) {
127 return originalString.compareTo(other.originalString);
128 }
129}
130
131// See also //master/tools/gen_locale.dart in the engine repo.
132Map<String, List<String>> _parseSection(String section) {
133 final result = <String, List<String>>{};
134 late List<String> lastHeading;
135 for (final String line in section.split('\n')) {
136 if (line == '') {
137 continue;
138 }
139 if (line.startsWith(' ')) {
140 lastHeading[lastHeading.length - 1] = '${lastHeading.last}${line.substring(1)}';
141 continue;
142 }
143 final int colon = line.indexOf(':');
144 if (colon <= 0) {
145 throw Exception('not sure how to deal with "$line"');
146 }
147 final String name = line.substring(0, colon);
148 final String value = line.substring(colon + 2);
149 lastHeading = result.putIfAbsent(name, () => <String>[]);
150 result[name]!.add(value);
151 }
152 return result;
153}
154
155final _languages = <String, String>{};
156final _regions = <String, String>{};
157final _scripts = <String, String>{};
158const kProvincePrefix = ', Province of ';
159const kParentheticalPrefix = ' (';
160
161/// Prepares the data for the [describeLocale] method below.
162///
163/// The data is obtained from the official IANA registry.
164void precacheLanguageAndRegionTags() {
165 final List<Map<String, List<String>>> sections = languageSubtagRegistry
166 .split('%%')
167 .skip(1)
168 .map<Map<String, List<String>>>(_parseSection)
169 .toList();
170 for (final section in sections) {
171 assert(section.containsKey('Type'), section.toString());
172 final String type = section['Type']!.single;
173 if (type == 'language' || type == 'region' || type == 'script') {
174 assert(
175 section.containsKey('Subtag') && section.containsKey('Description'),
176 section.toString(),
177 );
178 final String subtag = section['Subtag']!.single;
179 String description = section['Description']!.join(' ');
180 if (description.startsWith('United ')) {
181 description = 'the $description';
182 }
183 if (description.contains(kParentheticalPrefix)) {
184 description = description.substring(0, description.indexOf(kParentheticalPrefix));
185 }
186 if (description.contains(kProvincePrefix)) {
187 description = description.substring(0, description.indexOf(kProvincePrefix));
188 }
189 if (description.endsWith(' Republic')) {
190 description = 'the $description';
191 }
192 switch (type) {
193 case 'language':
194 _languages[subtag] = description;
195 case 'region':
196 _regions[subtag] = description;
197 case 'script':
198 _scripts[subtag] = description;
199 }
200 }
201 }
202}
203
204String describeLocale(String tag) {
205 final List<String> subtags = tag.split('_');
206 assert(subtags.isNotEmpty);
207 final String languageCode = subtags[0];
208 if (!_languages.containsKey(languageCode)) {
209 throw L10nException(
210 '"$languageCode" is not a supported language code.\n'
211 'See https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry '
212 'for the supported list.',
213 );
214 }
215 final String language = _languages[languageCode]!;
216 var output = language;
217 String? region;
218 String? script;
219 if (subtags.length == 2) {
220 region = _regions[subtags[1]];
221 script = _scripts[subtags[1]];
222 assert(region != null || script != null);
223 } else if (subtags.length >= 3) {
224 region = _regions[subtags[2]];
225 script = _scripts[subtags[1]];
226 assert(region != null && script != null);
227 }
228 if (region != null) {
229 output += ', as used in $region';
230 }
231 if (script != null) {
232 output += ', using the $script script';
233 }
234 return output;
235}
236
237/// Return the input string as a Dart-parsable string.
238///
239/// ```none
240/// foo => 'foo'
241/// foo "bar" => 'foo "bar"'
242/// foo 'bar' => "foo 'bar'"
243/// foo 'bar' "baz" => '''foo 'bar' "baz"'''
244/// ```
245///
246/// This function is used by tools that take in a JSON-formatted file to
247/// generate Dart code. For this reason, characters with special meaning
248/// in JSON files are escaped. For example, the backspace character (\b)
249/// has to be properly escaped by this function so that the generated
250/// Dart code correctly represents this character:
251/// ```none
252/// foo\bar => 'foo\\bar'
253/// foo\nbar => 'foo\\nbar'
254/// foo\\nbar => 'foo\\\\nbar'
255/// foo\\bar => 'foo\\\\bar'
256/// foo\ bar => 'foo\\ bar'
257/// foo$bar = 'foo\$bar'
258/// ```
259String generateString(String value) {
260 const backslash = '__BACKSLASH__';
261 assert(
262 !value.contains(backslash),
263 'Input string cannot contain the sequence: '
264 '"__BACKSLASH__", as it is used as part of '
265 'backslash character processing.',
266 );
267
268 value = value
269 // Replace backslashes with a placeholder for now to properly parse
270 // other special characters.
271 .replaceAll(r'\', backslash)
272 .replaceAll(r'$', r'\$')
273 .replaceAll("'", r"\'")
274 .replaceAll('"', r'\"')
275 .replaceAll('\n', r'\n')
276 .replaceAll('\f', r'\f')
277 .replaceAll('\t', r'\t')
278 .replaceAll('\r', r'\r')
279 .replaceAll('\b', r'\b')
280 // Reintroduce escaped backslashes into generated Dart string.
281 .replaceAll(backslash, r'\\');
282
283 return value;
284}
285
286/// Given a list of normal strings or interpolated variables, concatenate them
287/// into a single dart string to be returned. An example of a normal string
288/// would be "'Hello world!'" and an example of a interpolated variable would be
289/// "'$placeholder'".
290///
291/// Each of the strings in [expressions] should be a raw string, which, if it
292/// were to be added to a dart file, would be a properly formatted dart string
293/// with escapes and/or interpolation. The purpose of this function is to
294/// concatenate these dart strings into a single dart string which can be
295/// returned in the generated localization files.
296///
297/// The following rules describe the kinds of string expressions that can be
298/// handled:
299/// 1. If [expressions] is empty, return the empty string "''".
300/// 2. If [expressions] has only one [String] which is an interpolated variable,
301/// it is converted to the variable itself e.g. ["'$expr'"] -> "expr".
302/// 3. If one string in [expressions] is an interpolation and the next begins
303/// with an alphanumeric character, then the former interpolation should be
304/// wrapped in braces e.g. ["'$expr1'", "'another'"] -> "'${expr1}another'".
305String generateReturnExpr(List<String> expressions, {bool isSingleStringVar = false}) {
306 if (expressions.isEmpty) {
307 return "''";
308 } else if (isSingleStringVar) {
309 // If our expression is "$varName" where varName is a String, this is equivalent to just varName.
310 return expressions[0].substring(1);
311 } else {
312 final String string = expressions.reversed.fold<String>('', (String string, String expression) {
313 if (expression[0] != r'$') {
314 return expression + string;
315 }
316 final alphanumeric = RegExp(r'^([0-9a-zA-Z]|_)+$');
317 if (alphanumeric.hasMatch(expression.substring(1)) &&
318 !(string.isNotEmpty && alphanumeric.hasMatch(string[0]))) {
319 return '$expression$string';
320 } else {
321 return '\${${expression.substring(1)}}$string';
322 }
323 });
324 return "'$string'";
325 }
326}
327
328/// Typed configuration from the localizations config file.
329class LocalizationOptions {
330 LocalizationOptions({
331 required this.arbDir,
332 this.outputDir,
333 String? templateArbFile,
334 String? outputLocalizationFile,
335 this.untranslatedMessagesFile,
336 String? outputClass,
337 this.preferredSupportedLocales,
338 this.header,
339 this.headerFile,
340 bool? useDeferredLoading,
341 this.genInputsAndOutputsList,
342 this.projectDir,
343 bool? requiredResourceAttributes,
344 bool? nullableGetter,
345 bool? format,
346 bool? useEscaping,
347 bool? suppressWarnings,
348 bool? relaxSyntax,
349 bool? useNamedParameters,
350 }) : templateArbFile = templateArbFile ?? 'app_en.arb',
351 outputLocalizationFile = outputLocalizationFile ?? 'app_localizations.dart',
352 outputClass = outputClass ?? 'AppLocalizations',
353 useDeferredLoading = useDeferredLoading ?? false,
354 requiredResourceAttributes = requiredResourceAttributes ?? false,
355 nullableGetter = nullableGetter ?? true,
356 format = format ?? true,
357 useEscaping = useEscaping ?? false,
358 suppressWarnings = suppressWarnings ?? false,
359 relaxSyntax = relaxSyntax ?? false,
360 useNamedParameters = useNamedParameters ?? false;
361
362 /// The `--arb-dir` argument.
363 ///
364 /// The directory where all input localization files should reside.
365 final String arbDir;
366
367 /// The `--output-dir` argument.
368 ///
369 /// The directory where all output localization files should be generated.
370 final String? outputDir;
371
372 /// The `--template-arb-file` argument.
373 ///
374 /// This path is relative to [arbDir].
375 final String templateArbFile;
376
377 /// The `--output-localization-file` argument.
378 ///
379 /// This path is relative to [arbDir].
380 final String outputLocalizationFile;
381
382 /// The `--untranslated-messages-file` argument.
383 ///
384 /// This path is relative to [arbDir].
385 final String? untranslatedMessagesFile;
386
387 /// The `--output-class` argument.
388 final String outputClass;
389
390 /// The `--preferred-supported-locales` argument.
391 final List<String>? preferredSupportedLocales;
392
393 /// The `--header` argument.
394 ///
395 /// The header to prepend to the generated Dart localizations.
396 final String? header;
397
398 /// The `--header-file` argument.
399 ///
400 /// A file containing the header to prepend to the generated
401 /// Dart localizations.
402 final String? headerFile;
403
404 /// The `--use-deferred-loading` argument.
405 ///
406 /// Whether to generate the Dart localization file with locales imported
407 /// as deferred.
408 final bool useDeferredLoading;
409
410 /// The `--gen-inputs-and-outputs-list` argument.
411 ///
412 /// This path is relative to [arbDir].
413 final String? genInputsAndOutputsList;
414
415 /// The `--project-dir` argument.
416 ///
417 /// This path is relative to [arbDir].
418 final String? projectDir;
419
420 /// The `required-resource-attributes` argument.
421 ///
422 /// Whether to require all resource ids to contain a corresponding
423 /// resource attribute.
424 final bool requiredResourceAttributes;
425
426 /// The `nullable-getter` argument.
427 ///
428 /// Whether or not the localizations class getter is nullable.
429 final bool nullableGetter;
430
431 /// The `format` argument.
432 ///
433 /// Whether or not to format the generated files.
434 final bool format;
435
436 /// The `use-escaping` argument.
437 ///
438 /// Whether or not the ICU escaping syntax is used.
439 final bool useEscaping;
440
441 /// The `suppress-warnings` argument.
442 ///
443 /// Whether or not to suppress warnings.
444 final bool suppressWarnings;
445
446 /// The `relax-syntax` argument.
447 ///
448 /// Whether or not to relax the syntax. When specified, the syntax will be
449 /// relaxed so that the special character "{" is treated as a string if it is
450 /// not followed by a valid placeholder and "}" is treated as a string if it
451 /// does not close any previous "{" that is treated as a special character.
452 /// This was added in for backward compatibility and is not recommended
453 /// as it may mask errors.
454 final bool relaxSyntax;
455
456 /// The `use-named-parameters` argument.
457 ///
458 /// Whether or not to use named parameters for the generated localization
459 /// methods.
460 ///
461 /// Defaults to `false`.
462 final bool useNamedParameters;
463}
464
465/// Parse the localizations configuration options from [file].
466///
467/// Throws [Exception] if any of the contents are invalid. Returns a
468/// [LocalizationOptions] with all fields as `null` if the config file exists
469/// but is empty.
470LocalizationOptions parseLocalizationsOptionsFromYAML({
471 required File file,
472 required Logger logger,
473 required FileSystem fileSystem,
474 required String defaultArbDir,
475}) {
476 final String contents = file.readAsStringSync();
477 if (contents.trim().isEmpty) {
478 return LocalizationOptions(arbDir: defaultArbDir);
479 }
480 final YamlNode yamlNode;
481 try {
482 yamlNode = loadYamlNode(file.readAsStringSync());
483 } on YamlException catch (err) {
484 throwToolExit(err.message);
485 }
486 if (yamlNode is! YamlMap) {
487 logger.printError('Expected ${file.path} to contain a map, instead was $yamlNode');
488 throw Exception();
489 }
490 const kSyntheticPackage = 'synthetic-package';
491 const kFlutterGenNotice = 'http://flutter.dev/to/flutter-gen-deprecation';
492 final bool? syntheticPackage = _tryReadBool(yamlNode, kSyntheticPackage, logger);
493 if (syntheticPackage != null) {
494 if (syntheticPackage) {
495 throwToolExit(
496 '${file.path}: Cannot enable "$kSyntheticPackage", this feature has '
497 'been removed. See $kFlutterGenNotice.',
498 );
499 } else {
500 logger.printWarning(
501 '${file.path}: The argument "$kSyntheticPackage" no longer has any '
502 'effect and should be removed. See $kFlutterGenNotice',
503 );
504 }
505 }
506 return LocalizationOptions(
507 arbDir: _tryReadFilePath(yamlNode, 'arb-dir', logger, fileSystem) ?? defaultArbDir,
508 outputDir: _tryReadFilePath(yamlNode, 'output-dir', logger, fileSystem),
509 templateArbFile: _tryReadFilePath(yamlNode, 'template-arb-file', logger, fileSystem),
510 outputLocalizationFile: _tryReadFilePath(
511 yamlNode,
512 'output-localization-file',
513 logger,
514 fileSystem,
515 ),
516 untranslatedMessagesFile: _tryReadFilePath(
517 yamlNode,
518 'untranslated-messages-file',
519 logger,
520 fileSystem,
521 ),
522 outputClass: _tryReadString(yamlNode, 'output-class', logger),
523 header: _tryReadString(yamlNode, 'header', logger),
524 headerFile: _tryReadFilePath(yamlNode, 'header-file', logger, fileSystem),
525 useDeferredLoading: _tryReadBool(yamlNode, 'use-deferred-loading', logger),
526 preferredSupportedLocales: _tryReadStringList(yamlNode, 'preferred-supported-locales', logger),
527 requiredResourceAttributes: _tryReadBool(yamlNode, 'required-resource-attributes', logger),
528 nullableGetter: _tryReadBool(yamlNode, 'nullable-getter', logger),
529 format: _tryReadBool(yamlNode, 'format', logger),
530 useEscaping: _tryReadBool(yamlNode, 'use-escaping', logger),
531 suppressWarnings: _tryReadBool(yamlNode, 'suppress-warnings', logger),
532 relaxSyntax: _tryReadBool(yamlNode, 'relax-syntax', logger),
533 useNamedParameters: _tryReadBool(yamlNode, 'use-named-parameters', logger),
534 );
535}
536
537/// Parse the localizations configuration from [FlutterCommand].
538LocalizationOptions parseLocalizationsOptionsFromCommand({
539 required FlutterCommand command,
540 required String defaultArbDir,
541}) {
542 const kSyntheticPackage = 'synthetic-package';
543 const kFlutterGenNotice = 'http://flutter.dev/to/flutter-gen-deprecation';
544 if (command.argResults!.wasParsed(kSyntheticPackage)) {
545 if (command.boolArg(kSyntheticPackage)) {
546 throwToolExit(
547 'Cannot enable "$kSyntheticPackage", this feature has been removed. '
548 'See $kFlutterGenNotice.',
549 );
550 } else {
551 globals.logger.printWarning(
552 'The argument "$kSyntheticPackage" no longer has any effect and should '
553 'be removed. See $kFlutterGenNotice',
554 );
555 }
556 }
557 return LocalizationOptions(
558 arbDir: command.stringArg('arb-dir') ?? defaultArbDir,
559 outputDir: command.stringArg('output-dir'),
560 outputLocalizationFile: command.stringArg('output-localization-file'),
561 templateArbFile: command.stringArg('template-arb-file'),
562 untranslatedMessagesFile: command.stringArg('untranslated-messages-file'),
563 outputClass: command.stringArg('output-class'),
564 header: command.stringArg('header'),
565 headerFile: command.stringArg('header-file'),
566 useDeferredLoading: command.boolArg('use-deferred-loading'),
567 genInputsAndOutputsList: command.stringArg('gen-inputs-and-outputs-list'),
568 projectDir: command.stringArg('project-dir'),
569 requiredResourceAttributes: command.boolArg('required-resource-attributes'),
570 nullableGetter: command.boolArg('nullable-getter'),
571 format: command.boolArg('format'),
572 useEscaping: command.boolArg('use-escaping'),
573 suppressWarnings: command.boolArg('suppress-warnings'),
574 useNamedParameters: command.boolArg('use-named-parameters'),
575 );
576}
577
578// Try to read a `bool` value or null from `yamlMap`, otherwise throw.
579bool? _tryReadBool(YamlMap yamlMap, String key, Logger logger) {
580 final Object? value = yamlMap[key];
581 if (value == null) {
582 return null;
583 }
584 if (value is! bool) {
585 logger.printError('Expected "$key" to have a bool value, instead was "$value"');
586 throw Exception();
587 }
588 return value;
589}
590
591// Try to read a `String` value or null from `yamlMap`, otherwise throw.
592String? _tryReadString(YamlMap yamlMap, String key, Logger logger) {
593 final Object? value = yamlMap[key];
594 if (value == null) {
595 return null;
596 }
597 if (value is! String) {
598 logger.printError('Expected "$key" to have a String value, instead was "$value"');
599 throw Exception();
600 }
601 return value;
602}
603
604List<String>? _tryReadStringList(YamlMap yamlMap, String key, Logger logger) {
605 final Object? value = yamlMap[key];
606 if (value == null) {
607 return null;
608 }
609 if (value is String) {
610 return <String>[value];
611 }
612 if (value is Iterable) {
613 return value.map((dynamic e) => e.toString()).toList();
614 }
615 logger.printError('"$value" must be String or List.');
616 throw Exception();
617}
618
619// Try to read a valid file `Uri` or null from `yamlMap` to file path, otherwise throw.
620String? _tryReadFilePath(YamlMap yamlMap, String key, Logger logger, FileSystem fileSystem) {
621 final String? value = _tryReadString(yamlMap, key, logger);
622 if (value == null) {
623 return null;
624 }
625 final Uri? uri = Uri.tryParse(value);
626 if (uri == null) {
627 logger.printError('"$value" must be a relative file URI');
628 }
629 return uri != null ? fileSystem.path.normalize(uri.path) : null;
630}
631