To preserve backward compatibility with the old parser which would
ignore syntax errors, this PR introduces a way to treat the special
characters `{` and `}` in the following way:
1. If we encounter a `{` which searching for a string token and this `{`
is not followed by a valid placeholder, then we treat the `{` as a
string and continue lexing for strings.
2. If we encounter a `}` while not within some expression (i.e.
placeholders, arguments, plurals, or selects), then we treat the `}` as
a string and continue lexing for strings.
This makes it so that
```
"helloWorld": "{ } { placeholder }",
"@@helloWorld": {
"placeholders": {
"placeholder" {}
}
}
```
treats the `{ }` as a string while `{ placeholder } ` is treated as a
placeholder.
Fixes https://github.com/flutter/flutter/issues/122404.
648 lines
23 KiB
Dart
648 lines
23 KiB
Dart
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
// The whole design for the lexing and parsing step can be found in this design doc.
|
|
// See https://flutter.dev/go/icu-message-parser.
|
|
|
|
// Symbol Types
|
|
import '../base/logger.dart';
|
|
import 'gen_l10n_types.dart';
|
|
|
|
enum ST {
|
|
// Terminal Types
|
|
openBrace,
|
|
closeBrace,
|
|
comma,
|
|
equalSign,
|
|
other,
|
|
plural,
|
|
select,
|
|
string,
|
|
number,
|
|
identifier,
|
|
empty,
|
|
colon,
|
|
date,
|
|
time,
|
|
// Nonterminal Types
|
|
message,
|
|
|
|
placeholderExpr,
|
|
|
|
argumentExpr,
|
|
|
|
pluralExpr,
|
|
pluralParts,
|
|
pluralPart,
|
|
|
|
selectExpr,
|
|
selectParts,
|
|
selectPart,
|
|
|
|
argType,
|
|
}
|
|
|
|
// The grammar of the syntax.
|
|
Map<ST, List<List<ST>>> grammar = <ST, List<List<ST>>>{
|
|
ST.message: <List<ST>>[
|
|
<ST>[ST.string, ST.message],
|
|
<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>>[
|
|
<ST>[ST.openBrace, ST.identifier, ST.closeBrace],
|
|
],
|
|
ST.pluralExpr: <List<ST>>[
|
|
<ST>[ST.openBrace, ST.identifier, ST.comma, ST.plural, ST.comma, ST.pluralParts, ST.closeBrace],
|
|
],
|
|
ST.pluralParts: <List<ST>>[
|
|
<ST>[ST.pluralPart, ST.pluralParts],
|
|
<ST>[ST.empty],
|
|
],
|
|
ST.pluralPart: <List<ST>>[
|
|
<ST>[ST.identifier, ST.openBrace, ST.message, ST.closeBrace],
|
|
<ST>[ST.equalSign, ST.number, ST.openBrace, ST.message, ST.closeBrace],
|
|
<ST>[ST.other, ST.openBrace, ST.message, ST.closeBrace],
|
|
],
|
|
ST.selectExpr: <List<ST>>[
|
|
<ST>[ST.openBrace, ST.identifier, ST.comma, ST.select, ST.comma, ST.selectParts, ST.closeBrace],
|
|
<ST>[ST.other, ST.openBrace, ST.message, ST.closeBrace],
|
|
],
|
|
ST.selectParts: <List<ST>>[
|
|
<ST>[ST.selectPart, ST.selectParts],
|
|
<ST>[ST.empty],
|
|
],
|
|
ST.selectPart: <List<ST>>[
|
|
<ST>[ST.identifier, ST.openBrace, ST.message, ST.closeBrace],
|
|
<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 {
|
|
Node(this.type, this.positionInMessage, { this.expectedSymbolCount = 0, this.value, List<Node>? children }): children = children ?? <Node>[];
|
|
|
|
// Token constructors.
|
|
Node.openBrace(this.positionInMessage): type = ST.openBrace, value = '{';
|
|
Node.closeBrace(this.positionInMessage): type = ST.closeBrace, value = '}';
|
|
Node.brace(this.positionInMessage, String this.value) {
|
|
if (value == '{') {
|
|
type = ST.openBrace;
|
|
} else if (value == '}') {
|
|
type = ST.closeBrace;
|
|
} else {
|
|
// We should never arrive here.
|
|
throw L10nException('Provided value $value is not a brace.');
|
|
}
|
|
}
|
|
Node.equalSign(this.positionInMessage): type = ST.equalSign, value = '=';
|
|
Node.comma(this.positionInMessage): type = ST.comma, value = ',';
|
|
Node.string(this.positionInMessage, String this.value): type = ST.string;
|
|
Node.number(this.positionInMessage, String this.value): type = ST.number;
|
|
Node.identifier(this.positionInMessage, String this.value): type = ST.identifier;
|
|
Node.pluralKeyword(this.positionInMessage): type = ST.plural, value = 'plural';
|
|
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;
|
|
List<Node> children = <Node>[];
|
|
int positionInMessage;
|
|
int expectedSymbolCount = 0;
|
|
|
|
@override
|
|
String toString() {
|
|
return _toStringHelper(0);
|
|
}
|
|
|
|
String _toStringHelper(int indentLevel) {
|
|
final String indent = List<String>.filled(indentLevel, ' ').join();
|
|
if (children.isEmpty) {
|
|
return '''
|
|
${indent}Node($type, $positionInMessage${value == null ? '' : ", value: '$value'"})''';
|
|
}
|
|
final String childrenString = children.map((Node child) => child._toStringHelper(indentLevel + 1)).join(',\n');
|
|
return '''
|
|
${indent}Node($type, $positionInMessage${value == null ? '' : ", value: '$value'"}, children: <Node>[
|
|
$childrenString,
|
|
$indent])''';
|
|
}
|
|
|
|
// Only used for testing. We don't compare expectedSymbolCount because
|
|
// it is an auxiliary member used during the parse function but doesn't
|
|
// have meaning after calling compress.
|
|
@override
|
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes, hash_and_equals
|
|
bool operator==(covariant Node other) {
|
|
if (value != other.value
|
|
|| type != other.type
|
|
|| positionInMessage != other.positionInMessage
|
|
|| children.length != other.children.length
|
|
) {
|
|
return false;
|
|
}
|
|
for (int i = 0; i < children.length; i++) {
|
|
if (children[i] != other.children[i]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool get isFull {
|
|
return children.length >= expectedSymbolCount;
|
|
}
|
|
}
|
|
|
|
RegExp escapedString = RegExp(r"'[^']*'");
|
|
RegExp unescapedString = RegExp(r"[^{}']+");
|
|
RegExp normalString = RegExp(r'[^{}]+');
|
|
|
|
RegExp brace = RegExp(r'{|}');
|
|
|
|
RegExp whitespace = RegExp(r'\s+');
|
|
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,
|
|
};
|
|
|
|
class Parser {
|
|
Parser(
|
|
this.messageId,
|
|
this.filename,
|
|
this.messageString,
|
|
{
|
|
this.useEscaping = false,
|
|
this.logger,
|
|
this.placeholders,
|
|
}
|
|
);
|
|
|
|
final String messageId;
|
|
final String messageString;
|
|
final String filename;
|
|
final bool useEscaping;
|
|
final Logger? logger;
|
|
final List<String>? placeholders;
|
|
|
|
static String indentForError(int position) {
|
|
return '${List<String>.filled(position, ' ').join()}^';
|
|
}
|
|
|
|
// Lexes the message into a list of typed tokens. General idea is that
|
|
// every instance of "{" and "}" toggles the isString boolean and every
|
|
// instance of "'" toggles the isEscaped boolean (and treats a double
|
|
// single quote "''" as a single quote "'"). When !isString and !isEscaped
|
|
// delimit tokens by whitespace and special characters. When placeholders
|
|
// is passed, relax the syntax so that "{" and "}" can be used as strings in
|
|
// certain cases.
|
|
List<Node> lexIntoTokens() {
|
|
final bool useRelaxedLexer = placeholders != null;
|
|
final List<Node> tokens = <Node>[];
|
|
bool isString = true;
|
|
// Index specifying where to match from
|
|
int startIndex = 0;
|
|
int depth = 0;
|
|
|
|
// At every iteration, we should be able to match a new token until we
|
|
// reach the end of the string. If for some reason we don't match a
|
|
// token in any iteration of the loop, throw an error.
|
|
while (startIndex < messageString.length) {
|
|
Match? match;
|
|
if (isString) {
|
|
if (useEscaping) {
|
|
// This case is slightly involved. Essentially, wrapping any syntax in
|
|
// single quotes escapes the syntax except when there are consecutive pair of single
|
|
// quotes. For example, "Hello! 'Flutter''s amazing'. { unescapedPlaceholder }"
|
|
// converts the '' in "Flutter's" to a single quote for convenience, since technically,
|
|
// we should interpret this as two strings 'Flutter' and 's amazing'. To get around this,
|
|
// we also check if the previous character is a ', and if so, add a single quote at the beginning
|
|
// of the token.
|
|
match = escapedString.matchAsPrefix(messageString, startIndex);
|
|
if (match != null) {
|
|
final String string = match.group(0)!;
|
|
if (string == "''") {
|
|
tokens.add(Node.string(startIndex, "'"));
|
|
} else if (startIndex > 1 && messageString[startIndex - 1] == "'") {
|
|
// Include a single quote in the beginning of the token.
|
|
tokens.add(Node.string(startIndex, string.substring(0, string.length - 1)));
|
|
} else {
|
|
tokens.add(Node.string(startIndex, string.substring(1, string.length - 1)));
|
|
}
|
|
startIndex = match.end;
|
|
continue;
|
|
}
|
|
match = unescapedString.matchAsPrefix(messageString, startIndex);
|
|
if (match != null) {
|
|
tokens.add(Node.string(startIndex, match.group(0)!));
|
|
startIndex = match.end;
|
|
continue;
|
|
}
|
|
} else {
|
|
match = normalString.matchAsPrefix(messageString, startIndex);
|
|
if (match != null) {
|
|
tokens.add(Node.string(startIndex, match.group(0)!));
|
|
startIndex = match.end;
|
|
continue;
|
|
}
|
|
}
|
|
match = brace.matchAsPrefix(messageString, startIndex);
|
|
if (match != null) {
|
|
final String matchedBrace = match.group(0)!;
|
|
if (useRelaxedLexer) {
|
|
final Match? whitespaceMatch = whitespace.matchAsPrefix(messageString, match.end);
|
|
final int endOfWhitespace = whitespaceMatch?.group(0) == null ? match.end : whitespaceMatch!.end;
|
|
final Match? identifierMatch = alphanumeric.matchAsPrefix(messageString, endOfWhitespace);
|
|
// If we match a "}" and the depth is 0, treat it as a string.
|
|
// If we match a "{" and the next token is not a valid placeholder, treat it as a string.
|
|
if (matchedBrace == '}' && depth == 0) {
|
|
tokens.add(Node.string(startIndex, matchedBrace));
|
|
startIndex = match.end;
|
|
continue;
|
|
}
|
|
if (matchedBrace == '{' && (identifierMatch == null || !placeholders!.contains(identifierMatch.group(0)))) {
|
|
tokens.add(Node.string(startIndex, matchedBrace));
|
|
startIndex = match.end;
|
|
continue;
|
|
}
|
|
}
|
|
tokens.add(Node.brace(startIndex, match.group(0)!));
|
|
isString = false;
|
|
startIndex = match.end;
|
|
depth += 1;
|
|
continue;
|
|
}
|
|
// Theoretically, we only reach this point because of unmatched single quotes because
|
|
// 1. If it begins with single quotes, then we match the longest string contained in single quotes.
|
|
// 2. If it begins with braces, then we match those braces.
|
|
// 3. Else the first character is neither single quote or brace so it is matched by RegExp "unescapedString"
|
|
throw L10nParserException(
|
|
'ICU Lexing Error: Unmatched single quotes.',
|
|
filename,
|
|
messageId,
|
|
messageString,
|
|
startIndex,
|
|
);
|
|
} else {
|
|
RegExp matcher;
|
|
ST? matchedType;
|
|
|
|
// Try to match tokens until we succeed
|
|
for (matchedType in matchers.keys) {
|
|
matcher = matchers[matchedType]!;
|
|
match = matcher.matchAsPrefix(messageString, startIndex);
|
|
if (match != null) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (match == null) {
|
|
match = brace.matchAsPrefix(messageString, startIndex);
|
|
if (match != null) {
|
|
final String matchedBrace = match.group(0)!;
|
|
tokens.add(Node.brace(startIndex, matchedBrace));
|
|
isString = true;
|
|
startIndex = match.end;
|
|
if (matchedBrace == '{') {
|
|
depth += 1;
|
|
} else {
|
|
depth -= 1;
|
|
}
|
|
continue;
|
|
}
|
|
// This should only happen when there are special characters we are unable to match.
|
|
throw L10nParserException(
|
|
'ICU Lexing Error: Unexpected character.',
|
|
filename,
|
|
messageId,
|
|
messageString,
|
|
startIndex
|
|
);
|
|
} else if (matchedType == ST.empty) {
|
|
// Do not add whitespace as a token.
|
|
startIndex = match.end;
|
|
continue;
|
|
} else if (<ST>[ST.identifier].contains(matchedType) && tokens.last.type == ST.openBrace) {
|
|
// Treat any token as identifier if it comes right after an open brace, whether it's a keyword or not.
|
|
tokens.add(Node(ST.identifier, startIndex, value: match.group(0)));
|
|
startIndex = match.end;
|
|
continue;
|
|
} else {
|
|
// Handle keywords separately. Otherwise, lexer will assume parts of identifiers may be keywords.
|
|
final String tokenStr = match.group(0)!;
|
|
switch (tokenStr) {
|
|
case 'plural':
|
|
matchedType = ST.plural;
|
|
case 'select':
|
|
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;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
Node parseIntoTree() {
|
|
final List<Node> tokens = lexIntoTokens();
|
|
final List<ST> parsingStack = <ST>[ST.message];
|
|
final Node syntaxTree = Node(ST.empty, 0, expectedSymbolCount: 1);
|
|
final List<Node> treeTraversalStack = <Node>[syntaxTree];
|
|
|
|
// Helper function for parsing and constructing tree.
|
|
void parseAndConstructNode(ST nonterminal, int ruleIndex) {
|
|
final Node parent = treeTraversalStack.last;
|
|
final List<ST> grammarRule = grammar[nonterminal]![ruleIndex];
|
|
|
|
// When we run out of tokens, just use -1 to represent the last index.
|
|
final int positionInMessage = tokens.isNotEmpty ? tokens.first.positionInMessage : -1;
|
|
final Node node = Node(nonterminal, positionInMessage, expectedSymbolCount: grammarRule.length);
|
|
parsingStack.addAll(grammarRule.reversed);
|
|
|
|
// For tree construction, add nodes to the parent until the parent has all
|
|
// the children it is expecting.
|
|
parent.children.add(node);
|
|
if (parent.isFull) {
|
|
treeTraversalStack.removeLast();
|
|
}
|
|
treeTraversalStack.add(node);
|
|
}
|
|
|
|
while (parsingStack.isNotEmpty) {
|
|
final ST symbol = parsingStack.removeLast();
|
|
|
|
// Figure out which production rule to use.
|
|
switch (symbol) {
|
|
case ST.message:
|
|
if (tokens.isEmpty) {
|
|
parseAndConstructNode(ST.message, 5);
|
|
} else if (tokens[0].type == ST.closeBrace) {
|
|
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);
|
|
}
|
|
} else {
|
|
// Theoretically, we can never get here.
|
|
throw L10nException('ICU Syntax Error.');
|
|
}
|
|
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:
|
|
if (tokens.isNotEmpty && (
|
|
tokens[0].type == ST.identifier ||
|
|
tokens[0].type == ST.other ||
|
|
tokens[0].type == ST.equalSign
|
|
)
|
|
) {
|
|
parseAndConstructNode(ST.pluralParts, 0);
|
|
} else {
|
|
parseAndConstructNode(ST.pluralParts, 1);
|
|
}
|
|
case ST.pluralPart:
|
|
if (tokens.isNotEmpty && tokens[0].type == ST.identifier) {
|
|
parseAndConstructNode(ST.pluralPart, 0);
|
|
} else if (tokens.isNotEmpty && tokens[0].type == ST.equalSign) {
|
|
parseAndConstructNode(ST.pluralPart, 1);
|
|
} else if (tokens.isNotEmpty && tokens[0].type == ST.other) {
|
|
parseAndConstructNode(ST.pluralPart, 2);
|
|
} else {
|
|
throw L10nParserException(
|
|
'ICU Syntax Error: Plural parts must be of the form "identifier { message }" or "= number { message }"',
|
|
filename,
|
|
messageId,
|
|
messageString,
|
|
tokens[0].positionInMessage,
|
|
);
|
|
}
|
|
case ST.selectExpr:
|
|
parseAndConstructNode(ST.selectExpr, 0);
|
|
case ST.selectParts:
|
|
if (tokens.isNotEmpty && (
|
|
tokens[0].type == ST.identifier ||
|
|
tokens[0].type == ST.number ||
|
|
tokens[0].type == ST.other
|
|
)) {
|
|
parseAndConstructNode(ST.selectParts, 0);
|
|
} else {
|
|
parseAndConstructNode(ST.selectParts, 1);
|
|
}
|
|
case ST.selectPart:
|
|
if (tokens.isNotEmpty && tokens[0].type == ST.identifier) {
|
|
parseAndConstructNode(ST.selectPart, 0);
|
|
} else if (tokens.isNotEmpty && tokens[0].type == ST.number) {
|
|
parseAndConstructNode(ST.selectPart, 1);
|
|
} else if (tokens.isNotEmpty && tokens[0].type == ST.other) {
|
|
parseAndConstructNode(ST.selectPart, 2);
|
|
} else {
|
|
throw L10nParserException(
|
|
'ICU Syntax Error: Select parts must be of the form "identifier { message }"',
|
|
filename,
|
|
messageId,
|
|
messageString,
|
|
tokens[0].positionInMessage
|
|
);
|
|
}
|
|
// At this point, we are only handling terminal symbols.
|
|
// ignore: no_default_cases
|
|
default:
|
|
final Node parent = treeTraversalStack.last;
|
|
// If we match a terminal symbol, then remove it from tokens and
|
|
// add it to the tree.
|
|
if (symbol == ST.empty) {
|
|
parent.children.add(Node.empty(-1));
|
|
} else if (tokens.isEmpty) {
|
|
throw L10nParserException(
|
|
'ICU Syntax Error: Expected "${terminalTypeToString[symbol]}" but found no tokens.',
|
|
filename,
|
|
messageId,
|
|
messageString,
|
|
messageString.length + 1,
|
|
);
|
|
} else if (symbol == tokens[0].type) {
|
|
final Node token = tokens.removeAt(0);
|
|
parent.children.add(token);
|
|
} else {
|
|
throw L10nParserException(
|
|
'ICU Syntax Error: Expected "${terminalTypeToString[symbol]}" but found "${tokens[0].value}".',
|
|
filename,
|
|
messageId,
|
|
messageString,
|
|
tokens[0].positionInMessage,
|
|
);
|
|
}
|
|
|
|
if (parent.isFull) {
|
|
treeTraversalStack.removeLast();
|
|
}
|
|
}
|
|
}
|
|
|
|
return syntaxTree.children[0];
|
|
}
|
|
|
|
final Map<ST, String> terminalTypeToString = <ST, String>{
|
|
ST.openBrace: '{',
|
|
ST.closeBrace: '}',
|
|
ST.comma: ',',
|
|
ST.empty: '',
|
|
ST.identifier: 'identifier',
|
|
ST.number: 'number',
|
|
ST.plural: 'plural',
|
|
ST.select: 'select',
|
|
ST.equalSign: '=',
|
|
ST.other: 'other',
|
|
};
|
|
|
|
// Compress the syntax tree.
|
|
//
|
|
// After `parse(lex(message))`, the individual parts (`ST.string`,
|
|
// `ST.placeholderExpr`, `ST.pluralExpr`, and `ST.selectExpr`) are structured
|
|
// as a linked list (see diagram below). This function compresses these parts
|
|
// into a single children array (and does this for `ST.pluralParts` and
|
|
// `ST.selectParts` as well). Then it checks extra syntax rules. Essentially, it
|
|
// converts:
|
|
//
|
|
// Message
|
|
// / \
|
|
// PluralExpr Message
|
|
// / \
|
|
// String Message
|
|
// / \
|
|
// SelectExpr ...
|
|
//
|
|
// ...to:
|
|
//
|
|
// Message
|
|
// / | \
|
|
// PluralExpr String SelectExpr ...
|
|
//
|
|
// Keep in mind that this modifies the tree in place and the values of
|
|
// expectedSymbolCount and isFull is no longer useful after this operation.
|
|
Node compress(Node syntaxTree) {
|
|
Node node = syntaxTree;
|
|
final List<Node> children = <Node>[];
|
|
switch (syntaxTree.type) {
|
|
case ST.message:
|
|
case ST.pluralParts:
|
|
case ST.selectParts:
|
|
while (node.children.length == 2) {
|
|
children.add(node.children[0]);
|
|
compress(node.children[0]);
|
|
node = node.children[1];
|
|
}
|
|
syntaxTree.children = children;
|
|
// ignore: no_default_cases
|
|
default:
|
|
node.children.forEach(compress);
|
|
}
|
|
return syntaxTree;
|
|
}
|
|
|
|
// Takes in a compressed syntax tree and checks extra rules on
|
|
// plural parts and select parts.
|
|
void checkExtraRules(Node syntaxTree) {
|
|
final List<Node> children = syntaxTree.children;
|
|
switch (syntaxTree.type) {
|
|
case ST.pluralParts:
|
|
// Must have an "other" case.
|
|
if (children.every((Node node) => node.children[0].type != ST.other)) {
|
|
throw L10nParserException(
|
|
'ICU Syntax Error: Plural expressions must have an "other" case.',
|
|
filename,
|
|
messageId,
|
|
messageString,
|
|
syntaxTree.positionInMessage
|
|
);
|
|
}
|
|
// Identifier must be one of "zero", "one", "two", "few", "many".
|
|
for (final Node node in children) {
|
|
final Node pluralPartFirstToken = node.children[0];
|
|
const List<String> validIdentifiers = <String>['zero', 'one', 'two', 'few', 'many'];
|
|
if (pluralPartFirstToken.type == ST.identifier && !validIdentifiers.contains(pluralPartFirstToken.value)) {
|
|
throw L10nParserException(
|
|
'ICU Syntax Error: Plural expressions case must be one of "zero", "one", "two", "few", "many", or "other".',
|
|
filename,
|
|
messageId,
|
|
messageString,
|
|
node.positionInMessage,
|
|
);
|
|
}
|
|
}
|
|
case ST.selectParts:
|
|
if (children.every((Node node) => node.children[0].type != ST.other)) {
|
|
throw L10nParserException(
|
|
'ICU Syntax Error: Select expressions must have an "other" case.',
|
|
filename,
|
|
messageId,
|
|
messageString,
|
|
syntaxTree.positionInMessage,
|
|
);
|
|
}
|
|
// ignore: no_default_cases
|
|
default:
|
|
break;
|
|
}
|
|
children.forEach(checkExtraRules);
|
|
}
|
|
|
|
Node parse() {
|
|
final Node syntaxTree = compress(parseIntoTree());
|
|
checkExtraRules(syntaxTree);
|
|
return syntaxTree;
|
|
}
|
|
}
|