From ff838bca89905ed66b5966639cc0c01d068038e7 Mon Sep 17 00:00:00 2001 From: Tae Hyung Kim Date: Thu, 29 Jun 2023 09:23:34 -0700 Subject: [PATCH] Add locale-specific DateTime formatting syntax (#129573) Based on the [message format syntax](https://unicode-org.github.io/icu/userguide/format_parse/messages/#examples) for [ICU4J](https://unicode-org.github.io/icu-docs/apidoc/released/icu4j/com/ibm/icu/text/MessageFormat.html). This adds new syntax to the current Flutter messageFormat parser which should allow developers to add locale-specific date formatting. ## Usage example ``` "datetimeTest": "Today is {today, date, ::yMd}", "@datetimeTest": { "placeholders": { "today": { "description": "The date placeholder", "type": "DateTime" } } } ``` compiles to ``` String datetimeTest(DateTime today) { String _temp0 = intl.DateFormat.yMd(localeName).format(today); return 'Today is $_temp0'; } ``` Fixes https://github.com/flutter/flutter/issues/127304. --- .../lib/src/localizations/gen_l10n.dart | 29 +++++++ .../src/localizations/gen_l10n_templates.dart | 3 + .../lib/src/localizations/gen_l10n_types.dart | 39 +++++++-- .../lib/src/localizations/message_parser.dart | 39 ++++++++- .../generate_localizations_test.dart | 61 ++++++++++++++ .../general.shard/message_parser_test.dart | 19 +++++ .../test/integration.shard/gen_l10n_test.dart | 79 ++++++++++--------- .../test_data/gen_l10n_project.dart | 6 +- 8 files changed, 228 insertions(+), 47 deletions(-) diff --git a/packages/flutter_tools/lib/src/localizations/gen_l10n.dart b/packages/flutter_tools/lib/src/localizations/gen_l10n.dart index 01f97aa91d8..17bc3641a2e 100644 --- a/packages/flutter_tools/lib/src/localizations/gen_l10n.dart +++ b/packages/flutter_tools/lib/src/localizations/gen_l10n.dart @@ -1157,6 +1157,7 @@ class LocalizationsGenerator { // When traversing through a placeholderExpr node, return "$placeholderName". // When traversing through a pluralExpr node, return "$tempVarN" and add variable declaration in "tempVariables". // When traversing through a selectExpr node, return "$tempVarN" and add variable declaration in "tempVariables". + // When traversing through an argumentExpr node, return "$tempVarN" and add variable declaration in "tempVariables". // When traversing through a message node, return concatenation of all of "generateVariables(child)" for each child. String generateVariables(Node node, { bool isRoot = false }) { switch (node.type) { @@ -1259,6 +1260,34 @@ The plural cases must be one of "=0", "=1", "=2", "zero", "one", "two", "few", " .replaceAll('@(selectCases)', selectLogicArgs.join('\n')) ); return '\$$tempVarName'; + case ST.argumentExpr: + requiresIntlImport = true; + assert(node.children[1].type == ST.identifier); + assert(node.children[3].type == ST.argType); + assert(node.children[7].type == ST.identifier); + final String identifierName = node.children[1].value!; + final Node formatType = node.children[7]; + // Check that formatType is a valid intl.DateFormat. + if (!validDateFormats.contains(formatType.value)) { + throw L10nParserException( + 'Date format "${formatType.value!}" for placeholder ' + '$identifierName does not have a corresponding DateFormat ' + "constructor\n. Check the intl library's DateFormat class " + 'constructors for allowed date formats, or set "isCustomDateFormat" attribute ' + 'to "true".', + _inputFileNames[locale]!, + message.resourceId, + translationForMessage, + formatType.positionInMessage, + ); + } + final String tempVarName = getTempVariableName(); + tempVariables.add(dateVariableTemplate + .replaceAll('@(varName)', tempVarName) + .replaceAll('@(formatType)', formatType.value!) + .replaceAll('@(argument)', identifierName) + ); + return '\$$tempVarName'; // ignore: no_default_cases default: throw Exception('Cannot call "generateHelperMethod" on node type ${node.type}'); diff --git a/packages/flutter_tools/lib/src/localizations/gen_l10n_templates.dart b/packages/flutter_tools/lib/src/localizations/gen_l10n_templates.dart index 022e4962e3a..15e9cd6b4aa 100644 --- a/packages/flutter_tools/lib/src/localizations/gen_l10n_templates.dart +++ b/packages/flutter_tools/lib/src/localizations/gen_l10n_templates.dart @@ -157,6 +157,9 @@ const String selectVariableTemplate = ''' }, );'''; +const String dateVariableTemplate = ''' + String @(varName) = intl.DateFormat.@(formatType)(localeName).format(@(argument));'''; + const String classFileTemplate = ''' @(header)@(requiresIntlImport)import '@(fileName)'; diff --git a/packages/flutter_tools/lib/src/localizations/gen_l10n_types.dart b/packages/flutter_tools/lib/src/localizations/gen_l10n_types.dart index 7aad6d99134..a978e437943 100644 --- a/packages/flutter_tools/lib/src/localizations/gen_l10n_types.dart +++ b/packages/flutter_tools/lib/src/localizations/gen_l10n_types.dart @@ -27,7 +27,7 @@ import 'message_parser.dart'; // * // * // * -const Set _validDateFormats = { +const Set validDateFormats = { 'd', 'E', 'EEEE', @@ -244,13 +244,14 @@ class Placeholder { String? type; bool isPlural = false; bool isSelect = false; + bool isDateTime = false; + bool requiresDateFormatting = false; bool get requiresFormatting => requiresDateFormatting || requiresNumFormatting; - bool get requiresDateFormatting => type == 'DateTime'; bool get requiresNumFormatting => ['int', 'num', 'double'].contains(type) && format != null; bool get hasValidNumberFormat => _validNumberFormats.contains(format); bool get hasNumberFormatWithParameters => _numberFormatsWithNamedParameters.contains(format); - bool get hasValidDateFormat => _validDateFormats.contains(format); + bool get hasValidDateFormat => validDateFormats.contains(format); static String? _stringAttribute( String resourceId, @@ -488,7 +489,12 @@ class Message { final List traversalStack = [parsedMessages[locale]!]; while (traversalStack.isNotEmpty) { final Node node = traversalStack.removeLast(); - if ([ST.placeholderExpr, ST.pluralExpr, ST.selectExpr].contains(node.type)) { + if ([ + ST.placeholderExpr, + ST.pluralExpr, + ST.selectExpr, + ST.argumentExpr + ].contains(node.type)) { final String identifier = node.children[1].value!; Placeholder? placeholder = getPlaceholder(identifier); if (placeholder == null) { @@ -499,6 +505,14 @@ class Message { placeholder.isPlural = true; } else if (node.type == ST.selectExpr) { placeholder.isSelect = true; + } else if (node.type == ST.argumentExpr) { + placeholder.isDateTime = true; + } else { + // Here the node type must be ST.placeholderExpr. + // A DateTime placeholder must require date formatting. + if (placeholder.type == 'DateTime') { + placeholder.requiresDateFormatting = true; + } } } traversalStack.addAll(node.children); @@ -510,9 +524,16 @@ class Message { ..sort((MapEntry p1, MapEntry p2) => p1.key.compareTo(p2.key)) ); + bool atMostOneOf(bool x, bool y, bool z) { + return x && !y && !z + || !x && y && !z + || !x && !y && z + || !x && !y && !z; + } + for (final Placeholder placeholder in placeholders.values) { - if (placeholder.isPlural && placeholder.isSelect) { - throw L10nException('Placeholder is used as both a plural and select in certain languages.'); + if (!atMostOneOf(placeholder.isPlural, placeholder.isDateTime, placeholder.isSelect)) { + throw L10nException('Placeholder is used as plural/select/datetime in certain languages.'); } else if (placeholder.isPlural) { if (placeholder.type == null) { placeholder.type = 'num'; @@ -526,6 +547,12 @@ class Message { } else if (placeholder.type != 'String') { throw L10nException("Placeholders used in selects must be of type 'String'"); } + } else if (placeholder.isDateTime) { + if (placeholder.type == null) { + placeholder.type = 'DateTime'; + } else if (placeholder.type != 'DateTime') { + throw L10nException("Placeholders used in datetime expressions much be of type 'DateTime'"); + } } placeholder.type ??= 'Object'; } diff --git a/packages/flutter_tools/lib/src/localizations/message_parser.dart b/packages/flutter_tools/lib/src/localizations/message_parser.dart index dc05744f160..49a04fff485 100644 --- a/packages/flutter_tools/lib/src/localizations/message_parser.dart +++ b/packages/flutter_tools/lib/src/localizations/message_parser.dart @@ -22,11 +22,16 @@ enum ST { number, identifier, empty, + colon, + date, + time, // Nonterminal Types message, placeholderExpr, + argumentExpr, + pluralExpr, pluralParts, pluralPart, @@ -34,6 +39,8 @@ enum ST { selectExpr, selectParts, selectPart, + + argType, } // The grammar of the syntax. @@ -43,6 +50,7 @@ Map>> grammar = >>{ [ST.placeholderExpr, ST.message], [ST.pluralExpr, ST.message], [ST.selectExpr, ST.message], + [ST.argumentExpr, ST.message], [ST.empty], ], ST.placeholderExpr: >[ @@ -73,6 +81,13 @@ Map>> grammar = >>{ [ST.number, ST.openBrace, ST.message, ST.closeBrace], [ST.other, ST.openBrace, ST.message, ST.closeBrace], ], + ST.argumentExpr: >[ + [ST.openBrace, ST.identifier, ST.comma, ST.argType, ST.comma, ST.colon, ST.colon, ST.identifier, ST.closeBrace], + ], + ST.argType: >[ + [ST.date], + [ST.time], + ], }; class Node { @@ -100,6 +115,8 @@ class Node { Node.selectKeyword(this.positionInMessage): type = ST.select, value = 'select'; Node.otherKeyword(this.positionInMessage): type = ST.other, value = 'other'; Node.empty(this.positionInMessage): type = ST.empty, value = ''; + Node.dateKeyword(this.positionInMessage): type = ST.date, value = 'date'; + Node.timeKeyword(this.positionInMessage): type = ST.time, value = 'time'; String? value; late ST type; @@ -162,6 +179,7 @@ RegExp numeric = RegExp(r'[0-9]+'); RegExp alphanumeric = RegExp(r'[a-zA-Z0-9|_]+'); RegExp comma = RegExp(r','); RegExp equalSign = RegExp(r'='); +RegExp colon = RegExp(r':'); // List of token matchers ordered by precedence Map matchers = { @@ -169,6 +187,7 @@ Map matchers = { ST.number: numeric, ST.comma: comma, ST.equalSign: equalSign, + ST.colon: colon, ST.identifier: alphanumeric, }; @@ -312,6 +331,10 @@ class Parser { matchedType = ST.select; case 'other': matchedType = ST.other; + case 'date': + matchedType = ST.date; + case 'time': + matchedType = ST.time; } tokens.add(Node(matchedType!, startIndex, value: match.group(0))); startIndex = match.end; @@ -354,9 +377,9 @@ class Parser { switch (symbol) { case ST.message: if (tokens.isEmpty) { - parseAndConstructNode(ST.message, 4); + parseAndConstructNode(ST.message, 5); } else if (tokens[0].type == ST.closeBrace) { - parseAndConstructNode(ST.message, 4); + parseAndConstructNode(ST.message, 5); } else if (tokens[0].type == ST.string) { parseAndConstructNode(ST.message, 0); } else if (tokens[0].type == ST.openBrace) { @@ -364,6 +387,8 @@ class Parser { parseAndConstructNode(ST.message, 2); } else if (3 < tokens.length && tokens[3].type == ST.select) { parseAndConstructNode(ST.message, 3); + } else if (3 < tokens.length && (tokens[3].type == ST.date || tokens[3].type == ST.time)) { + parseAndConstructNode(ST.message, 4); } else { parseAndConstructNode(ST.message, 1); } @@ -373,6 +398,16 @@ class Parser { } case ST.placeholderExpr: parseAndConstructNode(ST.placeholderExpr, 0); + case ST.argumentExpr: + parseAndConstructNode(ST.argumentExpr, 0); + case ST.argType: + if (tokens.isNotEmpty && tokens[0].type == ST.date) { + parseAndConstructNode(ST.argType, 0); + } else if (tokens.isNotEmpty && tokens[0].type == ST.time) { + parseAndConstructNode(ST.argType, 1); + } else { + throw L10nException('ICU Syntax Error. Found unknown argument type.'); + } case ST.pluralExpr: parseAndConstructNode(ST.pluralExpr, 0); case ST.pluralParts: diff --git a/packages/flutter_tools/test/general.shard/generate_localizations_test.dart b/packages/flutter_tools/test/general.shard/generate_localizations_test.dart index 1fc7ac92192..ba35df50a88 100644 --- a/packages/flutter_tools/test/general.shard/generate_localizations_test.dart +++ b/packages/flutter_tools/test/general.shard/generate_localizations_test.dart @@ -1759,6 +1759,67 @@ import 'output-localization-file_en.dart' deferred as output-localization-file_e }); }); + group('argument messages', () { + testWithoutContext('should generate proper calls to intl.DateFormat', () { + setupLocalizations({ + 'en': ''' +{ + "datetime": "{today, date, ::yMd}" +}''' + }); + expect(getGeneratedFileContent(locale: 'en'), contains('intl.DateFormat.yMd(localeName).format(today)')); + }); + + testWithoutContext('should generate proper calls to intl.DateFormat when using time', () { + setupLocalizations({ + 'en': ''' +{ + "datetime": "{current, time, ::jms}" +}''' + }); + expect(getGeneratedFileContent(locale: 'en'), contains('intl.DateFormat.jms(localeName).format(current)')); + }); + + testWithoutContext('should not complain when placeholders are explicitly typed to DateTime', () { + setupLocalizations({ + 'en': ''' +{ + "datetime": "{today, date, ::yMd}", + "@datetime": { + "placeholders": { + "today": { "type": "DateTime" } + } + } +}''' + }); + expect(getGeneratedFileContent(locale: 'en'), contains('String datetime(DateTime today) {')); + }); + + testWithoutContext('should automatically infer date time placeholders that are not explicitly defined', () { + setupLocalizations({ + 'en': ''' +{ + "datetime": "{today, date, ::yMd}" +}''' + }); + expect(getGeneratedFileContent(locale: 'en'), contains('String datetime(DateTime today) {')); + }); + + testWithoutContext('should throw on invalid DateFormat', () { + try { + setupLocalizations({ + 'en': ''' +{ + "datetime": "{today, date, ::yMMMMMd}" +}''' + }); + assert(false); + } on L10nException { + expect(logger.errorText, contains('Date format "yMMMMMd" for placeholder today does not have a corresponding DateFormat constructor')); + } + }); + }); + // All error handling for messages should collect errors on a per-error // basis and log them out individually. Then, it will throw an L10nException. group('error handling tests', () { diff --git a/packages/flutter_tools/test/general.shard/message_parser_test.dart b/packages/flutter_tools/test/general.shard/message_parser_test.dart index 4d19d186bf0..efc9680adf2 100644 --- a/packages/flutter_tools/test/general.shard/message_parser_test.dart +++ b/packages/flutter_tools/test/general.shard/message_parser_test.dart @@ -306,6 +306,25 @@ void main() { ]) )); + expect(Parser('argumentTest', 'app_en.arb', 'Today is {date, date, ::yMMd}').parse(), equals( + Node(ST.message, 0, children: [ + Node(ST.string, 0, value: 'Today is '), + Node(ST.argumentExpr, 9, children: [ + Node(ST.openBrace, 9, value: '{'), + Node(ST.identifier, 10, value: 'date'), + Node(ST.comma, 14, value: ','), + Node(ST.argType, 16, children: [ + Node(ST.date, 16, value: 'date'), + ]), + Node(ST.comma, 20, value: ','), + Node(ST.colon, 22, value: ':'), + Node(ST.colon, 23, value: ':'), + Node(ST.identifier, 24, value: 'yMMd'), + Node(ST.closeBrace, 28, value: '}'), + ]), + ]) + )); + expect(Parser( 'plural', 'app_en.arb', diff --git a/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart b/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart index 6827b3814a1..13be650bc83 100644 --- a/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart +++ b/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart @@ -126,46 +126,49 @@ void main() { '#l10n 73 (he)\n' '#l10n 74 (they)\n' '#l10n 75 (she)\n' - '#l10n 76 (--- es ---)\n' - '#l10n 77 (ES - Hello world)\n' - '#l10n 78 (ES - Hello _NEWLINE_ World)\n' - '#l10n 79 (ES - Hola \$ Mundo)\n' - '#l10n 80 (ES - Hello Mundo)\n' - '#l10n 81 (ES - Hola Mundo)\n' - '#l10n 82 (ES - Hello World on viernes, 1 de enero de 1960)\n' - '#l10n 83 (ES - Hello world argument on 1/1/1960 at 0:00)\n' - '#l10n 84 (ES - Hello World from 1960 to 2020)\n' - '#l10n 85 (ES - Hello for 123)\n' - '#l10n 86 (ES - Hello)\n' - '#l10n 87 (ES - Hello World)\n' - '#l10n 88 (ES - Hello two worlds)\n' - '#l10n 89 (ES - Hello)\n' - '#l10n 90 (ES - Hello nuevo World)\n' - '#l10n 91 (ES - Hello two nuevo worlds)\n' - '#l10n 92 (ES - Hello on viernes, 1 de enero de 1960)\n' - '#l10n 93 (ES - Hello World, on viernes, 1 de enero de 1960)\n' - '#l10n 94 (ES - Hello two worlds, on viernes, 1 de enero de 1960)\n' - '#l10n 95 (ES - Hello other 0 worlds, with a total of 100 citizens)\n' - '#l10n 96 (ES - Hello World of 101 citizens)\n' - '#l10n 97 (ES - Hello two worlds with 102 total citizens)\n' - '#l10n 98 (ES - [Hola] -Mundo- #123#)\n' - '#l10n 99 (ES - \$!)\n' - '#l10n 100 (ES - One \$)\n' - "#l10n 101 (ES - Flutter's amazing!)\n" - "#l10n 102 (ES - Flutter's amazing, times 2!)\n" - '#l10n 103 (ES - Flutter is "amazing"!)\n' - '#l10n 104 (ES - Flutter is "amazing", times 2!)\n' - '#l10n 105 (ES - 16 wheel truck)\n' - "#l10n 106 (ES - Sedan's elegance)\n" - '#l10n 107 (ES - Cabriolet has "acceleration")\n' - '#l10n 108 (ES - Oh, she found ES - 1 itemES - !)\n' - '#l10n 109 (ES - Indeed, ES - they like ES - Flutter!)\n' - '#l10n 110 (--- es_419 ---)\n' - '#l10n 111 (ES 419 - Hello World)\n' - '#l10n 112 (ES 419 - Hello)\n' + '#l10n 76 (6/26/2023)\n' + '#l10n 77 (5:23:00 AM)\n' + '#l10n 78 (--- es ---)\n' + '#l10n 79 (ES - Hello world)\n' + '#l10n 80 (ES - Hello _NEWLINE_ World)\n' + '#l10n 81 (ES - Hola \$ Mundo)\n' + '#l10n 82 (ES - Hello Mundo)\n' + '#l10n 83 (ES - Hola Mundo)\n' + '#l10n 84 (ES - Hello World on viernes, 1 de enero de 1960)\n' + '#l10n 85 (ES - Hello world argument on 1/1/1960 at 0:00)\n' + '#l10n 86 (ES - Hello World from 1960 to 2020)\n' + '#l10n 87 (ES - Hello for 123)\n' + '#l10n 88 (ES - Hello)\n' + '#l10n 89 (ES - Hello World)\n' + '#l10n 90 (ES - Hello two worlds)\n' + '#l10n 91 (ES - Hello)\n' + '#l10n 92 (ES - Hello nuevo World)\n' + '#l10n 93 (ES - Hello two nuevo worlds)\n' + '#l10n 94 (ES - Hello on viernes, 1 de enero de 1960)\n' + '#l10n 95 (ES - Hello World, on viernes, 1 de enero de 1960)\n' + '#l10n 96 (ES - Hello two worlds, on viernes, 1 de enero de 1960)\n' + '#l10n 97 (ES - Hello other 0 worlds, with a total of 100 citizens)\n' + '#l10n 98 (ES - Hello World of 101 citizens)\n' + '#l10n 99 (ES - Hello two worlds with 102 total citizens)\n' + '#l10n 100 (ES - [Hola] -Mundo- #123#)\n' + '#l10n 101 (ES - \$!)\n' + '#l10n 102 (ES - One \$)\n' + "#l10n 103 (ES - Flutter's amazing!)\n" + "#l10n 104 (ES - Flutter's amazing, times 2!)\n" + '#l10n 105 (ES - Flutter is "amazing"!)\n' + '#l10n 106 (ES - Flutter is "amazing", times 2!)\n' + '#l10n 107 (ES - 16 wheel truck)\n' + "#l10n 108 (ES - Sedan's elegance)\n" + '#l10n 109 (ES - Cabriolet has "acceleration")\n' + '#l10n 110 (ES - Oh, she found ES - 1 itemES - !)\n' + '#l10n 111 (ES - Indeed, ES - they like ES - Flutter!)\n' + '#l10n 112 (--- es_419 ---)\n' '#l10n 113 (ES 419 - Hello World)\n' - '#l10n 114 (ES 419 - Hello two worlds)\n' + '#l10n 114 (ES 419 - Hello)\n' + '#l10n 115 (ES 419 - Hello World)\n' + '#l10n 116 (ES 419 - Hello two worlds)\n' '#l10n END\n' + ); } diff --git a/packages/flutter_tools/test/integration.shard/test_data/gen_l10n_project.dart b/packages/flutter_tools/test/integration.shard/test_data/gen_l10n_project.dart index 46d2a51202d..68cc153f92f 100644 --- a/packages/flutter_tools/test/integration.shard/test_data/gen_l10n_project.dart +++ b/packages/flutter_tools/test/integration.shard/test_data/gen_l10n_project.dart @@ -232,6 +232,8 @@ class Home extends StatelessWidget { "${localizations.selectInPlural('male', 1)}", "${localizations.selectInPlural('male', 2)}", "${localizations.selectInPlural('female', 1)}", + '${localizations.datetime1(DateTime(2023, 6, 26))}', + '${localizations.datetime2(DateTime(2023, 6, 26, 5, 23))}', ]); }, ), @@ -682,7 +684,9 @@ void main() { "type": "num" } } - } + }, + "datetime1": "{today, date, ::yMd}", + "datetime2": "{current, time, ::jms}" } ''';