Unverified Commit 7802c7ac authored by Tae Hyung Kim's avatar Tae Hyung Kim Committed by GitHub

[gen_l10n] Improvements to `gen_l10n` (#116202)

* init

* fix tests

* fix lint

* extra changes

* oops missed some merge conflicts

* fix lexer add tests

* consistent warnings and errors

* throw error at the end

* improve efficiency, improve code generation

* fix

* nit

* fix test

* remove helper method class

* two d's

* oops

* empty commit as google testing won't pass :(
parent 43d5cbb1
...@@ -201,6 +201,10 @@ class GenerateLocalizationsCommand extends FlutterCommand { ...@@ -201,6 +201,10 @@ class GenerateLocalizationsCommand extends FlutterCommand {
'contained within pairs of single quotes as normal strings and treat all ' 'contained within pairs of single quotes as normal strings and treat all '
'consecutive pairs of single quotes as a single quote character.', 'consecutive pairs of single quotes as a single quote character.',
); );
argParser.addFlag(
'suppress-warnings',
help: 'When specified, all warnings will be suppressed.\n'
);
} }
final FileSystem _fileSystem; final FileSystem _fileSystem;
...@@ -258,6 +262,7 @@ class GenerateLocalizationsCommand extends FlutterCommand { ...@@ -258,6 +262,7 @@ class GenerateLocalizationsCommand extends FlutterCommand {
final bool areResourceAttributesRequired = boolArgDeprecated('required-resource-attributes'); final bool areResourceAttributesRequired = boolArgDeprecated('required-resource-attributes');
final bool usesNullableGetter = boolArgDeprecated('nullable-getter'); final bool usesNullableGetter = boolArgDeprecated('nullable-getter');
final bool useEscaping = boolArgDeprecated('use-escaping'); final bool useEscaping = boolArgDeprecated('use-escaping');
final bool suppressWarnings = boolArgDeprecated('suppress-warnings');
precacheLanguageAndRegionTags(); precacheLanguageAndRegionTags();
...@@ -281,6 +286,7 @@ class GenerateLocalizationsCommand extends FlutterCommand { ...@@ -281,6 +286,7 @@ class GenerateLocalizationsCommand extends FlutterCommand {
usesNullableGetter: usesNullableGetter, usesNullableGetter: usesNullableGetter,
useEscaping: useEscaping, useEscaping: useEscaping,
logger: _logger, logger: _logger,
suppressWarnings: suppressWarnings,
) )
..loadResources() ..loadResources()
..writeOutputFiles()) ..writeOutputFiles())
......
...@@ -67,6 +67,7 @@ LocalizationsGenerator generateLocalizations({ ...@@ -67,6 +67,7 @@ LocalizationsGenerator generateLocalizations({
usesNullableGetter: options.usesNullableGetter, usesNullableGetter: options.usesNullableGetter,
useEscaping: options.useEscaping, useEscaping: options.useEscaping,
logger: logger, logger: logger,
suppressWarnings: options.suppressWarnings,
) )
..loadResources() ..loadResources()
..writeOutputFiles(isFromYaml: true); ..writeOutputFiles(isFromYaml: true);
...@@ -90,8 +91,6 @@ String _syntheticL10nPackagePath(FileSystem fileSystem) => fileSystem.path.join( ...@@ -90,8 +91,6 @@ String _syntheticL10nPackagePath(FileSystem fileSystem) => fileSystem.path.join(
// For example, if placeholders are used for plurals and no type was specified, then the type will // For example, if placeholders are used for plurals and no type was specified, then the type will
// automatically set to 'num'. Similarly, if such placeholders are used for selects, then the type // automatically set to 'num'. Similarly, if such placeholders are used for selects, then the type
// will be set to 'String'. For such placeholders that are used for both, we should throw an error. // will be set to 'String'. For such placeholders that are used for both, we should throw an error.
// TODO(thkim1011): Let's store the output of this function in the Message class, so that we don't
// recompute this. See https://github.com/flutter/flutter/issues/112709
List<String> generateMethodParameters(Message message) { List<String> generateMethodParameters(Message message) {
return message.placeholders.values.map((Placeholder placeholder) { return message.placeholders.values.map((Placeholder placeholder) {
return '${placeholder.type} ${placeholder.name}'; return '${placeholder.type} ${placeholder.name}';
...@@ -456,6 +455,7 @@ class LocalizationsGenerator { ...@@ -456,6 +455,7 @@ class LocalizationsGenerator {
bool usesNullableGetter = true, bool usesNullableGetter = true,
bool useEscaping = false, bool useEscaping = false,
required Logger logger, required Logger logger,
bool suppressWarnings = false,
}) { }) {
final Directory? projectDirectory = projectDirFromPath(fileSystem, projectPathString); final Directory? projectDirectory = projectDirFromPath(fileSystem, projectPathString);
final Directory inputDirectory = inputDirectoryFromPath(fileSystem, inputPathString, projectDirectory); final Directory inputDirectory = inputDirectoryFromPath(fileSystem, inputPathString, projectDirectory);
...@@ -478,6 +478,7 @@ class LocalizationsGenerator { ...@@ -478,6 +478,7 @@ class LocalizationsGenerator {
areResourceAttributesRequired: areResourceAttributesRequired, areResourceAttributesRequired: areResourceAttributesRequired,
useEscaping: useEscaping, useEscaping: useEscaping,
logger: logger, logger: logger,
suppressWarnings: suppressWarnings,
); );
} }
...@@ -501,10 +502,11 @@ class LocalizationsGenerator { ...@@ -501,10 +502,11 @@ class LocalizationsGenerator {
this.usesNullableGetter = true, this.usesNullableGetter = true,
required this.logger, required this.logger,
this.useEscaping = false, this.useEscaping = false,
this.suppressWarnings = false,
}); });
final FileSystem _fs; final FileSystem _fs;
Iterable<Message> _allMessages = <Message>[]; List<Message> _allMessages = <Message>[];
late final AppResourceBundleCollection _allBundles = AppResourceBundleCollection(inputDirectory); late final AppResourceBundleCollection _allBundles = AppResourceBundleCollection(inputDirectory);
late final AppResourceBundle _templateBundle = AppResourceBundle(templateArbFile); late final AppResourceBundle _templateBundle = AppResourceBundle(templateArbFile);
late final Map<LocaleInfo, String> _inputFileNames = Map<LocaleInfo, String>.fromEntries( late final Map<LocaleInfo, String> _inputFileNames = Map<LocaleInfo, String>.fromEntries(
...@@ -637,6 +639,9 @@ class LocalizationsGenerator { ...@@ -637,6 +639,9 @@ class LocalizationsGenerator {
/// Logger to be used during the execution of the script. /// Logger to be used during the execution of the script.
Logger logger; Logger logger;
/// Whether or not to suppress warnings or not.
final bool suppressWarnings;
static bool _isNotReadable(FileStat fileStat) { static bool _isNotReadable(FileStat fileStat) {
final String rawStatString = fileStat.modeString(); final String rawStatString = fileStat.modeString();
// Removes potential prepended permission bits, such as '(suid)' and '(guid)'. // Removes potential prepended permission bits, such as '(suid)' and '(guid)'.
...@@ -851,9 +856,6 @@ class LocalizationsGenerator { ...@@ -851,9 +856,6 @@ class LocalizationsGenerator {
// Load _allMessages from templateArbFile and _allBundles from all of the ARB // Load _allMessages from templateArbFile and _allBundles from all of the ARB
// files in inputDirectory. Also initialized: supportedLocales. // files in inputDirectory. Also initialized: supportedLocales.
void loadResources() { void loadResources() {
_allMessages = _templateBundle.resourceIds.map((String id) => Message(
_templateBundle, _allBundles, id, areResourceAttributesRequired, useEscaping: useEscaping,
));
for (final String resourceId in _templateBundle.resourceIds) { for (final String resourceId in _templateBundle.resourceIds) {
if (!_isValidGetterAndMethodName(resourceId)) { if (!_isValidGetterAndMethodName(resourceId)) {
throw L10nException( throw L10nException(
...@@ -864,7 +866,10 @@ class LocalizationsGenerator { ...@@ -864,7 +866,10 @@ class LocalizationsGenerator {
); );
} }
} }
// The call to .toList() is absolutely necessary. Otherwise, it is an iterator and will call Message's constructor again.
_allMessages = _templateBundle.resourceIds.map((String id) => Message(
_templateBundle, _allBundles, id, areResourceAttributesRequired, useEscaping: useEscaping, logger: logger,
)).toList();
if (inputsAndOutputsListFile != null) { if (inputsAndOutputsListFile != null) {
_inputFileList.addAll(_allBundles.bundles.map((AppResourceBundle bundle) { _inputFileList.addAll(_allBundles.bundles.map((AppResourceBundle bundle) {
return bundle.file.absolute.path; return bundle.file.absolute.path;
...@@ -1083,6 +1088,7 @@ class LocalizationsGenerator { ...@@ -1083,6 +1088,7 @@ class LocalizationsGenerator {
} }
String _generateMethod(Message message, LocaleInfo locale) { String _generateMethod(Message message, LocaleInfo locale) {
try {
// Determine if we must import intl for date or number formatting. // Determine if we must import intl for date or number formatting.
if (message.placeholdersRequireFormatting) { if (message.placeholdersRequireFormatting) {
requiresIntlImport = true; requiresIntlImport = true;
...@@ -1098,74 +1104,37 @@ class LocalizationsGenerator { ...@@ -1098,74 +1104,37 @@ class LocalizationsGenerator {
.replaceAll('@(message)', "'${generateString(node.children.map((Node child) => child.value!).join())}'"); .replaceAll('@(message)', "'${generateString(node.children.map((Node child) => child.value!).join())}'");
} }
final List<String> helperMethods = <String>[]; final List<String> tempVariables = <String>[];
// Get a unique temporary variable name.
// Get a unique helper method name. int variableCount = 0;
int methodNameCount = 0; String getTempVariableName() {
String getHelperMethodName() { return '_temp${variableCount++}';
return '_${message.resourceId}${methodNameCount++}';
} }
// Do a DFS post order traversal, generating dependent // Do a DFS post order traversal through placeholderExpr, pluralExpr, and selectExpr nodes.
// placeholder, plural, select helper methods, and combine these into // When traversing through a placeholderExpr node, return "$placeholderName".
// one message. Returns the method/placeholder to use in parent string. // When traversing through a pluralExpr node, return "$tempVarN" and add variable declaration in "tempVariables".
HelperMethod generateHelperMethods(Node node, { bool isRoot = false }) { // When traversing through a selectExpr node, return "$tempVarN" and add variable declaration in "tempVariables".
final Set<Placeholder> dependentPlaceholders = <Placeholder>{}; // 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) { switch (node.type) {
case ST.message: case ST.message:
final List<HelperMethod> helpers = node.children.map<HelperMethod>((Node node) { final List<String> expressions = node.children.map<String>((Node node) {
if (node.type == ST.string) { if (node.type == ST.string) {
return HelperMethod(<Placeholder>{}, string: node.value); return node.value!;
} }
final HelperMethod helper = generateHelperMethods(node); return generateVariables(node);
dependentPlaceholders.addAll(helper.dependentPlaceholders);
return helper;
}).toList(); }).toList();
final String messageString = generateReturnExpr(helpers); return generateReturnExpr(expressions);
// If the message is just a normal string, then only return the string.
if (dependentPlaceholders.isEmpty) {
return HelperMethod(dependentPlaceholders, string: messageString);
}
// For messages, if we are generating the actual overridden method, then we should also deal with
// date and number formatting here.
final String helperMethodName = getHelperMethodName();
final HelperMethod messageHelper = HelperMethod(dependentPlaceholders, helper: helperMethodName);
if (isRoot) {
helperMethods.add(methodTemplate
.replaceAll('@(name)', message.resourceId)
.replaceAll('@(parameters)', generateMethodParameters(message).join(', '))
.replaceAll('@(dateFormatting)', generateDateFormattingLogic(message))
.replaceAll('@(numberFormatting)', generateNumberFormattingLogic(message))
.replaceAll('@(message)', messageString)
.replaceAll('@(none)\n', '')
);
} else {
helperMethods.add(messageHelperTemplate
.replaceAll('@(name)', helperMethodName)
.replaceAll('@(parameters)', messageHelper.methodParameters)
.replaceAll('@(message)', messageString)
);
}
return messageHelper;
case ST.placeholderExpr: case ST.placeholderExpr:
assert(node.children[1].type == ST.identifier); assert(node.children[1].type == ST.identifier);
final Node identifier = node.children[1]; final String identifier = node.children[1].value!;
// Check that placeholders exist. final Placeholder placeholder = message.placeholders[identifier]!;
final Placeholder? placeholder = message.placeholders[identifier.value]; if (placeholder.requiresFormatting) {
if (placeholder == null) { return '\$${node.children[1].value}String';
throw L10nParserException(
'Make sure that the specified placeholder is defined in your arb file.',
_inputFileNames[locale]!,
message.resourceId,
translationForMessage,
identifier.positionInMessage,
);
} }
dependentPlaceholders.add(placeholder); return '\$${node.children[1].value}';
return HelperMethod(dependentPlaceholders, placeholder: placeholder);
case ST.pluralExpr: case ST.pluralExpr:
requiresIntlImport = true; requiresIntlImport = true;
...@@ -1178,28 +1147,6 @@ class LocalizationsGenerator { ...@@ -1178,28 +1147,6 @@ class LocalizationsGenerator {
final Node identifier = node.children[1]; final Node identifier = node.children[1];
final Node pluralParts = node.children[5]; final Node pluralParts = node.children[5];
// Check that placeholders exist and is of type int or num.
final Placeholder? placeholder = message.placeholders[identifier.value];
if (placeholder == null) {
throw L10nParserException(
'Make sure that the specified placeholder is defined in your arb file.',
_inputFileNames[locale]!,
message.resourceId,
translationForMessage,
identifier.positionInMessage,
);
}
if (placeholder.type != 'num' && placeholder.type != 'int') {
throw L10nParserException(
'The specified placeholder must be of type int or num.',
_inputFileNames[locale]!,
message.resourceId,
translationForMessage,
identifier.positionInMessage,
);
}
dependentPlaceholders.add(placeholder);
for (final Node pluralPart in pluralParts.children.reversed) { for (final Node pluralPart in pluralParts.children.reversed) {
String pluralCase; String pluralCase;
Node pluralMessage; Node pluralMessage;
...@@ -1215,26 +1162,22 @@ class LocalizationsGenerator { ...@@ -1215,26 +1162,22 @@ class LocalizationsGenerator {
pluralMessage = pluralPart.children[2]; pluralMessage = pluralPart.children[2];
} }
if (!pluralLogicArgs.containsKey(pluralCases[pluralCase])) { if (!pluralLogicArgs.containsKey(pluralCases[pluralCase])) {
final HelperMethod pluralPartHelper = generateHelperMethods(pluralMessage); final String pluralPartExpression = generateVariables(pluralMessage);
pluralLogicArgs[pluralCases[pluralCase]!] = ' ${pluralCases[pluralCase]}: ${pluralPartHelper.helperOrPlaceholder},'; pluralLogicArgs[pluralCases[pluralCase]!] = ' ${pluralCases[pluralCase]}: $pluralPartExpression,';
dependentPlaceholders.addAll(pluralPartHelper.dependentPlaceholders); } else if (!suppressWarnings) {
} else {
logger.printWarning(''' logger.printWarning('''
The plural part specified below is overrided by a later plural part. [${_inputFileNames[locale]}:${message.resourceId}] ICU Syntax Warning: The plural part specified below is overridden by a later plural part.
$translationForMessage $translationForMessage
${Parser.indentForError(pluralPart.positionInMessage)} ${Parser.indentForError(pluralPart.positionInMessage)}''');
''');
} }
} }
final String helperMethodName = getHelperMethodName(); final String tempVarName = getTempVariableName();
final HelperMethod pluralHelper = HelperMethod(dependentPlaceholders, helper: helperMethodName); tempVariables.add(pluralVariableTemplate
helperMethods.add(pluralHelperTemplate .replaceAll('@(varName)', tempVarName)
.replaceAll('@(name)', helperMethodName)
.replaceAll('@(parameters)', pluralHelper.methodParameters)
.replaceAll('@(count)', identifier.value!) .replaceAll('@(count)', identifier.value!)
.replaceAll('@(pluralLogicArgs)', pluralLogicArgs.values.join('\n')) .replaceAll('@(pluralLogicArgs)', pluralLogicArgs.values.join('\n'))
); );
return pluralHelper; return '\$$tempVarName';
case ST.selectExpr: case ST.selectExpr:
requiresIntlImport = true; requiresIntlImport = true;
...@@ -1244,53 +1187,53 @@ ${Parser.indentForError(pluralPart.positionInMessage)} ...@@ -1244,53 +1187,53 @@ ${Parser.indentForError(pluralPart.positionInMessage)}
assert(node.children[5].type == ST.selectParts); assert(node.children[5].type == ST.selectParts);
final Node identifier = node.children[1]; final Node identifier = node.children[1];
// Check that placeholders exist.
final Placeholder? placeholder = message.placeholders[identifier.value];
if (placeholder == null) {
throw L10nParserException(
'Make sure that the specified placeholder is defined in your arb file.',
_inputFileNames[locale]!,
message.resourceId,
translationForMessage,
identifier.positionInMessage,
);
}
dependentPlaceholders.add(placeholder);
final List<String> selectLogicArgs = <String>[]; final List<String> selectLogicArgs = <String>[];
final Node selectParts = node.children[5]; final Node selectParts = node.children[5];
for (final Node selectPart in selectParts.children) { for (final Node selectPart in selectParts.children) {
assert(selectPart.children[0].type == ST.identifier || selectPart.children[0].type == ST.other); assert(selectPart.children[0].type == ST.identifier || selectPart.children[0].type == ST.other);
assert(selectPart.children[2].type == ST.message); assert(selectPart.children[2].type == ST.message);
final String selectCase = selectPart.children[0].value!; final String selectCase = selectPart.children[0].value!;
final Node selectMessage = selectPart.children[2]; final Node selectMessage = selectPart.children[2];
final HelperMethod selectPartHelper = generateHelperMethods(selectMessage); final String selectPartExpression = generateVariables(selectMessage);
selectLogicArgs.add(" '$selectCase': ${selectPartHelper.helperOrPlaceholder},"); selectLogicArgs.add(" '$selectCase': $selectPartExpression,");
dependentPlaceholders.addAll(selectPartHelper.dependentPlaceholders);
} }
final String helperMethodName = getHelperMethodName(); final String tempVarName = getTempVariableName();
final HelperMethod selectHelper = HelperMethod(dependentPlaceholders, helper: helperMethodName); tempVariables.add(selectVariableTemplate
.replaceAll('@(varName)', tempVarName)
helperMethods.add(selectHelperTemplate
.replaceAll('@(name)', helperMethodName)
.replaceAll('@(parameters)', selectHelper.methodParameters)
.replaceAll('@(choice)', identifier.value!) .replaceAll('@(choice)', identifier.value!)
.replaceAll('@(selectCases)', selectLogicArgs.join('\n')) .replaceAll('@(selectCases)', selectLogicArgs.join('\n'))
); );
return HelperMethod(dependentPlaceholders, helper: helperMethodName); return '\$$tempVarName';
// ignore: no_default_cases // ignore: no_default_cases
default: default:
throw Exception('Cannot call "generateHelperMethod" on node type ${node.type}'); throw Exception('Cannot call "generateHelperMethod" on node type ${node.type}');
} }
} }
generateHelperMethods(node, isRoot: true); final String messageString = generateVariables(node, isRoot: true);
return helperMethods.last.replaceAll('@(helperMethods)', helperMethods.sublist(0, helperMethods.length - 1).join('\n\n')); final String tempVarLines = tempVariables.isEmpty ? '' : '${tempVariables.join('\n')}\n';
return methodTemplate
.replaceAll('@(name)', message.resourceId)
.replaceAll('@(parameters)', generateMethodParameters(message).join(', '))
.replaceAll('@(dateFormatting)', generateDateFormattingLogic(message))
.replaceAll('@(numberFormatting)', generateNumberFormattingLogic(message))
.replaceAll('@(tempVars)', tempVarLines)
.replaceAll('@(message)', messageString)
.replaceAll('@(none)\n', '');
} on L10nParserException catch (error) {
logger.printError(error.toString());
return '';
}
} }
List<String> writeOutputFiles({ bool isFromYaml = false }) { List<String> writeOutputFiles({ bool isFromYaml = false }) {
// First, generate the string contents of all necessary files. // First, generate the string contents of all necessary files.
final String generatedLocalizationsFile = _generateCode(); final String generatedLocalizationsFile = _generateCode();
// If there were any syntax errors, don't write to files.
if (logger.hadErrorOutput) {
throw L10nException('Found syntax errors.');
}
// A pubspec.yaml file is required when using a synthetic package. If it does not // A pubspec.yaml file is required when using a synthetic package. If it does not
// exist, create a blank one. // exist, create a blank one.
if (useSyntheticPackage) { if (useSyntheticPackage) {
......
...@@ -139,33 +139,23 @@ const String methodTemplate = ''' ...@@ -139,33 +139,23 @@ const String methodTemplate = '''
String @(name)(@(parameters)) { String @(name)(@(parameters)) {
@(dateFormatting) @(dateFormatting)
@(numberFormatting) @(numberFormatting)
@(helperMethods) @(tempVars) return @(message);
return @(message);
}'''; }''';
const String messageHelperTemplate = ''' const String pluralVariableTemplate = '''
String @(name)(@(parameters)) { String @(varName) = intl.Intl.pluralLogic(
return @(message); @(count),
}'''; locale: localeName,
const String pluralHelperTemplate = '''
String @(name)(@(parameters)) {
return intl.Intl.pluralLogic(
@(count),
locale: localeName,
@(pluralLogicArgs) @(pluralLogicArgs)
); );''';
}''';
const String selectVariableTemplate = '''
const String selectHelperTemplate = ''' String @(varName) = intl.Intl.selectLogic(
String @(name)(@(parameters)) { @(choice),
return intl.Intl.selectLogic( {
@(choice),
{
@(selectCases) @(selectCases)
}, },
); );''';
}''';
const String classFileTemplate = ''' const String classFileTemplate = '''
@(header)@(requiresIntlImport)import '@(fileName)'; @(header)@(requiresIntlImport)import '@(fileName)';
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'package:intl/locale.dart'; import 'package:intl/locale.dart';
import '../base/file_system.dart'; import '../base/file_system.dart';
import '../base/logger.dart';
import '../convert.dart'; import '../convert.dart';
import 'localizations_utils.dart'; import 'localizations_utils.dart';
import 'message_parser.dart'; import 'message_parser.dart';
...@@ -138,17 +139,31 @@ class L10nParserException extends L10nException { ...@@ -138,17 +139,31 @@ class L10nParserException extends L10nException {
this.messageString, this.messageString,
this.charNumber this.charNumber
): super(''' ): super('''
$error [$fileName:$messageId] $error
[$fileName:$messageId] $messageString $messageString
${List<String>.filled(4 + fileName.length + messageId.length + charNumber, ' ').join()}^'''); ${List<String>.filled(charNumber, ' ').join()}^''');
final String error; final String error;
final String fileName; final String fileName;
final String messageId; final String messageId;
final String messageString; final String messageString;
// Position of character within the "messageString" where the error is.
final int charNumber; final int charNumber;
} }
class L10nMissingPlaceholderException extends L10nParserException {
L10nMissingPlaceholderException(
super.error,
super.fileName,
super.messageId,
super.messageString,
super.charNumber,
this.placeholderName,
);
final String placeholderName;
}
// One optional named parameter to be used by a NumberFormat. // One optional named parameter to be used by a NumberFormat.
// //
// Some of the NumberFormat factory constructors have optional named parameters. // Some of the NumberFormat factory constructors have optional named parameters.
...@@ -319,7 +334,10 @@ class Message { ...@@ -319,7 +334,10 @@ class Message {
AppResourceBundleCollection allBundles, AppResourceBundleCollection allBundles,
this.resourceId, this.resourceId,
bool isResourceAttributeRequired, bool isResourceAttributeRequired,
{ this.useEscaping = false } {
this.useEscaping = false,
this.logger,
}
) : assert(templateBundle != null), ) : assert(templateBundle != null),
assert(allBundles != null), assert(allBundles != null),
assert(resourceId != null && resourceId.isNotEmpty), assert(resourceId != null && resourceId.isNotEmpty),
...@@ -335,64 +353,16 @@ class Message { ...@@ -335,64 +353,16 @@ class Message {
filenames[bundle.locale] = bundle.file.basename; filenames[bundle.locale] = bundle.file.basename;
final String? translation = bundle.translationFor(resourceId); final String? translation = bundle.translationFor(resourceId);
messages[bundle.locale] = translation; messages[bundle.locale] = translation;
parsedMessages[bundle.locale] = translation == null ? null : Parser(resourceId, bundle.file.basename, translation, useEscaping: useEscaping).parse(); parsedMessages[bundle.locale] = translation == null ? null : Parser(
} resourceId,
// Using parsed translations, attempt to infer types of placeholders used by plurals and selects. bundle.file.basename,
for (final LocaleInfo locale in parsedMessages.keys) { translation,
if (parsedMessages[locale] == null) { useEscaping: useEscaping,
continue; logger: logger
} ).parse();
final List<Node> traversalStack = <Node>[parsedMessages[locale]!];
while (traversalStack.isNotEmpty) {
final Node node = traversalStack.removeLast();
if (node.type == ST.pluralExpr) {
final Placeholder? placeholder = placeholders[node.children[1].value!];
if (placeholder == null) {
throw L10nParserException(
'Make sure that the specified plural placeholder is defined in your arb file.',
filenames[locale]!,
resourceId,
messages[locale]!,
node.children[1].positionInMessage
);
}
placeholders[node.children[1].value!]!.isPlural = true;
}
if (node.type == ST.selectExpr) {
final Placeholder? placeholder = placeholders[node.children[1].value!];
if (placeholder == null) {
throw L10nParserException(
'Make sure that the specified select placeholder is defined in your arb file.',
filenames[locale]!,
resourceId,
messages[locale]!,
node.children[1].positionInMessage
);
}
placeholders[node.children[1].value!]!.isSelect = true;
}
traversalStack.addAll(node.children);
}
}
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.');
} else if (placeholder.isPlural) {
if (placeholder.type == null) {
placeholder.type = 'num';
}
else if (!<String>['num', 'int'].contains(placeholder.type)) {
throw L10nException("Placeholders used in plurals must be of type 'num' or 'int'");
}
} else if (placeholder.isSelect) {
if (placeholder.type == null) {
placeholder.type = 'String';
} else if (placeholder.type != 'String') {
throw L10nException("Placeholders used in selects must be of type 'String'");
}
}
placeholder.type ??= 'Object';
} }
// Infer the placeholders
_inferPlaceholders(filenames);
} }
final String resourceId; final String resourceId;
...@@ -402,6 +372,7 @@ class Message { ...@@ -402,6 +372,7 @@ class Message {
final Map<LocaleInfo, Node?> parsedMessages; final Map<LocaleInfo, Node?> parsedMessages;
final Map<String, Placeholder> placeholders; final Map<String, Placeholder> placeholders;
final bool useEscaping; final bool useEscaping;
final Logger? logger;
bool get placeholdersRequireFormatting => placeholders.values.any((Placeholder p) => p.requiresFormatting); bool get placeholdersRequireFormatting => placeholders.values.any((Placeholder p) => p.requiresFormatting);
...@@ -496,6 +467,63 @@ class Message { ...@@ -496,6 +467,63 @@ class Message {
}), }),
); );
} }
// Using parsed translations, attempt to infer types of placeholders used by plurals and selects.
// For undeclared placeholders, create a new placeholder.
void _inferPlaceholders(Map<LocaleInfo, String> filenames) {
// We keep the undeclared placeholders separate so that we can sort them alphabetically afterwards.
final Map<String, Placeholder> undeclaredPlaceholders = <String, Placeholder>{};
// Helper for getting placeholder by name.
Placeholder? getPlaceholder(String name) => placeholders[name] ?? undeclaredPlaceholders[name];
for (final LocaleInfo locale in parsedMessages.keys) {
if (parsedMessages[locale] == null) {
continue;
}
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)) {
final String identifier = node.children[1].value!;
Placeholder? placeholder = getPlaceholder(identifier);
if (placeholder == null) {
placeholder = Placeholder(resourceId, identifier, <String, Object?>{});
undeclaredPlaceholders[identifier] = placeholder;
}
if (node.type == ST.pluralExpr) {
placeholder.isPlural = true;
} else if (node.type == ST.selectExpr) {
placeholder.isSelect = true;
}
}
traversalStack.addAll(node.children);
}
}
placeholders.addEntries(
undeclaredPlaceholders.entries
.toList()
..sort((MapEntry<String, Placeholder> p1, MapEntry<String, Placeholder> p2) => p1.key.compareTo(p2.key))
);
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.');
} else if (placeholder.isPlural) {
if (placeholder.type == null) {
placeholder.type = 'num';
}
else if (!<String>['num', 'int'].contains(placeholder.type)) {
throw L10nException("Placeholders used in plurals must be of type 'num' or 'int'");
}
} else if (placeholder.isSelect) {
if (placeholder.type == null) {
placeholder.type = 'String';
} else if (placeholder.type != 'String') {
throw L10nException("Placeholders used in selects must be of type 'String'");
}
}
placeholder.type ??= 'Object';
}
}
} }
// Represents the contents of one ARB file. // Represents the contents of one ARB file.
...@@ -834,50 +862,3 @@ final Set<String> _iso639Languages = <String>{ ...@@ -834,50 +862,3 @@ final Set<String> _iso639Languages = <String>{
'zh', 'zh',
'zu', 'zu',
}; };
// Used in LocalizationsGenerator._generateMethod.generateHelperMethod.
class HelperMethod {
HelperMethod(this.dependentPlaceholders, {this.helper, this.placeholder, this.string }):
assert((() {
// At least one of helper, placeholder, string must be nonnull.
final bool a = helper == null;
final bool b = placeholder == null;
final bool c = string == null;
return (!a && b && c) || (a && !b && c) || (a && b && !c);
})());
Set<Placeholder> dependentPlaceholders;
String? helper;
Placeholder? placeholder;
String? string;
String get helperOrPlaceholder {
if (helper != null) {
return '$helper($methodArguments)';
} else if (string != null) {
return '$string';
} else {
if (placeholder!.requiresFormatting) {
return '${placeholder!.name}String';
} else {
return placeholder!.name;
}
}
}
String get methodParameters {
assert(helper != null);
return dependentPlaceholders.map((Placeholder placeholder) =>
(placeholder.requiresFormatting)
? 'String ${placeholder.name}String'
: '${placeholder.type} ${placeholder.name}').join(', ');
}
String get methodArguments {
assert(helper != null);
return dependentPlaceholders.map((Placeholder placeholder) =>
(placeholder.requiresFormatting)
? '${placeholder.name}String'
: placeholder.name).join(', ');
}
}
...@@ -297,25 +297,23 @@ String generateString(String value) { ...@@ -297,25 +297,23 @@ String generateString(String value) {
/// Given a list of strings, placeholders, or helper function calls, concatenate /// Given a list of strings, placeholders, or helper function calls, concatenate
/// them into one expression to be returned. /// them into one expression to be returned.
String generateReturnExpr(List<HelperMethod> helpers) { /// If isSingleStringVar is passed, then we want to convert "'$expr'" to simply "expr".
if (helpers.isEmpty) { String generateReturnExpr(List<String> expressions, { bool isSingleStringVar = false }) {
if (expressions.isEmpty) {
return "''"; return "''";
} else if ( } else if (isSingleStringVar) {
helpers.length == 1 // If our expression is "$varName" where varName is a String, this is equivalent to just varName.
&& helpers[0].string == null return expressions[0].substring(1);
&& (helpers[0].placeholder?.type == 'String' || helpers[0].helper != null)
) {
return helpers[0].helperOrPlaceholder;
} else { } else {
final String string = helpers.reversed.fold<String>('', (String string, HelperMethod helper) { final String string = expressions.reversed.fold<String>('', (String string, String expression) {
if (helper.string != null) { if (expression[0] != r'$') {
return generateString(helper.string!) + string; return generateString(expression) + string;
} }
final RegExp alphanumeric = RegExp(r'^([0-9a-zA-Z]|_)+$'); final RegExp alphanumeric = RegExp(r'^([0-9a-zA-Z]|_)+$');
if (alphanumeric.hasMatch(helper.helperOrPlaceholder) && !(string.isNotEmpty && alphanumeric.hasMatch(string[0]))) { if (alphanumeric.hasMatch(expression.substring(1)) && !(string.isNotEmpty && alphanumeric.hasMatch(string[0]))) {
return '\$${helper.helperOrPlaceholder}$string'; return '$expression$string';
} else { } else {
return '\${${helper.helperOrPlaceholder}}$string'; return '\${${expression.substring(1)}}$string';
} }
}); });
return "'$string'"; return "'$string'";
...@@ -340,6 +338,7 @@ class LocalizationOptions { ...@@ -340,6 +338,7 @@ class LocalizationOptions {
this.usesNullableGetter = true, this.usesNullableGetter = true,
this.format = false, this.format = false,
this.useEscaping = false, this.useEscaping = false,
this.suppressWarnings = false,
}) : assert(useSyntheticPackage != null); }) : assert(useSyntheticPackage != null);
/// The `--arb-dir` argument. /// The `--arb-dir` argument.
...@@ -416,6 +415,11 @@ class LocalizationOptions { ...@@ -416,6 +415,11 @@ class LocalizationOptions {
/// ///
/// Whether or not the ICU escaping syntax is used. /// Whether or not the ICU escaping syntax is used.
final bool useEscaping; final bool useEscaping;
/// The `suppress-warnings` argument.
///
/// Whether or not to suppress warnings.
final bool suppressWarnings;
} }
/// Parse the localizations configuration options from [file]. /// Parse the localizations configuration options from [file].
...@@ -450,8 +454,9 @@ LocalizationOptions parseLocalizationsOptions({ ...@@ -450,8 +454,9 @@ LocalizationOptions parseLocalizationsOptions({
useSyntheticPackage: _tryReadBool(yamlNode, 'synthetic-package', logger) ?? true, useSyntheticPackage: _tryReadBool(yamlNode, 'synthetic-package', logger) ?? true,
areResourceAttributesRequired: _tryReadBool(yamlNode, 'required-resource-attributes', logger) ?? false, areResourceAttributesRequired: _tryReadBool(yamlNode, 'required-resource-attributes', logger) ?? false,
usesNullableGetter: _tryReadBool(yamlNode, 'nullable-getter', logger) ?? true, usesNullableGetter: _tryReadBool(yamlNode, 'nullable-getter', logger) ?? true,
format: _tryReadBool(yamlNode, 'format', logger) ?? true, format: _tryReadBool(yamlNode, 'format', logger) ?? false,
useEscaping: _tryReadBool(yamlNode, 'use-escaping', logger) ?? false, useEscaping: _tryReadBool(yamlNode, 'use-escaping', logger) ?? false,
suppressWarnings: _tryReadBool(yamlNode, 'suppress-warnings', logger) ?? false,
); );
} }
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
// See https://flutter.dev/go/icu-message-parser. // See https://flutter.dev/go/icu-message-parser.
// Symbol Types // Symbol Types
import '../base/logger.dart';
import 'gen_l10n_types.dart'; import 'gen_l10n_types.dart';
enum ST { enum ST {
...@@ -181,13 +182,17 @@ class Parser { ...@@ -181,13 +182,17 @@ class Parser {
this.messageId, this.messageId,
this.filename, this.filename,
this.messageString, this.messageString,
{ this.useEscaping = false } {
this.useEscaping = false,
this.logger
}
); );
final String messageId; final String messageId;
final String messageString; final String messageString;
final String filename; final String filename;
final bool useEscaping; final bool useEscaping;
final Logger? logger;
static String indentForError(int position) { static String indentForError(int position) {
return '${List<String>.filled(position, ' ').join()}^'; return '${List<String>.filled(position, ' ').join()}^';
...@@ -297,6 +302,11 @@ class Parser { ...@@ -297,6 +302,11 @@ class Parser {
// Do not add whitespace as a token. // Do not add whitespace as a token.
startIndex = match.end; startIndex = match.end;
continue; continue;
} else if (<ST>[ST.plural, ST.select].contains(matchedType) && tokens.last.type == ST.openBrace) {
// Treat "plural" or "select" as identifier if it comes right after an open brace.
tokens.add(Node(ST.identifier, startIndex, value: match.group(0)));
startIndex = match.end;
continue;
} else { } else {
tokens.add(Node(matchedType!, startIndex, value: match.group(0))); tokens.add(Node(matchedType!, startIndex, value: match.group(0)));
startIndex = match.end; startIndex = match.end;
...@@ -566,8 +576,13 @@ class Parser { ...@@ -566,8 +576,13 @@ class Parser {
} }
Node parse() { Node parse() {
final Node syntaxTree = compress(parseIntoTree()); try {
checkExtraRules(syntaxTree); final Node syntaxTree = compress(parseIntoTree());
return syntaxTree; checkExtraRules(syntaxTree);
return syntaxTree;
} on L10nParserException catch (error) {
logger?.printError(error.toString());
return Node(ST.empty, 0, value: '');
}
} }
} }
...@@ -1284,6 +1284,40 @@ class AppLocalizationsEn extends AppLocalizations { ...@@ -1284,6 +1284,40 @@ class AppLocalizationsEn extends AppLocalizations {
}); });
group('writeOutputFiles', () { group('writeOutputFiles', () {
testWithoutContext('multiple messages with syntax error all log their errors', () {
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(r'''
{
"msg1": "{",
"msg2": "{ {"
}''');
l10nDirectory.childFile(esArbFileName)
.writeAsStringSync(singleEsMessageArbFileString);
try {
LocalizationsGenerator(
fileSystem: fs,
inputPathString: defaultL10nPathString,
outputPathString: defaultL10nPathString,
templateArbFileName: defaultTemplateArbFileName,
outputFileString: defaultOutputFileString,
classNameString: defaultClassNameString,
logger: logger,
)
..loadResources()
..writeOutputFiles();
} on L10nException catch (error) {
expect(error.message, equals('Found syntax errors.'));
expect(logger.errorText, contains('''
[app_en.arb:msg1] ICU Syntax Error: Expected "identifier" but found no tokens.
{
^
[app_en.arb:msg2] ICU Syntax Error: Expected "identifier" but found "{".
{ {
^'''));
}
});
testWithoutContext('message without placeholders - should generate code comment with description and template message translation', () { testWithoutContext('message without placeholders - should generate code comment with description and template message translation', () {
_standardFlutterDirectoryL10nSetup(fs); _standardFlutterDirectoryL10nSetup(fs);
LocalizationsGenerator( LocalizationsGenerator(
...@@ -1581,47 +1615,31 @@ import 'output-localization-file_en.dart' deferred as output-localization-file_e ...@@ -1581,47 +1615,31 @@ import 'output-localization-file_en.dart' deferred as output-localization-file_e
}); });
group('placeholder tests', () { group('placeholder tests', () {
testWithoutContext('should throw attempting to generate a select message without placeholders', () { testWithoutContext('should automatically infer placeholders that are not explicitly defined', () {
const String selectMessageWithoutPlaceholdersAttribute = ''' const String messageWithoutDefinedPlaceholder = '''
{ {
"helloWorld": "Hello {name}", "helloWorld": "Hello {name}"
"@helloWorld": {
"description": "Improperly formatted since it has no placeholder attribute.",
"placeholders": {
"hello": {},
"world": {}
}
}
}'''; }''';
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n') final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true); ..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName) l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(selectMessageWithoutPlaceholdersAttribute); .writeAsStringSync(messageWithoutDefinedPlaceholder);
LocalizationsGenerator(
expect( fileSystem: fs,
() { inputPathString: defaultL10nPathString,
LocalizationsGenerator( outputPathString: defaultL10nPathString,
fileSystem: fs, templateArbFileName: defaultTemplateArbFileName,
inputPathString: defaultL10nPathString, outputFileString: defaultOutputFileString,
outputPathString: defaultL10nPathString, classNameString: defaultClassNameString,
templateArbFileName: defaultTemplateArbFileName, logger: logger,
outputFileString: defaultOutputFileString, )
classNameString: defaultClassNameString, ..loadResources()
logger: logger, ..writeOutputFiles();
) final String localizationsFile = fs.file(
..loadResources() fs.path.join(syntheticL10nPackagePath, 'output-localization-file_en.dart'),
..writeOutputFiles(); ).readAsStringSync();
}, expect(localizationsFile, contains('String helloWorld(Object name) {'));
throwsA(isA<L10nException>().having(
(L10nException e) => e.message,
'message',
contains('''
Make sure that the specified placeholder is defined in your arb file.
[app_en.arb:helloWorld] Hello {name}
^'''),
)),
);
}); });
}); });
...@@ -1909,119 +1927,64 @@ Make sure that the specified placeholder is defined in your arb file. ...@@ -1909,119 +1927,64 @@ Make sure that the specified placeholder is defined in your arb file.
}); });
group('plural messages', () { group('plural messages', () {
testWithoutContext('should throw attempting to generate a plural message without placeholders', () { testWithoutContext('warnings are generated when plural parts are repeated', () {
const String pluralMessageWithoutPlaceholdersAttribute = ''' const String pluralMessageWithOverriddenParts = '''
{ {
"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}}", "helloWorlds": "{count,plural, =0{Hello}zero{hello} other{hi}}",
"@helloWorlds": { "@helloWorlds": {
"description": "Improperly formatted since it has no placeholder attribute." "description": "Properly formatted but has redundant zero cases."
} }
}'''; }''';
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n') final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true); ..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName) l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(pluralMessageWithoutPlaceholdersAttribute); .writeAsStringSync(pluralMessageWithOverriddenParts);
LocalizationsGenerator(
expect( fileSystem: fs,
() { inputPathString: defaultL10nPathString,
LocalizationsGenerator( outputPathString: defaultL10nPathString,
fileSystem: fs, templateArbFileName: defaultTemplateArbFileName,
inputPathString: defaultL10nPathString, outputFileString: defaultOutputFileString,
outputPathString: defaultL10nPathString, classNameString: defaultClassNameString,
templateArbFileName: defaultTemplateArbFileName, logger: logger,
outputFileString: defaultOutputFileString, )
classNameString: defaultClassNameString, ..loadResources()
logger: logger, ..writeOutputFiles();
) expect(logger.hadWarningOutput, isTrue);
..loadResources() expect(logger.warningText, contains('''
..writeOutputFiles(); [app_en.arb:helloWorlds] ICU Syntax Warning: The plural part specified below is overridden by a later plural part.
}, {count,plural, =0{Hello}zero{hello} other{hi}}
throwsA(isA<L10nException>().having( ^'''));
(L10nException e) => e.message,
'message',
contains('''
Make sure that the specified plural placeholder is defined in your arb file.
[app_en.arb: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}}
^'''),
)),
);
}); });
testWithoutContext('should throw attempting to generate a plural message with an empty placeholders map', () { testWithoutContext('should automatically infer plural placeholders that are not explicitly defined', () {
const String pluralMessageWithEmptyPlaceholdersMap = ''' const String pluralMessageWithoutPlaceholdersAttribute = '''
{ {
"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}}", "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}}",
"@helloWorlds": { "@helloWorlds": {
"description": "Improperly formatted since it has no placeholder attribute.", "description": "Improperly formatted since it has no placeholder attribute."
"placeholders": {}
} }
}'''; }''';
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n') final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true); ..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName) l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(pluralMessageWithEmptyPlaceholdersMap); .writeAsStringSync(pluralMessageWithoutPlaceholdersAttribute);
LocalizationsGenerator(
expect( fileSystem: fs,
() { inputPathString: defaultL10nPathString,
LocalizationsGenerator( outputPathString: defaultL10nPathString,
fileSystem: fs, templateArbFileName: defaultTemplateArbFileName,
inputPathString: defaultL10nPathString, outputFileString: defaultOutputFileString,
outputPathString: defaultL10nPathString, classNameString: defaultClassNameString,
templateArbFileName: defaultTemplateArbFileName, logger: logger,
outputFileString: defaultOutputFileString, )
classNameString: defaultClassNameString, ..loadResources()
logger: logger, ..writeOutputFiles();
) final String localizationsFile = fs.file(
..loadResources() fs.path.join(syntheticL10nPackagePath, 'output-localization-file_en.dart'),
..writeOutputFiles(); ).readAsStringSync();
}, expect(localizationsFile, contains('String helloWorlds(num count) {'));
throwsA(isA<L10nException>().having(
(L10nException e) => e.message,
'message',
contains('''
Make sure that the specified plural placeholder is defined in your arb file.
[app_en.arb: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}}
^'''),
)),
);
});
testWithoutContext('should throw attempting to generate a plural message with no resource attributes', () {
const String pluralMessageWithoutResourceAttributes = '''
{
"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}}"
}''';
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(pluralMessageWithoutResourceAttributes);
expect(
() {
LocalizationsGenerator(
fileSystem: fs,
inputPathString: defaultL10nPathString,
outputPathString: defaultL10nPathString,
templateArbFileName: defaultTemplateArbFileName,
outputFileString: defaultOutputFileString,
classNameString: defaultClassNameString,
logger: logger,
)
..loadResources()
..writeOutputFiles();
},
throwsA(isA<L10nException>().having(
(L10nException e) => e.message,
'message',
contains('''
Make sure that the specified plural placeholder is defined in your arb file.
[app_en.arb: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}}
^'''),
)),
);
}); });
testWithoutContext('should throw attempting to generate a plural message with incorrect format for placeholders', () { testWithoutContext('should throw attempting to generate a plural message with incorrect format for placeholders', () {
...@@ -2065,7 +2028,7 @@ Make sure that the specified plural placeholder is defined in your arb file. ...@@ -2065,7 +2028,7 @@ Make sure that the specified plural placeholder is defined in your arb file.
}); });
group('select messages', () { group('select messages', () {
testWithoutContext('should throw attempting to generate a select message without placeholders', () { testWithoutContext('should auotmatically infer select placeholders that are not explicitly defined', () {
const String selectMessageWithoutPlaceholdersAttribute = ''' const String selectMessageWithoutPlaceholdersAttribute = '''
{ {
"genderSelect": "{gender, select, female {She} male {He} other {they} }", "genderSelect": "{gender, select, female {She} male {He} other {they} }",
...@@ -2078,106 +2041,21 @@ Make sure that the specified plural placeholder is defined in your arb file. ...@@ -2078,106 +2041,21 @@ Make sure that the specified plural placeholder is defined in your arb file.
..createSync(recursive: true); ..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName) l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(selectMessageWithoutPlaceholdersAttribute); .writeAsStringSync(selectMessageWithoutPlaceholdersAttribute);
LocalizationsGenerator(
expect( fileSystem: fs,
() { inputPathString: defaultL10nPathString,
LocalizationsGenerator( outputPathString: defaultL10nPathString,
fileSystem: fs, templateArbFileName: defaultTemplateArbFileName,
inputPathString: defaultL10nPathString, outputFileString: defaultOutputFileString,
outputPathString: defaultL10nPathString, classNameString: defaultClassNameString,
templateArbFileName: defaultTemplateArbFileName, logger: logger,
outputFileString: defaultOutputFileString, )
classNameString: defaultClassNameString, ..loadResources()
logger: logger, ..writeOutputFiles();
) final String localizationsFile = fs.file(
..loadResources() fs.path.join(syntheticL10nPackagePath, 'output-localization-file_en.dart'),
..writeOutputFiles(); ).readAsStringSync();
}, expect(localizationsFile, contains('String genderSelect(String gender) {'));
throwsA(isA<L10nException>().having(
(L10nException e) => e.message,
'message',
contains('''
Make sure that the specified select placeholder is defined in your arb file.
[app_en.arb:genderSelect] {gender, select, female {She} male {He} other {they} }
^'''),
)),
);
});
testWithoutContext('should throw attempting to generate a select message with an empty placeholders map', () {
const String selectMessageWithEmptyPlaceholdersMap = '''
{
"genderSelect": "{gender, select, female {She} male {He} other {they} }",
"@genderSelect": {
"description": "Improperly formatted since it has no placeholder attribute.",
"placeholders": {}
}
}''';
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(selectMessageWithEmptyPlaceholdersMap);
expect(
() {
LocalizationsGenerator(
fileSystem: fs,
inputPathString: defaultL10nPathString,
outputPathString: defaultL10nPathString,
templateArbFileName: defaultTemplateArbFileName,
outputFileString: defaultOutputFileString,
classNameString: defaultClassNameString,
logger: logger,
)
..loadResources()
..writeOutputFiles();
},
throwsA(isA<L10nException>().having(
(L10nException e) => e.message,
'message',
contains('''
Make sure that the specified select placeholder is defined in your arb file.
[app_en.arb:genderSelect] {gender, select, female {She} male {He} other {they} }
^'''),
)),
);
});
testWithoutContext('should throw attempting to generate a select message with no resource attributes', () {
const String selectMessageWithoutResourceAttributes = '''
{
"genderSelect": "{gender, select, female {She} male {He} other {they} }"
}''';
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(selectMessageWithoutResourceAttributes);
expect(
() {
LocalizationsGenerator(
fileSystem: fs,
inputPathString: defaultL10nPathString,
outputPathString: defaultL10nPathString,
templateArbFileName: defaultTemplateArbFileName,
outputFileString: defaultOutputFileString,
classNameString: defaultClassNameString,
logger: logger,
)
..loadResources()
..writeOutputFiles();
},
throwsA(isA<L10nException>().having(
(L10nException e) => e.message,
'message',
contains('''
Make sure that the specified select placeholder is defined in your arb file.
[app_en.arb:genderSelect] {gender, select, female {She} male {He} other {they} }
^'''),
)),
);
}); });
testWithoutContext('should throw attempting to generate a select message with incorrect format for placeholders', () { testWithoutContext('should throw attempting to generate a select message with incorrect format for placeholders', () {
...@@ -2234,30 +2112,25 @@ Make sure that the specified select placeholder is defined in your arb file. ...@@ -2234,30 +2112,25 @@ Make sure that the specified select placeholder is defined in your arb file.
..createSync(recursive: true); ..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName) l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(selectMessageWithoutPlaceholdersAttribute); .writeAsStringSync(selectMessageWithoutPlaceholdersAttribute);
try {
expect( LocalizationsGenerator(
() { fileSystem: fs,
LocalizationsGenerator( inputPathString: defaultL10nPathString,
fileSystem: fs, outputPathString: defaultL10nPathString,
inputPathString: defaultL10nPathString, templateArbFileName: defaultTemplateArbFileName,
outputPathString: defaultL10nPathString, outputFileString: defaultOutputFileString,
templateArbFileName: defaultTemplateArbFileName, classNameString: defaultClassNameString,
outputFileString: defaultOutputFileString, logger: logger,
classNameString: defaultClassNameString, )
logger: logger, ..loadResources()
) ..writeOutputFiles();
..loadResources() } on L10nException {
..writeOutputFiles(); expect(logger.errorText, contains('''
}, [app_en.arb:genderSelect] ICU Syntax Error: Select expressions must have an "other" case.
throwsA(isA<L10nException>().having( {gender, select,}
(L10nException e) => e.message, ^''')
'message', );
contains(''' }
Select expressions must have an "other" case.
[app_en.arb:genderSelect] {gender, select,}
^'''),
)),
);
}); });
}); });
...@@ -2984,37 +2857,66 @@ AppLocalizations lookupAppLocalizations(Locale locale) { ...@@ -2984,37 +2857,66 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
''')); '''));
}); });
// TODO(thkim1011): Uncomment when implementing escaping. testWithoutContext('escaping with single quotes', () {
// See https://github.com/flutter/flutter/issues/113455. const String arbFile = '''
// testWithoutContext('escaping with single quotes', () { {
// const String arbFile = ''' "singleQuote": "Flutter''s amazing!",
// { "@singleQuote": {
// "singleQuote": "Flutter''s amazing!", "description": "A message with a single quote."
// "@singleQuote": { }
// "description": "A message with a single quote." }''';
// }
// }'''; final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true);
// final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n') l10nDirectory.childFile(defaultTemplateArbFileName)
// ..createSync(recursive: true); .writeAsStringSync(arbFile);
// l10nDirectory.childFile(defaultTemplateArbFileName)
// .writeAsStringSync(arbFile); LocalizationsGenerator(
fileSystem: fs,
// LocalizationsGenerator( inputPathString: defaultL10nPathString,
// fileSystem: fs, outputPathString: defaultL10nPathString,
// inputPathString: defaultL10nPathString, templateArbFileName: defaultTemplateArbFileName,
// outputPathString: defaultL10nPathString, outputFileString: defaultOutputFileString,
// templateArbFileName: defaultTemplateArbFileName, classNameString: defaultClassNameString,
// outputFileString: defaultOutputFileString, logger: logger,
// classNameString: defaultClassNameString, useEscaping: true,
// logger: logger, )
// ) ..loadResources()
// ..loadResources() ..writeOutputFiles();
// ..writeOutputFiles();
final String localizationsFile = fs.file(
// final String localizationsFile = fs.file( fs.path.join(syntheticL10nPackagePath, 'output-localization-file_en.dart'),
// fs.path.join(syntheticL10nPackagePath, 'output-localization-file_en.dart'), ).readAsStringSync();
// ).readAsStringSync(); expect(localizationsFile, contains(r"Flutter\'s amazing"));
// expect(localizationsFile, contains(r"Flutter\'s amazing")); });
// });
testWithoutContext('suppress warnings flag actually suppresses warnings', () {
const String pluralMessageWithOverriddenParts = '''
{
"helloWorlds": "{count,plural, =0{Hello}zero{hello} other{hi}}",
"@helloWorlds": {
"description": "Properly formatted but has redundant zero cases.",
"placeholders": {
"count": {}
}
}
}''';
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(pluralMessageWithOverriddenParts);
LocalizationsGenerator(
fileSystem: fs,
inputPathString: defaultL10nPathString,
outputPathString: defaultL10nPathString,
templateArbFileName: defaultTemplateArbFileName,
outputFileString: defaultOutputFileString,
classNameString: defaultClassNameString,
logger: logger,
suppressWarnings: true,
)
..loadResources()
..writeOutputFiles();
expect(logger.hadWarningOutput, isFalse);
});
} }
...@@ -218,6 +218,14 @@ void main() { ...@@ -218,6 +218,14 @@ void main() {
])); ]));
}); });
testWithoutContext('lexer identifier names can be "select" or "plural"', () {
final List<Node> tokens = Parser('keywords', 'app_en.arb', '{ select } { plural, select, singular{test} other{hmm} }').lexIntoTokens();
expect(tokens[1].value, equals('select'));
expect(tokens[1].type, equals(ST.identifier));
expect(tokens[5].value, equals('plural'));
expect(tokens[5].type, equals(ST.identifier));
});
testWithoutContext('lexer: lexically correct but syntactically incorrect', () { testWithoutContext('lexer: lexically correct but syntactically incorrect', () {
final List<Node> tokens = Parser( final List<Node> tokens = Parser(
'syntax', 'syntax',
...@@ -242,9 +250,9 @@ void main() { ...@@ -242,9 +250,9 @@ void main() {
testWithoutContext('lexer unmatched single quote', () { testWithoutContext('lexer unmatched single quote', () {
const String message = "here''s an unmatched single quote: '"; const String message = "here''s an unmatched single quote: '";
const String expectedError = ''' const String expectedError = '''
ICU Lexing Error: Unmatched single quotes. [app_en.arb:escaping] ICU Lexing Error: Unmatched single quotes.
[app_en.arb:escaping] here''s an unmatched single quote: ' here''s an unmatched single quote: '
^'''; ^''';
expect( expect(
() => Parser('escaping', 'app_en.arb', message, useEscaping: true).lexIntoTokens(), () => Parser('escaping', 'app_en.arb', message, useEscaping: true).lexIntoTokens(),
throwsA(isA<L10nException>().having( throwsA(isA<L10nException>().having(
...@@ -257,9 +265,9 @@ ICU Lexing Error: Unmatched single quotes. ...@@ -257,9 +265,9 @@ ICU Lexing Error: Unmatched single quotes.
testWithoutContext('lexer unexpected character', () { testWithoutContext('lexer unexpected character', () {
const String message = '{ * }'; const String message = '{ * }';
const String expectedError = ''' const String expectedError = '''
ICU Lexing Error: Unexpected character. [app_en.arb:lex] ICU Lexing Error: Unexpected character.
[app_en.arb:lex] { * } { * }
^'''; ^''';
expect( expect(
() => Parser('lex', 'app_en.arb', message).lexIntoTokens(), () => Parser('lex', 'app_en.arb', message).lexIntoTokens(),
throwsA(isA<L10nException>().having( throwsA(isA<L10nException>().having(
...@@ -460,11 +468,11 @@ ICU Lexing Error: Unexpected character. ...@@ -460,11 +468,11 @@ ICU Lexing Error: Unexpected character.
testWithoutContext('parser unexpected token', () { testWithoutContext('parser unexpected token', () {
// unexpected token // unexpected token
const String expectedError1 = ''' const String expectedError1 = '''
ICU Syntax Error: Expected "}" but found "=". [app_en.arb:unexpectedToken] ICU Syntax Error: Expected "}" but found "=".
[app_en.arb:unexpectedToken] { placeholder = { placeholder =
^'''; ^''';
expect( expect(
() => Parser('unexpectedToken', 'app_en.arb', '{ placeholder =').parse(), () => Parser('unexpectedToken', 'app_en.arb', '{ placeholder =').parseIntoTree(),
throwsA(isA<L10nException>().having( throwsA(isA<L10nException>().having(
(L10nException e) => e.message, (L10nException e) => e.message,
'message', 'message',
...@@ -472,11 +480,11 @@ ICU Syntax Error: Expected "}" but found "=". ...@@ -472,11 +480,11 @@ ICU Syntax Error: Expected "}" but found "=".
))); )));
const String expectedError2 = ''' const String expectedError2 = '''
ICU Syntax Error: Expected "number" but found "}". [app_en.arb:unexpectedToken] ICU Syntax Error: Expected "number" but found "}".
[app_en.arb:unexpectedToken] { count, plural, = } { count, plural, = }
^'''; ^''';
expect( expect(
() => Parser('unexpectedToken', 'app_en.arb', '{ count, plural, = }').parse(), () => Parser('unexpectedToken', 'app_en.arb', '{ count, plural, = }').parseIntoTree(),
throwsA(isA<L10nException>().having( throwsA(isA<L10nException>().having(
(L10nException e) => e.message, (L10nException e) => e.message,
'message', 'message',
...@@ -484,11 +492,11 @@ ICU Syntax Error: Expected "number" but found "}". ...@@ -484,11 +492,11 @@ ICU Syntax Error: Expected "number" but found "}".
))); )));
const String expectedError3 = ''' const String expectedError3 = '''
ICU Syntax Error: Expected "identifier" but found ",". [app_en.arb:unexpectedToken] ICU Syntax Error: Expected "identifier" but found ",".
[app_en.arb:unexpectedToken] { , plural , = } { , plural , = }
^'''; ^''';
expect( expect(
() => Parser('unexpectedToken', 'app_en.arb', '{ , plural , = }').parse(), () => Parser('unexpectedToken', 'app_en.arb', '{ , plural , = }').parseIntoTree(),
throwsA(isA<L10nException>().having( throwsA(isA<L10nException>().having(
(L10nException e) => e.message, (L10nException e) => e.message,
'message', 'message',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment