localizations_validator.dart 8.06 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'dart:convert' show json;
6 7
import 'dart:io';

8 9
import 'localizations_utils.dart';

10 11 12
// 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'];
13
final RegExp kPluralRegexp = RegExp(r'(\w*)(' + kPluralSuffixes.skip(1).join(r'|') + r')$');
14 15 16 17 18 19 20 21

class ValidationError implements Exception {
  ValidationError(this. message);
  final String message;
  @override
  String toString() => message;
}

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 26 27 28 29
/// - 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.
///
30 31
/// Throws an exception upon failure.
void validateEnglishLocalizations(File file) {
32
  final StringBuffer errorMessages = StringBuffer();
33 34 35

  if (!file.existsSync()) {
    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 51 52 53

    bool checkPluralResource(String suffix) {
      final int suffixIndex = resourceId.indexOf(suffix);
      return suffixIndex != -1 && bundle['@${resourceId.substring(0, suffixIndex)}'] != null;
    }
54
    if (kPluralSuffixes.any(checkPluralResource)) {
55
      continue;
56
    }
57 58 59 60

    errorMessages.writeln('A value was not specified for @$resourceId');
  }

61
  for (final String atResourceId in bundle.keys) {
62
    if (!atResourceId.startsWith('@')) {
63
      continue;
64
    }
65 66

    final dynamic atResourceValue = bundle[atResourceId];
67
    final Map<String, dynamic>? atResource =
68
        atResourceValue is Map<String, dynamic> ? atResourceValue : null;
69 70 71 72 73
    if (atResource == null) {
      errorMessages.writeln('A map value was not specified for $atResourceId');
      continue;
    }

74
    final bool optional = atResource.containsKey('optional');
75
    final String? description = atResource['description'] as String?;
76
    if (description == null && !optional) {
77
      errorMessages.writeln('No description specified for $atResourceId');
78
    }
79

80
    final String? plural = atResource['plural'] as String?;
81 82 83
    final String resourceId = atResourceId.substring(1);
    if (plural != null) {
      final String resourceIdOther = '${resourceId}Other';
84
      if (!bundle.containsKey(resourceIdOther)) {
85
        errorMessages.writeln('Default plural resource $resourceIdOther undefined');
86
      }
87
    } else {
88
      if (!optional && !bundle.containsKey(resourceId)) {
89
        errorMessages.writeln('No matching $resourceId defined for $atResourceId');
90
      }
91 92 93
    }
  }

94
  if (errorMessages.isNotEmpty) {
95
    throw ValidationError(errorMessages.toString());
96
  }
97 98
}

99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118
/// 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);
119
      if (pluralMatch == null) {
120
        return false;
121
      }
122 123 124 125 126 127 128 129 130 131 132 133 134
      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));
  });
}

135
/// Enforces the following invariants in our localizations:
136
///
137 138
/// - Resource keys are valid, i.e. they appear in the canonical list.
/// - Resource keys are complete for language-level locales, e.g. "es", "he".
139
///
140 141
/// Uses "en" localizations as the canonical source of locale keys that other
/// locales are compared against.
142
///
143 144
/// If validation fails, throws an exception.
void validateLocalizations(
145
  Map<LocaleInfo, Map<String, String>> localeToResources,
146 147 148
  Map<LocaleInfo, Map<String, dynamic>> localeToAttributes, {
  bool removeUndefined = false,
}) {
149
  final Map<String, String> canonicalLocalizations = localeToResources[LocaleInfo.fromString('en')]!;
150 151
  final Set<String> canonicalKeys = Set<String>.from(canonicalLocalizations.keys);
  final StringBuffer errorMessages = StringBuffer();
152
  bool explainMissingKeys = false;
153
  for (final LocaleInfo locale in localeToResources.keys) {
154
    final Map<String, String> resources = localeToResources[locale]!;
155 156 157 158 159 160 161

    // 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) {
162
      final Match? pluralMatch = kPluralRegexp.firstMatch(key);
163
      if (pluralMatch == null) {
164
        return false;
165
      }
166
      final String? prefix = pluralMatch[1];
167 168 169
      return resources.containsKey('${prefix}Other');
    }

170
    final Set<String> keys = Set<String>.from(
171 172 173 174 175 176
      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);
177
    if (invalidKeys.isNotEmpty && !removeUndefined) {
178
      errorMessages.writeln('Locale "$locale" contains invalid resource keys: ${invalidKeys.join(', ')}');
179 180
    }

181 182
    // For language-level locales only, check that they have a complete list of
    // keys, or opted out of using certain ones.
183
    if (locale.length == 1) {
184 185
      final Map<String, dynamic>? attributes = localeToAttributes[locale];
      final List<String?> missingKeys = <String?>[];
186
       for (final String missingKey in canonicalKeys.difference(keys)) {
187
        final dynamic attribute = attributes?[missingKey];
188
        final bool intentionallyOmitted = attribute is Map && attribute.containsKey('notUsed');
189
        if (!intentionallyOmitted && !isPluralVariation(missingKey)) {
190
          missingKeys.add(missingKey);
191
        }
192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212
      }
      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('}');
    }
213
    throw ValidationError(errorMessages.toString());
214 215
  }
}