flutter_flutter/dev/tools/localization/localizations_utils.dart
Kate Lovett 9d96df2364
Modernize framework lints (#179089)
WIP

Commits separated as follows:
- Update lints in analysis_options files
- Run `dart fix --apply`
- Clean up leftover analysis issues 
- Run `dart format .` in the right places.

Local analysis and testing passes. Checking CI now.

Part of https://github.com/flutter/flutter/issues/178827
- Adoption of flutter_lints in examples/api coming in a separate change
(cc @loic-sharma)

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2025-11-26 01:10:39 +00:00

455 lines
16 KiB
Dart

// 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;
var 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) {
scriptCode = switch ((languageCode, countryCode)) {
('zh', 'CN' || 'SG' || null) => 'Hans',
('zh', 'TW' || 'HK' || 'MO') => 'Hant',
('sr', null) => 'Cyrl',
_ => null,
};
// 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 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 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 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.
var 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 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 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(
'widgets',
help:
'Whether to print the generated classes for the Widgets package only. Ignored when --overwrite is passed.',
)
..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 writeToFile = args['overwrite'] as bool;
final removeUndefined = args['remove-undefined'] as bool;
final widgetsOnly = args['widgets'] as bool;
final materialOnly = args['material'] as bool;
final cupertinoOnly = args['cupertino'] as bool;
return GeneratorOptions(
writeToFile: writeToFile,
materialOnly: materialOnly,
cupertinoOnly: cupertinoOnly,
widgetsOnly: widgetsOnly,
removeUndefined: removeUndefined,
);
}
class GeneratorOptions {
GeneratorOptions({
required this.writeToFile,
required this.removeUndefined,
required this.materialOnly,
required this.cupertinoOnly,
required this.widgetsOnly,
});
final bool writeToFile;
final bool removeUndefined;
final bool materialOnly;
final bool cupertinoOnly;
final bool widgetsOnly;
}
// See also //master/tools/gen_locale.dart in the engine repo.
Map<String, List<String>> _parseSection(String section) {
final 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 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;
case 'region':
_regions[subtag] = description;
case 'script':
_scripts[subtag] = description;
}
}
}
}
String describeLocale(String tag) {
final List<String> subtags = tag.split('_');
assert(subtags.isNotEmpty);
assert(_languages.containsKey(subtags[0]));
final String language = _languages[subtags[0]]!;
var 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.
///
/// ```none
/// 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:
/// ```none
/// 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 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'";
}