| // 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()); |
| } |
| } |