1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// 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.
import 'dart:convert' show json;
import 'dart:io';
import 'localizations_utils.dart';
// The first suffix in kPluralSuffixes must be "Other". "Other" is special
// because it's the only one that is required.
const List<String> kPluralSuffixes = <String>['Other', 'Zero', 'One', 'Two', 'Few', 'Many'];
final RegExp kPluralRegexp = RegExp(r'(\w*)(' + kPluralSuffixes.skip(1).join(r'|') + r')$');
class ValidationError implements Exception {
ValidationError(this. message);
final String message;
@override
String toString() => message;
}
/// Sanity checking of the @foo metadata in the English translations, *_en.arb.
///
/// - For each foo, resource, there must be a corresponding @foo.
/// - For each @foo resource, there must be a corresponding foo, except
/// for plurals, for which there must be a fooOther.
/// - Each @foo resource must have a Map value with a String valued
/// description entry.
///
/// Throws an exception upon failure.
void validateEnglishLocalizations(File file) {
final StringBuffer errorMessages = StringBuffer();
if (!file.existsSync()) {
errorMessages.writeln('English localizations do not exist: $file');
throw ValidationError(errorMessages.toString());
}
final Map<String, dynamic> bundle = json.decode(file.readAsStringSync()) as Map<String, dynamic>;
for (final String resourceId in bundle.keys) {
if (resourceId.startsWith('@')) {
continue;
}
if (bundle['@$resourceId'] != null) {
continue;
}
bool checkPluralResource(String suffix) {
final int suffixIndex = resourceId.indexOf(suffix);
return suffixIndex != -1 && bundle['@${resourceId.substring(0, suffixIndex)}'] != null;
}
if (kPluralSuffixes.any(checkPluralResource)) {
continue;
}
errorMessages.writeln('A value was not specified for @$resourceId');
}
for (final String atResourceId in bundle.keys) {
if (!atResourceId.startsWith('@')) {
continue;
}
final dynamic atResourceValue = bundle[atResourceId];
final Map<String, dynamic>? atResource =
atResourceValue is Map<String, dynamic> ? atResourceValue : null;
if (atResource == null) {
errorMessages.writeln('A map value was not specified for $atResourceId');
continue;
}
final bool optional = atResource.containsKey('optional');
final String? description = atResource['description'] as String?;
if (description == null && !optional) {
errorMessages.writeln('No description specified for $atResourceId');
}
final String? plural = atResource['plural'] as String?;
final String resourceId = atResourceId.substring(1);
if (plural != null) {
final String resourceIdOther = '${resourceId}Other';
if (!bundle.containsKey(resourceIdOther)) {
errorMessages.writeln('Default plural resource $resourceIdOther undefined');
}
} else {
if (!optional && !bundle.containsKey(resourceId)) {
errorMessages.writeln('No matching $resourceId defined for $atResourceId');
}
}
}
if (errorMessages.isNotEmpty) {
throw ValidationError(errorMessages.toString());
}
}
/// This removes undefined localizations (localizations that aren't present in
/// the canonical locale anymore) by:
///
/// 1. Looking up the canonical (English, in this case) localizations.
/// 2. For each locale, getting the resources.
/// 3. Determining the set of keys that aren't plural variations (we're only
/// interested in the base terms being translated and not their variants)
/// 4. Determining the set of invalid keys; that is those that are (non-plural)
/// keys in the resources for this locale, but which _aren't_ keys in the
/// canonical list.
/// 5. Removes the invalid mappings from this resource's locale.
void removeUndefinedLocalizations(
Map<LocaleInfo, Map<String, String>> localeToResources,
) {
final Map<String, String> canonicalLocalizations = localeToResources[LocaleInfo.fromString('en')]!;
final Set<String> canonicalKeys = Set<String>.from(canonicalLocalizations.keys);
localeToResources.forEach((LocaleInfo locale, Map<String, String> resources) {
bool isPluralVariation(String key) {
final Match? pluralMatch = kPluralRegexp.firstMatch(key);
if (pluralMatch == null) {
return false;
}
final String? prefix = pluralMatch[1];
return resources.containsKey('${prefix}Other');
}
final Set<String> keys = Set<String>.from(
resources.keys.where((String key) => !isPluralVariation(key))
);
final Set<String> invalidKeys = keys.difference(canonicalKeys);
resources.removeWhere((String key, String value) => invalidKeys.contains(key));
});
}
/// Enforces the following invariants in our localizations:
///
/// - Resource keys are valid, i.e. they appear in the canonical list.
/// - Resource keys are complete for language-level locales, e.g. "es", "he".
///
/// Uses "en" localizations as the canonical source of locale keys that other
/// locales are compared against.
///
/// If validation fails, throws an exception.
void validateLocalizations(
Map<LocaleInfo, Map<String, String>> localeToResources,
Map<LocaleInfo, Map<String, dynamic>> localeToAttributes, {
bool removeUndefined = false,
}) {
final Map<String, String> canonicalLocalizations = localeToResources[LocaleInfo.fromString('en')]!;
final Set<String> canonicalKeys = Set<String>.from(canonicalLocalizations.keys);
final StringBuffer errorMessages = StringBuffer();
bool explainMissingKeys = false;
for (final LocaleInfo locale in localeToResources.keys) {
final Map<String, String> resources = localeToResources[locale]!;
// Whether `key` corresponds to one of the plural variations of a key with
// the same prefix and suffix "Other".
//
// Many languages require only a subset of these variations, so we do not
// require them so long as the "Other" variation exists.
bool isPluralVariation(String key) {
final Match? pluralMatch = kPluralRegexp.firstMatch(key);
if (pluralMatch == null) {
return false;
}
final String? prefix = pluralMatch[1];
return resources.containsKey('${prefix}Other');
}
final Set<String> keys = Set<String>.from(
resources.keys.where((String key) => !isPluralVariation(key))
);
// Make sure keys are valid (i.e. they also exist in the canonical
// localizations)
final Set<String> invalidKeys = keys.difference(canonicalKeys);
if (invalidKeys.isNotEmpty && !removeUndefined) {
errorMessages.writeln('Locale "$locale" contains invalid resource keys: ${invalidKeys.join(', ')}');
}
// For language-level locales only, check that they have a complete list of
// keys, or opted out of using certain ones.
if (locale.length == 1) {
final Map<String, dynamic>? attributes = localeToAttributes[locale];
final List<String?> missingKeys = <String?>[];
for (final String missingKey in canonicalKeys.difference(keys)) {
final dynamic attribute = attributes?[missingKey];
final bool intentionallyOmitted = attribute is Map && attribute.containsKey('notUsed');
if (!intentionallyOmitted && !isPluralVariation(missingKey)) {
missingKeys.add(missingKey);
}
}
if (missingKeys.isNotEmpty) {
explainMissingKeys = true;
errorMessages.writeln('Locale "$locale" is missing the following resource keys: ${missingKeys.join(', ')}');
}
}
}
if (errorMessages.isNotEmpty) {
if (explainMissingKeys) {
errorMessages
..writeln()
..writeln(
'If a resource key is intentionally omitted, add an attribute corresponding '
'to the key name with a "notUsed" property explaining why. Example:'
)
..writeln()
..writeln('"@anteMeridiemAbbreviation": {')
..writeln(' "notUsed": "Sindhi time format does not use a.m. indicator"')
..writeln('}');
}
throw ValidationError(errorMessages.toString());
}
}