Skip to content

Commit

Permalink
Add locale-specific DateTime formatting syntax (#129573)
Browse files Browse the repository at this point in the history
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 #127304.
  • Loading branch information
thkim1011 committed Jun 29, 2023
1 parent f3a7485 commit ff838bc
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 47 deletions.
29 changes: 29 additions & 0 deletions packages/flutter_tools/lib/src/localizations/gen_l10n.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)';
Expand Down
39 changes: 33 additions & 6 deletions packages/flutter_tools/lib/src/localizations/gen_l10n_types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import 'message_parser.dart';
// * <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> _validDateFormats = <String>{
const Set<String> validDateFormats = <String>{
'd',
'E',
'EEEE',
Expand Down Expand Up @@ -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 => <String>['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,
Expand Down Expand Up @@ -488,7 +489,12 @@ class Message {
final List<Node> traversalStack = <Node>[parsedMessages[locale]!];
while (traversalStack.isNotEmpty) {
final Node node = traversalStack.removeLast();
if (<ST>[ST.placeholderExpr, ST.pluralExpr, ST.selectExpr].contains(node.type)) {
if (<ST>[
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) {
Expand All @@ -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);
Expand All @@ -510,9 +524,16 @@ class Message {
..sort((MapEntry<String, Placeholder> p1, MapEntry<String, Placeholder> 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';
Expand All @@ -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';
}
Expand Down
39 changes: 37 additions & 2 deletions packages/flutter_tools/lib/src/localizations/message_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,25 @@ enum ST {
number,
identifier,
empty,
colon,
date,
time,
// Nonterminal Types
message,

placeholderExpr,

argumentExpr,

pluralExpr,
pluralParts,
pluralPart,

selectExpr,
selectParts,
selectPart,

argType,
}

// The grammar of the syntax.
Expand All @@ -43,6 +50,7 @@ Map<ST, List<List<ST>>> grammar = <ST, List<List<ST>>>{
<ST>[ST.placeholderExpr, ST.message],
<ST>[ST.pluralExpr, ST.message],
<ST>[ST.selectExpr, ST.message],
<ST>[ST.argumentExpr, ST.message],
<ST>[ST.empty],
],
ST.placeholderExpr: <List<ST>>[
Expand Down Expand Up @@ -73,6 +81,13 @@ Map<ST, List<List<ST>>> grammar = <ST, List<List<ST>>>{
<ST>[ST.number, ST.openBrace, ST.message, ST.closeBrace],
<ST>[ST.other, ST.openBrace, ST.message, ST.closeBrace],
],
ST.argumentExpr: <List<ST>>[
<ST>[ST.openBrace, ST.identifier, ST.comma, ST.argType, ST.comma, ST.colon, ST.colon, ST.identifier, ST.closeBrace],
],
ST.argType: <List<ST>>[
<ST>[ST.date],
<ST>[ST.time],
],
};

class Node {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -162,13 +179,15 @@ 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<ST, RegExp> matchers = <ST, RegExp>{
ST.empty: whitespace,
ST.number: numeric,
ST.comma: comma,
ST.equalSign: equalSign,
ST.colon: colon,
ST.identifier: alphanumeric,
};

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -354,16 +377,18 @@ 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) {
if (3 < tokens.length && tokens[3].type == ST.plural) {
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);
}
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<String, String>{
'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(<String, String>{
'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(<String, String>{
'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(<String, String>{
'en': '''
{
"datetime": "{today, date, ::yMd}"
}'''
});
expect(getGeneratedFileContent(locale: 'en'), contains('String datetime(DateTime today) {'));
});

testWithoutContext('should throw on invalid DateFormat', () {
try {
setupLocalizations(<String, String>{
'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', () {
Expand Down
19 changes: 19 additions & 0 deletions packages/flutter_tools/test/general.shard/message_parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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>[
Node(ST.string, 0, value: 'Today is '),
Node(ST.argumentExpr, 9, children: <Node>[
Node(ST.openBrace, 9, value: '{'),
Node(ST.identifier, 10, value: 'date'),
Node(ST.comma, 14, value: ','),
Node(ST.argType, 16, children: <Node>[
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',
Expand Down

0 comments on commit ff838bc

Please sign in to comment.