| 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 = atResourceValue is Map<String, dynamic> |
| 69 | ? atResourceValue |
| 70 | : null; |
| 71 | if (atResource == null) { |
| 72 | errorMessages.writeln('A map value was not specified for $atResourceId' ); |
| 73 | continue; |
| 74 | } |
| 75 | |
| 76 | final bool optional = atResource.containsKey('optional' ); |
| 77 | final String? description = atResource['description' ] as String?; |
| 78 | if (description == null && !optional) { |
| 79 | errorMessages.writeln('No description specified for $atResourceId' ); |
| 80 | } |
| 81 | |
| 82 | final String? plural = atResource['plural' ] as String?; |
| 83 | final String resourceId = atResourceId.substring(1); |
| 84 | if (plural != null) { |
| 85 | final String resourceIdOther = ' ${resourceId}Other' ; |
| 86 | if (!bundle.containsKey(resourceIdOther)) { |
| 87 | errorMessages.writeln('Default plural resource $resourceIdOther undefined' ); |
| 88 | } |
| 89 | } else { |
| 90 | if (!optional && !bundle.containsKey(resourceId)) { |
| 91 | errorMessages.writeln('No matching $resourceId defined for $atResourceId' ); |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | if (errorMessages.isNotEmpty) { |
| 97 | throw ValidationError(errorMessages.toString()); |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | /// This removes undefined localizations (localizations that aren't present in |
| 102 | /// the canonical locale anymore) by: |
| 103 | /// |
| 104 | /// 1. Looking up the canonical (English, in this case) localizations. |
| 105 | /// 2. For each locale, getting the resources. |
| 106 | /// 3. Determining the set of keys that aren't plural variations (we're only |
| 107 | /// interested in the base terms being translated and not their variants) |
| 108 | /// 4. Determining the set of invalid keys; that is those that are (non-plural) |
| 109 | /// keys in the resources for this locale, but which _aren't_ keys in the |
| 110 | /// canonical list. |
| 111 | /// 5. Removes the invalid mappings from this resource's locale. |
| 112 | void removeUndefinedLocalizations(Map<LocaleInfo, Map<String, String>> localeToResources) { |
| 113 | final Map<String, String> canonicalLocalizations = |
| 114 | localeToResources[LocaleInfo.fromString('en' )]!; |
| 115 | final Set<String> canonicalKeys = Set<String>.from(canonicalLocalizations.keys); |
| 116 | |
| 117 | localeToResources.forEach((LocaleInfo locale, Map<String, String> resources) { |
| 118 | bool isPluralVariation(String key) { |
| 119 | final Match? pluralMatch = kPluralRegexp.firstMatch(key); |
| 120 | if (pluralMatch == null) { |
| 121 | return false; |
| 122 | } |
| 123 | final String? prefix = pluralMatch[1]; |
| 124 | return resources.containsKey(' ${prefix}Other' ); |
| 125 | } |
| 126 | |
| 127 | final Set<String> keys = Set<String>.from( |
| 128 | resources.keys.where((String key) => !isPluralVariation(key)), |
| 129 | ); |
| 130 | |
| 131 | final Set<String> invalidKeys = keys.difference(canonicalKeys); |
| 132 | resources.removeWhere((String key, String value) => invalidKeys.contains(key)); |
| 133 | }); |
| 134 | } |
| 135 | |
| 136 | /// Enforces the following invariants in our localizations: |
| 137 | /// |
| 138 | /// - Resource keys are valid, i.e. they appear in the canonical list. |
| 139 | /// - Resource keys are complete for language-level locales, e.g. "es", "he". |
| 140 | /// |
| 141 | /// Uses "en" localizations as the canonical source of locale keys that other |
| 142 | /// locales are compared against. |
| 143 | /// |
| 144 | /// If validation fails, throws an exception. |
| 145 | void validateLocalizations( |
| 146 | Map<LocaleInfo, Map<String, String>> localeToResources, |
| 147 | Map<LocaleInfo, Map<String, dynamic>> localeToAttributes, { |
| 148 | bool removeUndefined = false, |
| 149 | }) { |
| 150 | final Map<String, String> canonicalLocalizations = |
| 151 | localeToResources[LocaleInfo.fromString('en' )]!; |
| 152 | final Set<String> canonicalKeys = Set<String>.from(canonicalLocalizations.keys); |
| 153 | final StringBuffer errorMessages = StringBuffer(); |
| 154 | bool explainMissingKeys = false; |
| 155 | for (final LocaleInfo locale in localeToResources.keys) { |
| 156 | final Map<String, String> resources = localeToResources[locale]!; |
| 157 | |
| 158 | // Whether `key` corresponds to one of the plural variations of a key with |
| 159 | // the same prefix and suffix "Other". |
| 160 | // |
| 161 | // Many languages require only a subset of these variations, so we do not |
| 162 | // require them so long as the "Other" variation exists. |
| 163 | bool isPluralVariation(String key) { |
| 164 | final Match? pluralMatch = kPluralRegexp.firstMatch(key); |
| 165 | if (pluralMatch == null) { |
| 166 | return false; |
| 167 | } |
| 168 | final String? prefix = pluralMatch[1]; |
| 169 | return resources.containsKey(' ${prefix}Other' ); |
| 170 | } |
| 171 | |
| 172 | final Set<String> keys = Set<String>.from( |
| 173 | resources.keys.where((String key) => !isPluralVariation(key)), |
| 174 | ); |
| 175 | |
| 176 | // Make sure keys are valid (i.e. they also exist in the canonical |
| 177 | // localizations) |
| 178 | final Set<String> invalidKeys = keys.difference(canonicalKeys); |
| 179 | if (invalidKeys.isNotEmpty && !removeUndefined) { |
| 180 | errorMessages.writeln( |
| 181 | 'Locale " $locale" contains invalid resource keys: ${invalidKeys.join(', ' )}' , |
| 182 | ); |
| 183 | } |
| 184 | |
| 185 | // For language-level locales only, check that they have a complete list of |
| 186 | // keys, or opted out of using certain ones. |
| 187 | if (locale.length == 1) { |
| 188 | final Map<String, dynamic>? attributes = localeToAttributes[locale]; |
| 189 | final List<String?> missingKeys = <String?>[]; |
| 190 | for (final String missingKey in canonicalKeys.difference(keys)) { |
| 191 | final dynamic attribute = attributes?[missingKey]; |
| 192 | final bool intentionallyOmitted = attribute is Map && attribute.containsKey('notUsed' ); |
| 193 | if (!intentionallyOmitted && !isPluralVariation(missingKey)) { |
| 194 | missingKeys.add(missingKey); |
| 195 | } |
| 196 | } |
| 197 | if (missingKeys.isNotEmpty) { |
| 198 | explainMissingKeys = true; |
| 199 | errorMessages.writeln( |
| 200 | 'Locale " $locale" is missing the following resource keys: ${missingKeys.join(', ' )}' , |
| 201 | ); |
| 202 | } |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | if (errorMessages.isNotEmpty) { |
| 207 | if (explainMissingKeys) { |
| 208 | errorMessages |
| 209 | ..writeln() |
| 210 | ..writeln( |
| 211 | 'If a resource key is intentionally omitted, add an attribute corresponding ' |
| 212 | 'to the key name with a "notUsed" property explaining why. Example:' , |
| 213 | ) |
| 214 | ..writeln() |
| 215 | ..writeln('"@anteMeridiemAbbreviation": {' ) |
| 216 | ..writeln(' "notUsed": "Sindhi time format does not use a.m. indicator"' ) |
| 217 | ..writeln('}' ); |
| 218 | } |
| 219 | throw ValidationError(errorMessages.toString()); |
| 220 | } |
| 221 | } |
| 222 | |