gen_l10n localizations date formatting (simple messages) (#47006)
* Implement date parsing for the gen_l10n tool
diff --git a/dev/tools/localization/gen_l10n.dart b/dev/tools/localization/gen_l10n.dart
index 972e981..3bab7be 100644
--- a/dev/tools/localization/gen_l10n.dart
+++ b/dev/tools/localization/gen_l10n.dart
@@ -136,7 +136,7 @@
''';
const String simpleMethodTemplate = '''
- String @methodName(@methodParameters) {
+ String @methodName(@methodParameters) {@dateFormatting
return Intl.message(
@message,
locale: _localeName,
@@ -153,6 +153,96 @@
}
''';
+// The set of date formats that can be automatically localized.
+//
+// The localizations generation tool makes use of the intl library's
+// DateFormat class to properly format dates based on the locale, the
+// desired format, as well as the passed in [DateTime]. For example, using
+// DateFormat.yMMMMd("en_US").format(DateTime.utc(1996, 7, 10)) results
+// in the string "July 10, 1996".
+//
+// Since the tool generates code that uses DateFormat's constructor, it is
+// necessary to verify that the constructor exists, or the
+// tool will generate code that may cause a compile-time error.
+//
+// See also:
+//
+// * <https://pub.dev/packages/intl>
+// * <https://pub.dev/documentation/intl/latest/intl/DateFormat-class.html>
+// * <https://api.dartlang.org/stable/2.7.0/dart-core/DateTime-class.html>
+const Set<String> allowableDateFormats = <String>{
+ 'd',
+ 'E',
+ 'EEEE',
+ 'LLL',
+ 'LLLL',
+ 'M',
+ 'Md',
+ 'MEd',
+ 'MMM',
+ 'MMMd',
+ 'MMMEd',
+ 'MMMM',
+ 'MMMMd',
+ 'MMMMEEEEd',
+ 'QQQ',
+ 'QQQQ',
+ 'y',
+ 'yM',
+ 'yMd',
+ 'yMEd',
+ 'yMMM',
+ 'yMMMd',
+ 'yMMMEd',
+ 'yMMMM',
+ 'yMMMMd',
+ 'yMMMMEEEEd',
+ 'yQQQ',
+ 'yQQQQ',
+ 'H',
+ 'Hm',
+ 'Hms',
+ 'j',
+ 'jm',
+ 'jms',
+ 'jmv',
+ 'jmz',
+ 'jv',
+ 'jz',
+ 'm',
+ 'ms',
+ 's',
+};
+
+bool _isDateParameter(dynamic placeholderValue) {
+ return placeholderValue is Map<String, dynamic> &&
+ placeholderValue['type'] == 'DateTime';
+}
+
+bool _dateParameterIsValid(Map<String, dynamic> placeholderValue, String placeholder) {
+ if (allowableDateFormats.contains(placeholderValue['format']))
+ return true;
+ throw L10nException(
+ 'Date format ${placeholderValue['format']} for the $placeholder \n'
+ 'placeholder does not have a corresponding DateFormat \n'
+ 'constructor. Check the intl library\'s DateFormat class \n'
+ 'constructors for allowed date formats.'
+ );
+}
+
+bool _containsFormatKey(Map<String, dynamic> placeholderValue, String placeholder) {
+ if (placeholderValue.containsKey('format'))
+ return true;
+ throw L10nException(
+ 'The placeholder, $placeholder, has its "type" resource attribute set to '
+ 'the "DateTime" type. To properly resolve for the right DateTime format, '
+ 'the "format" attribute needs to be set to determine which DateFormat to '
+ 'use. \n'
+ 'Check the intl library\'s DateFormat class constructors for allowed '
+ 'date formats.'
+ );
+}
+
List<String> genMethodParameters(Map<String, dynamic> bundle, String key, String type) {
final Map<String, dynamic> attributesMap = bundle['@$key'] as Map<String, dynamic>;
if (attributesMap != null && attributesMap.containsKey('placeholders')) {
@@ -162,6 +252,30 @@
return <String>[];
}
+String generateDateFormattingLogic(Map<String, dynamic> bundle, String key) {
+ String result = '';
+ final Map<String, dynamic> attributesMap = bundle['@$key'] as Map<String, dynamic>;
+ if (attributesMap != null && attributesMap.containsKey('placeholders')) {
+ final Map<String, dynamic> placeholders = attributesMap['placeholders'] as Map<String, dynamic>;
+ for (String placeholder in placeholders.keys) {
+ final dynamic value = placeholders[placeholder];
+ if (
+ _isDateParameter(value) &&
+ _containsFormatKey(value, placeholder) &&
+ _dateParameterIsValid(value, placeholder)
+ ) {
+ result += '''
+
+ final DateFormat ${placeholder}DateFormat = DateFormat.${value['format']}(_localeName);
+ final String ${placeholder}String = ${placeholder}DateFormat.format($placeholder);
+''';
+ }
+ }
+ }
+
+ return result;
+}
+
List<String> genIntlMethodArgs(Map<String, dynamic> bundle, String key) {
final List<String> attributes = <String>['name: \'$key\''];
final Map<String, dynamic> attributesMap = bundle['@$key'] as Map<String, dynamic>;
@@ -173,7 +287,20 @@
if (attributesMap.containsKey('placeholders')) {
final Map<String, dynamic> placeholders = attributesMap['placeholders'] as Map<String, dynamic>;
if (placeholders.isNotEmpty) {
- final String args = placeholders.keys.join(', ');
+ final List<String> argumentList = <String>[];
+ for (String placeholder in placeholders.keys) {
+ final dynamic value = placeholders[placeholder];
+ if (
+ _isDateParameter(value) &&
+ _containsFormatKey(value, placeholder) &&
+ _dateParameterIsValid(value, placeholder)
+ ) {
+ argumentList.add('${placeholder}String');
+ } else {
+ argumentList.add(placeholder);
+ }
+ }
+ final String args = argumentList.join(', ');
attributes.add('args: <Object>[$args]');
}
}
@@ -186,8 +313,14 @@
String message = bundle[key] as String;
final Map<String, dynamic> attributesMap = bundle['@$key'] as Map<String, dynamic>;
final Map<String, dynamic> placeholders = attributesMap['placeholders'] as Map<String, dynamic>;
- for (String placeholder in placeholders.keys)
- message = message.replaceAll('{$placeholder}', '\$$placeholder');
+ for (String placeholder in placeholders.keys) {
+ final dynamic value = placeholders[placeholder];
+ if (_isDateParameter(value)) {
+ message = message.replaceAll('{$placeholder}', '\$${placeholder}String');
+ } else {
+ message = message.replaceAll('{$placeholder}', '\$$placeholder');
+ }
+ }
return generateString(message);
}
@@ -202,6 +335,7 @@
return simpleMethodTemplate
.replaceAll('@methodName', key)
.replaceAll('@methodParameters', genMethodParameters(bundle, key, 'Object').join(', '))
+ .replaceAll('@dateFormatting', generateDateFormattingLogic(bundle, key))
.replaceAll('@message', '${genSimpleMethodMessage(bundle, key)}')
.replaceAll('@intlMethodArgs', genIntlMethodArgs(bundle, key).join(',\n '));
}
diff --git a/dev/tools/test/localization/gen_l10n_test.dart b/dev/tools/test/localization/gen_l10n_test.dart
index 2aca69d..1114b46 100644
--- a/dev/tools/test/localization/gen_l10n_test.dart
+++ b/dev/tools/test/localization/gen_l10n_test.dart
@@ -554,6 +554,237 @@
''');
});
+ test('correctly generates simple message with dates', () {
+ const String singleDateMessageArbFileString = '''{
+ "springBegins": "Spring begins on {springStartDate}",
+ "@springBegins": {
+ "description": "The first day of spring",
+ "placeholders": {
+ "springStartDate": {
+ "type": "DateTime",
+ "format": "yMMMMEEEEd"
+ }
+ }
+ }
+}''';
+ final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
+ ..createSync(recursive: true);
+ l10nDirectory.childFile(defaultTemplateArbFileName)
+ .writeAsStringSync(singleDateMessageArbFileString);
+
+ final LocalizationsGenerator generator = LocalizationsGenerator(fs);
+ try {
+ generator.initialize(
+ l10nDirectoryPath: defaultArbPathString,
+ templateArbFileName: defaultTemplateArbFileName,
+ outputFileString: defaultOutputFileString,
+ classNameString: defaultClassNameString,
+ );
+ generator.parseArbFiles();
+ generator.generateClassMethods();
+ } on Exception catch (e) {
+ fail('Parsing template arb file should succeed: \n$e');
+ }
+
+ expect(generator.classMethods, isNotEmpty);
+ expect(
+ generator.classMethods.first,
+ ''' String springBegins(Object springStartDate) {
+ final DateFormat springStartDateDateFormat = DateFormat.yMMMMEEEEd(_localeName);
+ final String springStartDateString = springStartDateDateFormat.format(springStartDate);
+
+ return Intl.message(
+ r'Spring begins on \$springStartDateString',
+ locale: _localeName,
+ name: 'springBegins',
+ desc: r'The first day of spring',
+ args: <Object>[springStartDateString]
+ );
+ }
+''');
+ });
+
+ test('throws an exception when improperly formatted date is passed in', () {
+ const String singleDateMessageArbFileString = '''{
+ "springBegins": "Spring begins on {springStartDate}",
+ "@springBegins": {
+ "description": "The first day of spring",
+ "placeholders": {
+ "springStartDate": {
+ "type": "DateTime",
+ "format": "asdf"
+ }
+ }
+ }
+}''';
+ final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
+ ..createSync(recursive: true);
+ l10nDirectory.childFile(defaultTemplateArbFileName)
+ .writeAsStringSync(singleDateMessageArbFileString);
+
+ final LocalizationsGenerator generator = LocalizationsGenerator(fs);
+ try {
+ generator.initialize(
+ l10nDirectoryPath: defaultArbPathString,
+ templateArbFileName: defaultTemplateArbFileName,
+ outputFileString: defaultOutputFileString,
+ classNameString: defaultClassNameString,
+ );
+ generator.parseArbFiles();
+ generator.generateClassMethods();
+ } on L10nException catch (e) {
+ expect(e.message, contains('asdf'));
+ expect(e.message, contains('springStartDate'));
+ expect(e.message, contains('does not have a corresponding DateFormat'));
+ return;
+ }
+
+ fail('Improper date formatting should throw an exception');
+ });
+
+ test('throws an exception when no format attribute is passed in', () {
+ const String singleDateMessageArbFileString = '''{
+ "springBegins": "Spring begins on {springStartDate}",
+ "@springBegins": {
+ "description": "The first day of spring",
+ "placeholders": {
+ "springStartDate": {
+ "type": "DateTime"
+ }
+ }
+ }
+}''';
+ final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
+ ..createSync(recursive: true);
+ l10nDirectory.childFile(defaultTemplateArbFileName)
+ .writeAsStringSync(singleDateMessageArbFileString);
+
+ final LocalizationsGenerator generator = LocalizationsGenerator(fs);
+ try {
+ generator.initialize(
+ l10nDirectoryPath: defaultArbPathString,
+ templateArbFileName: defaultTemplateArbFileName,
+ outputFileString: defaultOutputFileString,
+ classNameString: defaultClassNameString,
+ );
+ generator.parseArbFiles();
+ generator.generateClassMethods();
+ } on L10nException catch (e) {
+ expect(e.message, contains('the "format" attribute needs to be set'));
+ return;
+ }
+
+ fail('Improper date formatting should throw an exception');
+ });
+
+ test('correctly generates simple message with date along with other placeholders', () {
+ const String singleDateMessageArbFileString = '''{
+ "springGreetings": "Since it's {springStartDate}, it's finally spring! {helloWorld}!",
+ "@springGreetings": {
+ "description": "A realization that it's finally the spring season, followed by a greeting.",
+ "placeholders": {
+ "springStartDate": {
+ "type": "DateTime",
+ "format": "yMMMMEEEEd"
+ },
+ "helloWorld": {}
+ }
+ }
+}''';
+ final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
+ ..createSync(recursive: true);
+ l10nDirectory.childFile(defaultTemplateArbFileName)
+ .writeAsStringSync(singleDateMessageArbFileString);
+
+ final LocalizationsGenerator generator = LocalizationsGenerator(fs);
+ try {
+ generator.initialize(
+ l10nDirectoryPath: defaultArbPathString,
+ templateArbFileName: defaultTemplateArbFileName,
+ outputFileString: defaultOutputFileString,
+ classNameString: defaultClassNameString,
+ );
+ generator.parseArbFiles();
+ generator.generateClassMethods();
+ } on Exception catch (e) {
+ fail('Parsing template arb file should succeed: \n$e');
+ }
+
+ expect(generator.classMethods, isNotEmpty);
+ expect(
+ generator.classMethods.first,
+ ''' String springGreetings(Object springStartDate, Object helloWorld) {
+ final DateFormat springStartDateDateFormat = DateFormat.yMMMMEEEEd(_localeName);
+ final String springStartDateString = springStartDateDateFormat.format(springStartDate);
+
+ return Intl.message(
+ r\'Since it\' "\'" r\'s \$springStartDateString, it\' "\'" r\'s finally spring! \$helloWorld!\',
+ locale: _localeName,
+ name: 'springGreetings',
+ desc: r\'A realization that it\' "\'" r\'s finally the spring season, followed by a greeting.\',
+ args: <Object>[springStartDateString, helloWorld]
+ );
+ }
+''');
+ });
+
+ test('correctly generates simple message with multiple dates', () {
+ const String singleDateMessageArbFileString = '''{
+ "springRange": "Spring begins on {springStartDate} and ends on {springEndDate}",
+ "@springRange": {
+ "description": "The range of dates for spring in the year",
+ "placeholders": {
+ "springStartDate": {
+ "type": "DateTime",
+ "format": "yMMMMEEEEd"
+ },
+ "springEndDate": {
+ "type": "DateTime",
+ "format": "yMMMMEEEEd"
+ }
+ }
+ }
+}''';
+ final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
+ ..createSync(recursive: true);
+ l10nDirectory.childFile(defaultTemplateArbFileName)
+ .writeAsStringSync(singleDateMessageArbFileString);
+
+ final LocalizationsGenerator generator = LocalizationsGenerator(fs);
+ try {
+ generator.initialize(
+ l10nDirectoryPath: defaultArbPathString,
+ templateArbFileName: defaultTemplateArbFileName,
+ outputFileString: defaultOutputFileString,
+ classNameString: defaultClassNameString,
+ );
+ generator.parseArbFiles();
+ generator.generateClassMethods();
+ } on Exception catch (e) {
+ fail('Parsing template arb file should succeed: \n$e');
+ }
+
+ expect(generator.classMethods, isNotEmpty);
+ expect(
+ generator.classMethods.first,
+ ''' String springRange(Object springStartDate, Object springEndDate) {
+ final DateFormat springStartDateDateFormat = DateFormat.yMMMMEEEEd(_localeName);
+ final String springStartDateString = springStartDateDateFormat.format(springStartDate);
+
+ final DateFormat springEndDateDateFormat = DateFormat.yMMMMEEEEd(_localeName);
+ final String springEndDateString = springEndDateDateFormat.format(springEndDate);
+
+ return Intl.message(
+ r\'Spring begins on \$springStartDateString and ends on \$springEndDateString\',
+ locale: _localeName,
+ name: 'springRange',
+ desc: r\'The range of dates for spring in the year\',
+ args: <Object>[springStartDateString, springEndDateString]
+ );
+ }
+''');
+ });
+
test('correctly generates a plural message:', () {
const String singlePluralMessageArbFileString = '''{
"helloWorlds": "{count,plural, =0{Hello}=1{Hello World}=2{Hello two worlds}few{Hello {count} worlds}many{Hello all {count} worlds}other{Hello other {count} worlds}}",