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