Skip to content

Commit ff838bc

Browse files
authored
Add locale-specific DateTime formatting syntax (flutter#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 flutter#127304.
1 parent f3a7485 commit ff838bc

File tree

8 files changed

+228
-47
lines changed

8 files changed

+228
-47
lines changed

packages/flutter_tools/lib/src/localizations/gen_l10n.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,6 +1157,7 @@ class LocalizationsGenerator {
11571157
// When traversing through a placeholderExpr node, return "$placeholderName".
11581158
// When traversing through a pluralExpr node, return "$tempVarN" and add variable declaration in "tempVariables".
11591159
// When traversing through a selectExpr node, return "$tempVarN" and add variable declaration in "tempVariables".
1160+
// When traversing through an argumentExpr node, return "$tempVarN" and add variable declaration in "tempVariables".
11601161
// When traversing through a message node, return concatenation of all of "generateVariables(child)" for each child.
11611162
String generateVariables(Node node, { bool isRoot = false }) {
11621163
switch (node.type) {
@@ -1259,6 +1260,34 @@ The plural cases must be one of "=0", "=1", "=2", "zero", "one", "two", "few", "
12591260
.replaceAll('@(selectCases)', selectLogicArgs.join('\n'))
12601261
);
12611262
return '\$$tempVarName';
1263+
case ST.argumentExpr:
1264+
requiresIntlImport = true;
1265+
assert(node.children[1].type == ST.identifier);
1266+
assert(node.children[3].type == ST.argType);
1267+
assert(node.children[7].type == ST.identifier);
1268+
final String identifierName = node.children[1].value!;
1269+
final Node formatType = node.children[7];
1270+
// Check that formatType is a valid intl.DateFormat.
1271+
if (!validDateFormats.contains(formatType.value)) {
1272+
throw L10nParserException(
1273+
'Date format "${formatType.value!}" for placeholder '
1274+
'$identifierName does not have a corresponding DateFormat '
1275+
"constructor\n. Check the intl library's DateFormat class "
1276+
'constructors for allowed date formats, or set "isCustomDateFormat" attribute '
1277+
'to "true".',
1278+
_inputFileNames[locale]!,
1279+
message.resourceId,
1280+
translationForMessage,
1281+
formatType.positionInMessage,
1282+
);
1283+
}
1284+
final String tempVarName = getTempVariableName();
1285+
tempVariables.add(dateVariableTemplate
1286+
.replaceAll('@(varName)', tempVarName)
1287+
.replaceAll('@(formatType)', formatType.value!)
1288+
.replaceAll('@(argument)', identifierName)
1289+
);
1290+
return '\$$tempVarName';
12621291
// ignore: no_default_cases
12631292
default:
12641293
throw Exception('Cannot call "generateHelperMethod" on node type ${node.type}');

packages/flutter_tools/lib/src/localizations/gen_l10n_templates.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ const String selectVariableTemplate = '''
157157
},
158158
);''';
159159

160+
const String dateVariableTemplate = '''
161+
String @(varName) = intl.DateFormat.@(formatType)(localeName).format(@(argument));''';
162+
160163
const String classFileTemplate = '''
161164
@(header)@(requiresIntlImport)import '@(fileName)';
162165

packages/flutter_tools/lib/src/localizations/gen_l10n_types.dart

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import 'message_parser.dart';
2727
// * <https://pub.dev/packages/intl>
2828
// * <https://pub.dev/documentation/intl/latest/intl/DateFormat-class.html>
2929
// * <https://api.dartlang.org/stable/2.7.0/dart-core/DateTime-class.html>
30-
const Set<String> _validDateFormats = <String>{
30+
const Set<String> validDateFormats = <String>{
3131
'd',
3232
'E',
3333
'EEEE',
@@ -244,13 +244,14 @@ class Placeholder {
244244
String? type;
245245
bool isPlural = false;
246246
bool isSelect = false;
247+
bool isDateTime = false;
248+
bool requiresDateFormatting = false;
247249

248250
bool get requiresFormatting => requiresDateFormatting || requiresNumFormatting;
249-
bool get requiresDateFormatting => type == 'DateTime';
250251
bool get requiresNumFormatting => <String>['int', 'num', 'double'].contains(type) && format != null;
251252
bool get hasValidNumberFormat => _validNumberFormats.contains(format);
252253
bool get hasNumberFormatWithParameters => _numberFormatsWithNamedParameters.contains(format);
253-
bool get hasValidDateFormat => _validDateFormats.contains(format);
254+
bool get hasValidDateFormat => validDateFormats.contains(format);
254255

255256
static String? _stringAttribute(
256257
String resourceId,
@@ -488,7 +489,12 @@ class Message {
488489
final List<Node> traversalStack = <Node>[parsedMessages[locale]!];
489490
while (traversalStack.isNotEmpty) {
490491
final Node node = traversalStack.removeLast();
491-
if (<ST>[ST.placeholderExpr, ST.pluralExpr, ST.selectExpr].contains(node.type)) {
492+
if (<ST>[
493+
ST.placeholderExpr,
494+
ST.pluralExpr,
495+
ST.selectExpr,
496+
ST.argumentExpr
497+
].contains(node.type)) {
492498
final String identifier = node.children[1].value!;
493499
Placeholder? placeholder = getPlaceholder(identifier);
494500
if (placeholder == null) {
@@ -499,6 +505,14 @@ class Message {
499505
placeholder.isPlural = true;
500506
} else if (node.type == ST.selectExpr) {
501507
placeholder.isSelect = true;
508+
} else if (node.type == ST.argumentExpr) {
509+
placeholder.isDateTime = true;
510+
} else {
511+
// Here the node type must be ST.placeholderExpr.
512+
// A DateTime placeholder must require date formatting.
513+
if (placeholder.type == 'DateTime') {
514+
placeholder.requiresDateFormatting = true;
515+
}
502516
}
503517
}
504518
traversalStack.addAll(node.children);
@@ -510,9 +524,16 @@ class Message {
510524
..sort((MapEntry<String, Placeholder> p1, MapEntry<String, Placeholder> p2) => p1.key.compareTo(p2.key))
511525
);
512526

527+
bool atMostOneOf(bool x, bool y, bool z) {
528+
return x && !y && !z
529+
|| !x && y && !z
530+
|| !x && !y && z
531+
|| !x && !y && !z;
532+
}
533+
513534
for (final Placeholder placeholder in placeholders.values) {
514-
if (placeholder.isPlural && placeholder.isSelect) {
515-
throw L10nException('Placeholder is used as both a plural and select in certain languages.');
535+
if (!atMostOneOf(placeholder.isPlural, placeholder.isDateTime, placeholder.isSelect)) {
536+
throw L10nException('Placeholder is used as plural/select/datetime in certain languages.');
516537
} else if (placeholder.isPlural) {
517538
if (placeholder.type == null) {
518539
placeholder.type = 'num';
@@ -526,6 +547,12 @@ class Message {
526547
} else if (placeholder.type != 'String') {
527548
throw L10nException("Placeholders used in selects must be of type 'String'");
528549
}
550+
} else if (placeholder.isDateTime) {
551+
if (placeholder.type == null) {
552+
placeholder.type = 'DateTime';
553+
} else if (placeholder.type != 'DateTime') {
554+
throw L10nException("Placeholders used in datetime expressions much be of type 'DateTime'");
555+
}
529556
}
530557
placeholder.type ??= 'Object';
531558
}

packages/flutter_tools/lib/src/localizations/message_parser.dart

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,25 @@ enum ST {
2222
number,
2323
identifier,
2424
empty,
25+
colon,
26+
date,
27+
time,
2528
// Nonterminal Types
2629
message,
2730

2831
placeholderExpr,
2932

33+
argumentExpr,
34+
3035
pluralExpr,
3136
pluralParts,
3237
pluralPart,
3338

3439
selectExpr,
3540
selectParts,
3641
selectPart,
42+
43+
argType,
3744
}
3845

3946
// The grammar of the syntax.
@@ -43,6 +50,7 @@ Map<ST, List<List<ST>>> grammar = <ST, List<List<ST>>>{
4350
<ST>[ST.placeholderExpr, ST.message],
4451
<ST>[ST.pluralExpr, ST.message],
4552
<ST>[ST.selectExpr, ST.message],
53+
<ST>[ST.argumentExpr, ST.message],
4654
<ST>[ST.empty],
4755
],
4856
ST.placeholderExpr: <List<ST>>[
@@ -73,6 +81,13 @@ Map<ST, List<List<ST>>> grammar = <ST, List<List<ST>>>{
7381
<ST>[ST.number, ST.openBrace, ST.message, ST.closeBrace],
7482
<ST>[ST.other, ST.openBrace, ST.message, ST.closeBrace],
7583
],
84+
ST.argumentExpr: <List<ST>>[
85+
<ST>[ST.openBrace, ST.identifier, ST.comma, ST.argType, ST.comma, ST.colon, ST.colon, ST.identifier, ST.closeBrace],
86+
],
87+
ST.argType: <List<ST>>[
88+
<ST>[ST.date],
89+
<ST>[ST.time],
90+
],
7691
};
7792

7893
class Node {
@@ -100,6 +115,8 @@ class Node {
100115
Node.selectKeyword(this.positionInMessage): type = ST.select, value = 'select';
101116
Node.otherKeyword(this.positionInMessage): type = ST.other, value = 'other';
102117
Node.empty(this.positionInMessage): type = ST.empty, value = '';
118+
Node.dateKeyword(this.positionInMessage): type = ST.date, value = 'date';
119+
Node.timeKeyword(this.positionInMessage): type = ST.time, value = 'time';
103120

104121
String? value;
105122
late ST type;
@@ -162,13 +179,15 @@ RegExp numeric = RegExp(r'[0-9]+');
162179
RegExp alphanumeric = RegExp(r'[a-zA-Z0-9|_]+');
163180
RegExp comma = RegExp(r',');
164181
RegExp equalSign = RegExp(r'=');
182+
RegExp colon = RegExp(r':');
165183

166184
// List of token matchers ordered by precedence
167185
Map<ST, RegExp> matchers = <ST, RegExp>{
168186
ST.empty: whitespace,
169187
ST.number: numeric,
170188
ST.comma: comma,
171189
ST.equalSign: equalSign,
190+
ST.colon: colon,
172191
ST.identifier: alphanumeric,
173192
};
174193

@@ -312,6 +331,10 @@ class Parser {
312331
matchedType = ST.select;
313332
case 'other':
314333
matchedType = ST.other;
334+
case 'date':
335+
matchedType = ST.date;
336+
case 'time':
337+
matchedType = ST.time;
315338
}
316339
tokens.add(Node(matchedType!, startIndex, value: match.group(0)));
317340
startIndex = match.end;
@@ -354,16 +377,18 @@ class Parser {
354377
switch (symbol) {
355378
case ST.message:
356379
if (tokens.isEmpty) {
357-
parseAndConstructNode(ST.message, 4);
380+
parseAndConstructNode(ST.message, 5);
358381
} else if (tokens[0].type == ST.closeBrace) {
359-
parseAndConstructNode(ST.message, 4);
382+
parseAndConstructNode(ST.message, 5);
360383
} else if (tokens[0].type == ST.string) {
361384
parseAndConstructNode(ST.message, 0);
362385
} else if (tokens[0].type == ST.openBrace) {
363386
if (3 < tokens.length && tokens[3].type == ST.plural) {
364387
parseAndConstructNode(ST.message, 2);
365388
} else if (3 < tokens.length && tokens[3].type == ST.select) {
366389
parseAndConstructNode(ST.message, 3);
390+
} else if (3 < tokens.length && (tokens[3].type == ST.date || tokens[3].type == ST.time)) {
391+
parseAndConstructNode(ST.message, 4);
367392
} else {
368393
parseAndConstructNode(ST.message, 1);
369394
}
@@ -373,6 +398,16 @@ class Parser {
373398
}
374399
case ST.placeholderExpr:
375400
parseAndConstructNode(ST.placeholderExpr, 0);
401+
case ST.argumentExpr:
402+
parseAndConstructNode(ST.argumentExpr, 0);
403+
case ST.argType:
404+
if (tokens.isNotEmpty && tokens[0].type == ST.date) {
405+
parseAndConstructNode(ST.argType, 0);
406+
} else if (tokens.isNotEmpty && tokens[0].type == ST.time) {
407+
parseAndConstructNode(ST.argType, 1);
408+
} else {
409+
throw L10nException('ICU Syntax Error. Found unknown argument type.');
410+
}
376411
case ST.pluralExpr:
377412
parseAndConstructNode(ST.pluralExpr, 0);
378413
case ST.pluralParts:

packages/flutter_tools/test/general.shard/generate_localizations_test.dart

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,6 +1759,67 @@ import 'output-localization-file_en.dart' deferred as output-localization-file_e
17591759
});
17601760
});
17611761

1762+
group('argument messages', () {
1763+
testWithoutContext('should generate proper calls to intl.DateFormat', () {
1764+
setupLocalizations(<String, String>{
1765+
'en': '''
1766+
{
1767+
"datetime": "{today, date, ::yMd}"
1768+
}'''
1769+
});
1770+
expect(getGeneratedFileContent(locale: 'en'), contains('intl.DateFormat.yMd(localeName).format(today)'));
1771+
});
1772+
1773+
testWithoutContext('should generate proper calls to intl.DateFormat when using time', () {
1774+
setupLocalizations(<String, String>{
1775+
'en': '''
1776+
{
1777+
"datetime": "{current, time, ::jms}"
1778+
}'''
1779+
});
1780+
expect(getGeneratedFileContent(locale: 'en'), contains('intl.DateFormat.jms(localeName).format(current)'));
1781+
});
1782+
1783+
testWithoutContext('should not complain when placeholders are explicitly typed to DateTime', () {
1784+
setupLocalizations(<String, String>{
1785+
'en': '''
1786+
{
1787+
"datetime": "{today, date, ::yMd}",
1788+
"@datetime": {
1789+
"placeholders": {
1790+
"today": { "type": "DateTime" }
1791+
}
1792+
}
1793+
}'''
1794+
});
1795+
expect(getGeneratedFileContent(locale: 'en'), contains('String datetime(DateTime today) {'));
1796+
});
1797+
1798+
testWithoutContext('should automatically infer date time placeholders that are not explicitly defined', () {
1799+
setupLocalizations(<String, String>{
1800+
'en': '''
1801+
{
1802+
"datetime": "{today, date, ::yMd}"
1803+
}'''
1804+
});
1805+
expect(getGeneratedFileContent(locale: 'en'), contains('String datetime(DateTime today) {'));
1806+
});
1807+
1808+
testWithoutContext('should throw on invalid DateFormat', () {
1809+
try {
1810+
setupLocalizations(<String, String>{
1811+
'en': '''
1812+
{
1813+
"datetime": "{today, date, ::yMMMMMd}"
1814+
}'''
1815+
});
1816+
assert(false);
1817+
} on L10nException {
1818+
expect(logger.errorText, contains('Date format "yMMMMMd" for placeholder today does not have a corresponding DateFormat constructor'));
1819+
}
1820+
});
1821+
});
1822+
17621823
// All error handling for messages should collect errors on a per-error
17631824
// basis and log them out individually. Then, it will throw an L10nException.
17641825
group('error handling tests', () {

packages/flutter_tools/test/general.shard/message_parser_test.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,25 @@ void main() {
306306
])
307307
));
308308

309+
expect(Parser('argumentTest', 'app_en.arb', 'Today is {date, date, ::yMMd}').parse(), equals(
310+
Node(ST.message, 0, children: <Node>[
311+
Node(ST.string, 0, value: 'Today is '),
312+
Node(ST.argumentExpr, 9, children: <Node>[
313+
Node(ST.openBrace, 9, value: '{'),
314+
Node(ST.identifier, 10, value: 'date'),
315+
Node(ST.comma, 14, value: ','),
316+
Node(ST.argType, 16, children: <Node>[
317+
Node(ST.date, 16, value: 'date'),
318+
]),
319+
Node(ST.comma, 20, value: ','),
320+
Node(ST.colon, 22, value: ':'),
321+
Node(ST.colon, 23, value: ':'),
322+
Node(ST.identifier, 24, value: 'yMMd'),
323+
Node(ST.closeBrace, 28, value: '}'),
324+
]),
325+
])
326+
));
327+
309328
expect(Parser(
310329
'plural',
311330
'app_en.arb',

0 commit comments

Comments
 (0)