1// Copyright 2017 The Chromium 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 5import 'dart:convert' show json; 6import 'dart:io'; 7 8import '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. 12const List<String> kPluralSuffixes = <String>['Other', 'Zero', 'One', 'Two', 'Few', 'Many']; 13final RegExp kPluralRegexp = RegExp(r'(\w*)(' + kPluralSuffixes.skip(1).join(r'|') + r')$'); 14 15class 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. 31void 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()); 40 41 for (String resourceId in bundle.keys) { 42 if (resourceId.startsWith('@')) 43 continue; 44 45 if (bundle['@$resourceId'] != null) 46 continue; 47 48 bool checkPluralResource(String suffix) { 49 final int suffixIndex = resourceId.indexOf(suffix); 50 return suffixIndex != -1 && bundle['@${resourceId.substring(0, suffixIndex)}'] != null; 51 } 52 if (kPluralSuffixes.any(checkPluralResource)) 53 continue; 54 55 errorMessages.writeln('A value was not specified for @$resourceId'); 56 } 57 58 for (String atResourceId in bundle.keys) { 59 if (!atResourceId.startsWith('@')) 60 continue; 61 62 final dynamic atResourceValue = bundle[atResourceId]; 63 final Map<String, dynamic> atResource = 64 atResourceValue is Map<String, dynamic> ? atResourceValue : null; 65 if (atResource == null) { 66 errorMessages.writeln('A map value was not specified for $atResourceId'); 67 continue; 68 } 69 70 final String description = atResource['description']; 71 if (description == null) 72 errorMessages.writeln('No description specified for $atResourceId'); 73 74 final String plural = atResource['plural']; 75 final String resourceId = atResourceId.substring(1); 76 if (plural != null) { 77 final String resourceIdOther = '${resourceId}Other'; 78 if (!bundle.containsKey(resourceIdOther)) 79 errorMessages.writeln('Default plural resource $resourceIdOther undefined'); 80 } else { 81 if (!bundle.containsKey(resourceId)) 82 errorMessages.writeln('No matching $resourceId defined for $atResourceId'); 83 } 84 } 85 86 if (errorMessages.isNotEmpty) 87 throw ValidationError(errorMessages.toString()); 88} 89 90/// Enforces the following invariants in our localizations: 91/// 92/// - Resource keys are valid, i.e. they appear in the canonical list. 93/// - Resource keys are complete for language-level locales, e.g. "es", "he". 94/// 95/// Uses "en" localizations as the canonical source of locale keys that other 96/// locales are compared against. 97/// 98/// If validation fails, throws an exception. 99void validateLocalizations( 100 Map<LocaleInfo, Map<String, String>> localeToResources, 101 Map<LocaleInfo, Map<String, dynamic>> localeToAttributes, 102) { 103 final Map<String, String> canonicalLocalizations = localeToResources[LocaleInfo.fromString('en')]; 104 final Set<String> canonicalKeys = Set<String>.from(canonicalLocalizations.keys); 105 final StringBuffer errorMessages = StringBuffer(); 106 bool explainMissingKeys = false; 107 for (final LocaleInfo locale in localeToResources.keys) { 108 final Map<String, String> resources = localeToResources[locale]; 109 110 // Whether `key` corresponds to one of the plural variations of a key with 111 // the same prefix and suffix "Other". 112 // 113 // Many languages require only a subset of these variations, so we do not 114 // require them so long as the "Other" variation exists. 115 bool isPluralVariation(String key) { 116 final Match pluralMatch = kPluralRegexp.firstMatch(key); 117 if (pluralMatch == null) 118 return false; 119 final String prefix = pluralMatch[1]; 120 return resources.containsKey('${prefix}Other'); 121 } 122 123 final Set<String> keys = Set<String>.from( 124 resources.keys.where((String key) => !isPluralVariation(key)) 125 ); 126 127 // Make sure keys are valid (i.e. they also exist in the canonical 128 // localizations) 129 final Set<String> invalidKeys = keys.difference(canonicalKeys); 130 if (invalidKeys.isNotEmpty) 131 errorMessages.writeln('Locale "$locale" contains invalid resource keys: ${invalidKeys.join(', ')}'); 132 // For language-level locales only, check that they have a complete list of 133 // keys, or opted out of using certain ones. 134 if (locale.length == 1) { 135 final Map<String, dynamic> attributes = localeToAttributes[locale]; 136 final List<String> missingKeys = <String>[]; 137 for (final String missingKey in canonicalKeys.difference(keys)) { 138 final dynamic attribute = attributes[missingKey]; 139 final bool intentionallyOmitted = attribute is Map && attribute.containsKey('notUsed'); 140 if (!intentionallyOmitted && !isPluralVariation(missingKey)) 141 missingKeys.add(missingKey); 142 } 143 if (missingKeys.isNotEmpty) { 144 explainMissingKeys = true; 145 errorMessages.writeln('Locale "$locale" is missing the following resource keys: ${missingKeys.join(', ')}'); 146 } 147 } 148 } 149 150 if (errorMessages.isNotEmpty) { 151 if (explainMissingKeys) { 152 errorMessages 153 ..writeln() 154 ..writeln( 155 'If a resource key is intentionally omitted, add an attribute corresponding ' 156 'to the key name with a "notUsed" property explaining why. Example:' 157 ) 158 ..writeln() 159 ..writeln('"@anteMeridiemAbbreviation": {') 160 ..writeln(' "notUsed": "Sindhi time format does not use a.m. indicator"') 161 ..writeln('}'); 162 } 163 throw ValidationError(errorMessages.toString()); 164 } 165} 166