001/*
002 * Copyright 2017-2022 Product Mog LLC, 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.lokalized;
018
019import com.lokalized.LocalizedString.LanguageFormSelector;
020import com.lokalized.LocalizedString.PlaceholderMetadata;
021import com.lokalized.LocalizedString.LanguageFormTranslation;
022import com.lokalized.LocalizedString.LanguageFormTranslationRule;
023import com.lokalized.LocalizedString.LanguageFormTranslationRange;
024import com.lokalized.MinimalJson.Json;
025import com.lokalized.MinimalJson.JsonArray;
026import com.lokalized.MinimalJson.JsonObject;
027import com.lokalized.MinimalJson.JsonObject.Member;
028import com.lokalized.MinimalJson.JsonValue;
029import org.jspecify.annotations.NonNull;
030import org.jspecify.annotations.Nullable;
031
032import javax.annotation.concurrent.ThreadSafe;
033import java.io.File;
034import java.io.IOException;
035import java.io.InputStream;
036import java.net.JarURLConnection;
037import java.net.URISyntaxException;
038import java.net.URL;
039import java.nio.file.Files;
040import java.nio.file.Path;
041import java.nio.file.Paths;
042import java.util.ArrayList;
043import java.util.Arrays;
044import java.util.Collections;
045import java.util.Enumeration;
046import java.util.HashSet;
047import java.util.LinkedHashMap;
048import java.util.LinkedHashSet;
049import java.util.List;
050import java.util.Locale;
051import java.util.Map;
052import java.util.Set;
053import java.util.TreeMap;
054import java.util.jar.JarEntry;
055import java.util.jar.JarFile;
056import java.util.logging.Logger;
057import java.util.regex.Pattern;
058import java.util.stream.Collectors;
059
060import static java.lang.String.format;
061import static java.nio.charset.StandardCharsets.UTF_8;
062import static java.util.Objects.requireNonNull;
063
064/**
065 * Utility methods for loading localized strings files.
066 *
067 * @author <a href="https://revetkn.com">Mark Allen</a>
068 */
069@ThreadSafe
070public final class LocalizedStringLoader {
071  @NonNull
072  private static final Map<@NonNull String, @NonNull LanguageForm> SUPPORTED_LANGUAGE_FORMS_BY_NAME;
073  @NonNull
074  private static final Map<@NonNull String, @NonNull LanguageFormType> SUPPORTED_LANGUAGE_FORM_TYPES_BY_NAME;
075  @NonNull
076  private static final Map<@NonNull LanguageFormType, @NonNull Set<@NonNull String>> SUPPORTED_LANGUAGE_FORM_NAMES_BY_TYPE;
077  @NonNull
078  private static final Logger LOGGER;
079  @NonNull
080  private static final ExpressionEvaluator EXPRESSION_EVALUATOR;
081  @NonNull
082  private static final Pattern PLACEHOLDER_NAME_PATTERN;
083  @NonNull
084  private static final Pattern LANGUAGE_TAG_PATTERN;
085  @NonNull
086  private static final String JSON_EXTENSION;
087
088  static {
089    LOGGER = Logger.getLogger(LoggerType.LOCALIZED_STRING_LOADER.getLoggerName());
090    EXPRESSION_EVALUATOR = new ExpressionEvaluator();
091
092    Set<@NonNull LanguageForm> supportedLanguageForms = new LinkedHashSet<>();
093    supportedLanguageForms.addAll(Arrays.asList(Gender.values()));
094    supportedLanguageForms.addAll(Arrays.asList(GrammaticalCase.values()));
095    supportedLanguageForms.addAll(Arrays.asList(Definiteness.values()));
096    supportedLanguageForms.addAll(Arrays.asList(Classifier.values()));
097    supportedLanguageForms.addAll(Arrays.asList(Formality.values()));
098    supportedLanguageForms.addAll(Arrays.asList(Clusivity.values()));
099    supportedLanguageForms.addAll(Arrays.asList(Animacy.values()));
100    supportedLanguageForms.addAll(Arrays.asList(Cardinality.values()));
101    supportedLanguageForms.addAll(Arrays.asList(Ordinality.values()));
102    supportedLanguageForms.addAll(Arrays.asList(Phonetic.values()));
103
104    Map<@NonNull String, @NonNull LanguageForm> supportedLanguageFormsByName = new LinkedHashMap<>();
105    Map<@NonNull LanguageFormType, @NonNull Set<@NonNull String>> supportedLanguageFormNamesByType = new LinkedHashMap<>();
106
107    for (LanguageFormType languageFormType : LanguageFormType.values())
108      supportedLanguageFormNamesByType.put(languageFormType, new LinkedHashSet<>());
109
110    for (LanguageForm languageForm : supportedLanguageForms) {
111      if (!languageForm.getClass().isEnum())
112        throw new IllegalArgumentException(format("The %s interface must be implemented by enum types. %s is not an enum",
113            LanguageForm.class.getSimpleName(), languageForm.getClass().getSimpleName()));
114
115      String languageFormName = ((Enum<?>) languageForm).name();
116      LanguageForm existingLanguageForm = supportedLanguageFormsByName.get(languageFormName);
117
118      if (existingLanguageForm != null)
119        throw new IllegalArgumentException(format("There is already a language form %s.%s whose name collides with %s.%s. " +
120                "Language form names must be unique", existingLanguageForm.getClass().getSimpleName(), languageFormName,
121            languageForm.getClass().getSimpleName(), languageFormName));
122
123      // Massage Cardinality to match file format, e.g. "ONE" -> "CARDINALITY_ONE"
124      if (languageForm instanceof Cardinality)
125        languageFormName = LocalizedStringUtils.localizedStringNameForCardinalityName(languageFormName);
126
127      // Massage Ordinality to match file format, e.g. "ONE" -> "ORDINALITY_ONE"
128      if (languageForm instanceof Ordinality)
129        languageFormName = LocalizedStringUtils.localizedStringNameForOrdinalityName(languageFormName);
130
131      // Massage Gender to match file format, e.g. "MASCULINE" -> "GENDER_MASCULINE"
132      if (languageForm instanceof Gender)
133        languageFormName = LocalizedStringUtils.localizedStringNameForGenderName(languageFormName);
134
135      // Massage GrammaticalCase to match file format, e.g. "DATIVE" -> "CASE_DATIVE"
136      if (languageForm instanceof GrammaticalCase)
137        languageFormName = LocalizedStringUtils.localizedStringNameForGrammaticalCaseName(languageFormName);
138
139      // Massage Definiteness to match file format, e.g. "DEFINITE" -> "DEFINITENESS_DEFINITE"
140      if (languageForm instanceof Definiteness)
141        languageFormName = LocalizedStringUtils.localizedStringNameForDefinitenessName(languageFormName);
142
143      // Massage Classifier to match file format, e.g. "GENERAL" -> "CLASSIFIER_GENERAL"
144      if (languageForm instanceof Classifier)
145        languageFormName = LocalizedStringUtils.localizedStringNameForClassifierName(languageFormName);
146
147      // Massage Formality to match file format, e.g. "FORMAL" -> "FORMALITY_FORMAL"
148      if (languageForm instanceof Formality)
149        languageFormName = LocalizedStringUtils.localizedStringNameForFormalityName(languageFormName);
150
151      // Massage Clusivity to match file format, e.g. "INCLUSIVE" -> "CLUSIVITY_INCLUSIVE"
152      if (languageForm instanceof Clusivity)
153        languageFormName = LocalizedStringUtils.localizedStringNameForClusivityName(languageFormName);
154
155      // Massage Animacy to match file format, e.g. "ANIMATE" -> "ANIMACY_ANIMATE"
156      if (languageForm instanceof Animacy)
157        languageFormName = LocalizedStringUtils.localizedStringNameForAnimacyName(languageFormName);
158
159      // Massage Phonetic to match file format, e.g. "VOWEL" -> "PHONETIC_VOWEL"
160      if (languageForm instanceof Phonetic)
161        languageFormName = LocalizedStringUtils.localizedStringNameForPhoneticName(languageFormName);
162
163      supportedLanguageFormsByName.put(languageFormName, languageForm);
164      supportedLanguageFormNamesByType.get(LanguageFormType.forLanguageForm(languageForm)).add(languageFormName);
165    }
166
167    SUPPORTED_LANGUAGE_FORMS_BY_NAME = Collections.unmodifiableMap(supportedLanguageFormsByName);
168    SUPPORTED_LANGUAGE_FORM_TYPES_BY_NAME = Collections.unmodifiableMap(new LinkedHashMap<>(LanguageFormType.getLanguageFormTypesByName()));
169    Map<@NonNull LanguageFormType, @NonNull Set<@NonNull String>> immutableSupportedLanguageFormNamesByType = new LinkedHashMap<>();
170
171    for (Map.Entry<@NonNull LanguageFormType, @NonNull Set<@NonNull String>> entry : supportedLanguageFormNamesByType.entrySet())
172      immutableSupportedLanguageFormNamesByType.put(entry.getKey(), Collections.unmodifiableSet(new LinkedHashSet<>(entry.getValue())));
173
174    SUPPORTED_LANGUAGE_FORM_NAMES_BY_TYPE = Collections.unmodifiableMap(immutableSupportedLanguageFormNamesByType);
175    PLACEHOLDER_NAME_PATTERN = Pattern.compile("^[\\p{Alpha}_][\\p{Alnum}_-]*$");
176    LANGUAGE_TAG_PATTERN = Pattern.compile("^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*$");
177    JSON_EXTENSION = ".json";
178  }
179
180  private LocalizedStringLoader() {
181    // Non-instantiable
182  }
183
184  /**
185   * Loads all localized string files present in the specified package on the classpath.
186   * <p>
187   * Filenames must correspond to the IETF BCP 47 language tag format, optionally suffixed with {@code .json}.
188   * <p>
189   * Example filenames:
190   * <ul>
191   * <li>{@code en}</li>
192   * <li>{@code en.json}</li>
193   * <li>{@code es-MX}</li>
194   * <li>{@code es-MX.json}</li>
195   * <li>{@code nan-Hant-TW}</li>
196   * </ul>
197   * <p>
198   * Like any classpath reference, packages are separated using the {@code /} character.
199   * <p>
200   * Example package names:
201   * <ul>
202   * <li>{@code strings}
203   * <li>{@code com/lokalized/strings}
204   * </ul>
205   * <p>
206   * Note: this implementation only scans the specified package, it does not descend into child packages.
207   *
208   * @param classpathPackage location of a package on the classpath, not null
209   * @return per-locale sets of localized strings, not null
210   * @throws LocalizedStringLoadingException if an error occurs while loading localized string files
211   */
212  @NonNull
213  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(@NonNull String classpathPackage) {
214    return loadFromClasspath(LocalizedStringLoader.class.getClassLoader(), classpathPackage);
215  }
216
217  @NonNull
218  static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(@NonNull ClassLoader classLoader,
219                                                             @NonNull String classpathPackage) {
220    requireNonNull(classpathPackage);
221    requireNonNull(classLoader);
222
223    Enumeration<URL> urls;
224
225    try {
226      urls = classLoader.getResources(classpathPackage);
227    } catch (IOException e) {
228      throw new LocalizedStringLoadingException(format("Unable to search classpath for '%s'", classpathPackage), e);
229    }
230
231    if (!urls.hasMoreElements())
232      throw new LocalizedStringLoadingException(format("Unable to find package '%s' on the classpath", classpathPackage));
233
234    Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> mergedByLocale = createLocaleKeyMap();
235
236    while (urls.hasMoreElements()) {
237      URL url = urls.nextElement();
238      Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = loadFromUrl(url, classpathPackage);
239      mergeLocalizedStrings(mergedByLocale, localizedStringsByLocale);
240    }
241
242    return toLocalizedStringsByLocale(mergedByLocale);
243  }
244
245  /**
246   * Loads all localized string files present in the specified directory.
247   * <p>
248   * Filenames must correspond to the IETF BCP 47 language tag format, optionally suffixed with {@code .json}.
249   * <p>
250   * Example filenames:
251   * <ul>
252   * <li>{@code en}</li>
253   * <li>{@code en.json}</li>
254   * <li>{@code es-MX}</li>
255   * <li>{@code es-MX.json}</li>
256   * <li>{@code nan-Hant-TW}</li>
257   * </ul>
258   * <p>
259   * Note: this implementation only scans the specified directory, it does not descend into child directories.
260   *
261   * @param directory directory in which to search for localized string files, not null
262   * @return per-locale sets of localized strings, not null
263   * @throws LocalizedStringLoadingException if an error occurs while loading localized string files
264   */
265  @NonNull
266  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromFilesystem(@NonNull Path directory) {
267    requireNonNull(directory);
268    return loadFromDirectory(directory.toFile());
269  }
270
271  // TODO: should we expose methods for loading a single file?
272
273  /**
274   * Loads all localized string files present in the specified directory.
275   *
276   * @param directory directory in which to search for localized string files, not null
277   * @return per-locale sets of localized strings, not null
278   * @throws LocalizedStringLoadingException if an error occurs while loading localized string files
279   */
280  @NonNull
281  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromDirectory(@NonNull File directory) {
282    requireNonNull(directory);
283
284    if (!directory.exists())
285      throw new LocalizedStringLoadingException(format("Location '%s' does not exist",
286          directory));
287
288    if (!directory.isDirectory())
289      throw new LocalizedStringLoadingException(format("Location '%s' exists but is not a directory",
290          directory));
291
292    Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap();
293
294    File[] files = directory.listFiles();
295
296    if (files == null)
297      throw new LocalizedStringLoadingException(format("Unable to list files in directory '%s'", directory));
298
299    if (files != null) {
300      for (File file : files) {
301        if (file.isDirectory())
302          continue;
303
304        String fileName = file.getName();
305        String languageTag = languageTagForFileName(fileName);
306
307        if (languageTag != null) {
308          LOGGER.fine(format("Loading localized strings file '%s'...", fileName));
309          Locale locale = Locale.forLanguageTag(languageTag);
310
311          if (localizedStringsByLocale.containsKey(locale))
312            throw new LocalizedStringLoadingException(format("Duplicate localized strings file for locale '%s' found at '%s'",
313                locale.toLanguageTag(), file.getPath()));
314
315          localizedStringsByLocale.put(locale, parseLocalizedStringsFile(file));
316        } else {
317          LOGGER.fine(format("File '%s' does not correspond to a known language tag, skipping...", fileName));
318        }
319      }
320    }
321
322    return Collections.unmodifiableMap(localizedStringsByLocale);
323  }
324
325  @NonNull
326  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromUrl(@NonNull URL url, @NonNull String classpathPackage) {
327    requireNonNull(url);
328    requireNonNull(classpathPackage);
329
330    String protocol = url.getProtocol();
331
332    if ("file".equals(protocol)) {
333      try {
334        return loadFromDirectory(Paths.get(url.toURI()).toFile());
335      } catch (URISyntaxException e) {
336        throw new LocalizedStringLoadingException(format("Unable to resolve classpath location '%s'", url), e);
337      }
338    }
339
340    if ("jar".equals(protocol))
341      return loadFromJar(url, classpathPackage);
342
343    throw new LocalizedStringLoadingException(format("Unsupported classpath protocol '%s' for location '%s'",
344        protocol, url));
345  }
346
347  @NonNull
348  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromJar(@NonNull URL jarUrl,
349                                                               @NonNull String classpathPackage) {
350    requireNonNull(jarUrl);
351    requireNonNull(classpathPackage);
352
353    Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap();
354
355    try {
356      JarURLConnection connection = (JarURLConnection) jarUrl.openConnection();
357      connection.setUseCaches(false);
358
359      try (JarFile jarFile = connection.getJarFile()) {
360        String packagePath = connection.getEntryName();
361
362        if (packagePath == null || packagePath.isEmpty())
363          packagePath = classpathPackage;
364
365        if (!packagePath.endsWith("/"))
366          packagePath = packagePath + "/";
367
368        Enumeration<JarEntry> entries = jarFile.entries();
369
370        while (entries.hasMoreElements()) {
371          JarEntry entry = entries.nextElement();
372
373          if (entry.isDirectory())
374            continue;
375
376          String entryName = entry.getName();
377
378          if (!entryName.startsWith(packagePath))
379            continue;
380
381          String relativeName = entryName.substring(packagePath.length());
382
383          if ("".equals(relativeName) || relativeName.contains("/"))
384            continue;
385
386          String languageTag = languageTagForFileName(relativeName);
387
388          if (languageTag != null) {
389            LOGGER.fine(format("Loading localized strings file '%s' from %s...", relativeName, jarFile.getName()));
390            Locale locale = Locale.forLanguageTag(languageTag);
391
392            if (localizedStringsByLocale.containsKey(locale))
393              throw new LocalizedStringLoadingException(format("Duplicate localized strings file for locale '%s' found in %s",
394                  locale.toLanguageTag(), jarFile.getName()));
395
396            try (InputStream inputStream = jarFile.getInputStream(entry)) {
397              String contents = new String(inputStream.readAllBytes(), UTF_8).trim();
398              String canonicalPath = format("jar:%s!/%s", jarFile.getName(), entryName);
399              localizedStringsByLocale.put(locale, parseLocalizedStrings(canonicalPath, contents));
400            }
401          } else {
402            LOGGER.fine(format("File '%s' does not correspond to a known language tag, skipping...", relativeName));
403          }
404        }
405      }
406    } catch (IOException e) {
407      throw new LocalizedStringLoadingException(format("Unable to load localized strings from '%s'", jarUrl), e);
408    }
409
410    return Collections.unmodifiableMap(localizedStringsByLocale);
411  }
412
413  @NonNull
414  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> createLocaleMap() {
415    return new TreeMap<>((locale1, locale2) -> locale1.toLanguageTag().compareTo(locale2.toLanguageTag()));
416  }
417
418  @NonNull
419  private static Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> createLocaleKeyMap() {
420    return new TreeMap<>((locale1, locale2) -> locale1.toLanguageTag().compareTo(locale2.toLanguageTag()));
421  }
422
423  private static void mergeLocalizedStrings(
424      @NonNull Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> target,
425      @NonNull Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> source) {
426    requireNonNull(target);
427    requireNonNull(source);
428
429    for (Map.Entry<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> entry : source.entrySet()) {
430      Locale locale = entry.getKey();
431      Map<@NonNull String, @NonNull LocalizedString> localizedStringsByKey = target.get(locale);
432
433      if (localizedStringsByKey == null) {
434        localizedStringsByKey = new LinkedHashMap<>();
435        target.put(locale, localizedStringsByKey);
436      }
437
438      for (LocalizedString localizedString : entry.getValue()) {
439        String key = localizedString.getKey();
440        LocalizedString existing = localizedStringsByKey.get(key);
441
442        if (existing != null)
443          throw new LocalizedStringLoadingException(format("Duplicate localized string key '%s' found for locale '%s' while merging classpath resources",
444              key, locale.toLanguageTag()));
445
446        localizedStringsByKey.put(key, localizedString);
447      }
448    }
449  }
450
451  @NonNull
452  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> toLocalizedStringsByLocale(
453      @NonNull Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> localizedStringsByKeyByLocale) {
454    requireNonNull(localizedStringsByKeyByLocale);
455
456    Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap();
457
458    for (Map.Entry<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> entry : localizedStringsByKeyByLocale.entrySet()) {
459      localizedStringsByLocale.put(entry.getKey(),
460          Collections.unmodifiableSet(new LinkedHashSet<>(entry.getValue().values())));
461    }
462
463    return Collections.unmodifiableMap(localizedStringsByLocale);
464  }
465
466  private static boolean isLanguageTag(@NonNull String languageTag) {
467    requireNonNull(languageTag);
468
469    if (!LANGUAGE_TAG_PATTERN.matcher(languageTag).matches())
470      return false;
471
472    Locale locale = Locale.forLanguageTag(languageTag);
473    if (!"".equals(locale.getLanguage()))
474      return true;
475
476    return languageTag.toLowerCase(Locale.ROOT).startsWith("x-");
477  }
478
479  @Nullable
480  private static String languageTagForFileName(@NonNull String fileName) {
481    requireNonNull(fileName);
482
483    String languageTag = fileName;
484
485    if (fileName.toLowerCase(Locale.ROOT).endsWith(JSON_EXTENSION))
486      languageTag = fileName.substring(0, fileName.length() - JSON_EXTENSION.length());
487
488    return isLanguageTag(languageTag) ? languageTag : null;
489  }
490
491  /**
492   * Parses out a set of localized strings from the given file.
493   *
494   * @param file the file to parse, not null
495   * @return the set of localized strings contained in the file, not null
496   * @throws LocalizedStringLoadingException if an error occurs while parsing the localized string file
497   */
498  @NonNull
499  private static Set<@NonNull LocalizedString> parseLocalizedStringsFile(@NonNull File file) {
500    requireNonNull(file);
501
502    String canonicalPath;
503
504    try {
505      canonicalPath = file.getCanonicalPath();
506    } catch (IOException e) {
507      throw new LocalizedStringLoadingException(
508          format("Unable to determine canonical path for localized strings file %s", file), e);
509    }
510
511    if (!Files.isRegularFile(file.toPath()))
512      throw new LocalizedStringLoadingException(format("%s is not a regular file", canonicalPath));
513
514    String localizedStringsFileContents;
515
516    try {
517      localizedStringsFileContents = new String(Files.readAllBytes(file.toPath()), UTF_8).trim();
518    } catch (IOException e) {
519      throw new LocalizedStringLoadingException(format("Unable to load localized strings file contents for %s",
520          canonicalPath), e);
521    }
522
523    return parseLocalizedStrings(canonicalPath, localizedStringsFileContents);
524  }
525
526  @NonNull
527  private static Set<@NonNull LocalizedString> parseLocalizedStrings(@NonNull String canonicalPath,
528                                                            @NonNull String localizedStringsFileContents) {
529    requireNonNull(canonicalPath);
530    requireNonNull(localizedStringsFileContents);
531
532    if ("".equals(localizedStringsFileContents))
533      return Collections.emptySet();
534
535    Set<@NonNull LocalizedString> localizedStrings = new HashSet<>();
536    JsonValue outerJsonValue;
537
538    try {
539      outerJsonValue = Json.parse(localizedStringsFileContents);
540    } catch (MinimalJson.ParseException e) {
541      throw new LocalizedStringLoadingException(
542          format("%s: unable to parse localized strings file", canonicalPath), e);
543    }
544
545    if (!outerJsonValue.isObject())
546      throw new LocalizedStringLoadingException(format("%s: a localized strings file must be comprised of a single JSON object", canonicalPath));
547
548    JsonObject outerJsonObject = outerJsonValue.asObject();
549    Set<String> keys = new HashSet<>();
550
551    for (Member member : outerJsonObject) {
552      String key = member.getName();
553
554      if (!keys.add(key))
555        throw new LocalizedStringLoadingException(format("%s: duplicate localized string key '%s' encountered", canonicalPath, key));
556
557      JsonValue value = member.getValue();
558      localizedStrings.add(parseLocalizedString(canonicalPath, key, value, null));
559    }
560
561    return Collections.unmodifiableSet(localizedStrings);
562  }
563
564  /**
565   * Parses "toplevel" localized string data.
566   * <p>
567   * Operates recursively if alternatives are encountered.
568   *
569   * @param canonicalPath the unique path to the file (or URL) being parsed, used for error reporting. not null
570   * @param key           the toplevel translation key, not null
571   * @param jsonValue     the toplevel translation value - might be a simple string, might be a complex object. not null
572   * @return a localized string instance, not null
573   * @throws LocalizedStringLoadingException if an error occurs while parsing the localized string file
574   */
575  @NonNull
576  private static LocalizedString parseLocalizedString(@NonNull String canonicalPath, @NonNull String key, @NonNull JsonValue jsonValue,
577                                                      @Nullable List<@NonNull Token> expressionTokens) {
578    requireNonNull(canonicalPath);
579    requireNonNull(key);
580    requireNonNull(jsonValue);
581
582    LocalizedString.Builder localizedStringBuilder = new LocalizedString.Builder(key).expressionTokens(expressionTokens);
583
584    if (jsonValue.isString()) {
585      // Simple case - just a key and a value, no translation rules
586      //
587      // Example format:
588      //
589      // {
590      //   "Hello, world!" : "Приветствую, мир"
591      // }
592
593      String translation = jsonValue.asString();
594
595      if (translation == null)
596        throw new LocalizedStringLoadingException(format("%s: a translation is required for key '%s'", canonicalPath, key));
597
598      return localizedStringBuilder.translation(translation).build();
599    } else if (jsonValue.isObject()) {
600      // More complex case, there can be placeholders and alternatives.
601      //
602      // Example format:
603      //
604      // {
605      //   "I read {{bookCount}} books" : {
606      //     "translation" : "I read {{bookCount}} {{books}}",
607      //     "commentary" : "Message shown when user achieves her book-reading goal for the month",
608      //     "placeholders" : {
609      //       "books" : {
610      //         "value" : "bookCount",
611      //         "translations" : {
612      //           "ONE" : "book",
613      //           "OTHER" : "books"
614      //         }
615      //       }
616      //     },
617      //     "alternatives" : [
618      //       {
619      //         "bookCount == 0" : {
620      //           "translation" : "I haven't read any books"
621      //         }
622      //       }
623      //     ]
624      //   }
625      // }
626
627      JsonObject localizedStringObject = jsonValue.asObject();
628
629      String translation = null;
630
631      JsonValue translationJsonValue = localizedStringObject.get("translation");
632
633      if (translationJsonValue != null && !translationJsonValue.isNull()) {
634        if (!translationJsonValue.isString())
635          throw new LocalizedStringLoadingException(format("%s: translation must be a string for key '%s'", canonicalPath, key));
636
637        translation = translationJsonValue.asString();
638      }
639
640      String commentary = null;
641
642      JsonValue commentaryJsonValue = localizedStringObject.get("commentary");
643
644      if (commentaryJsonValue != null && !commentaryJsonValue.isNull()) {
645        if (!commentaryJsonValue.isString())
646          throw new LocalizedStringLoadingException(format("%s: commentary must be a string for key '%s'", canonicalPath, key));
647
648        commentary = commentaryJsonValue.asString();
649      }
650
651      Map<@NonNull String, @NonNull PlaceholderMetadata> placeholderMetadataByPlaceholder = new LinkedHashMap<>();
652
653      JsonValue placeholderMetadataJsonValue = localizedStringObject.get("placeholderMetadata");
654
655      if (placeholderMetadataJsonValue != null && !placeholderMetadataJsonValue.isNull()) {
656        if (!placeholderMetadataJsonValue.isObject())
657          throw new LocalizedStringLoadingException(format("%s: the placeholderMetadata value must be an object. Key is '%s'", canonicalPath, key));
658
659        JsonObject placeholderMetadataJsonObject = placeholderMetadataJsonValue.asObject();
660
661        for (Member placeholderMetadataMember : placeholderMetadataJsonObject) {
662          String placeholderKey = placeholderMetadataMember.getName();
663          JsonValue placeholderMetadataValue = placeholderMetadataMember.getValue();
664
665          ensureValidPlaceholderName(canonicalPath, key, placeholderKey, "placeholder metadata");
666
667          if (!placeholderMetadataValue.isObject())
668            throw new LocalizedStringLoadingException(format("%s: placeholder metadata must be an object. Key is '%s'", canonicalPath, key));
669
670          JsonObject placeholderMetadataObject = placeholderMetadataValue.asObject();
671          JsonValue typeJsonValue = placeholderMetadataObject.get("type");
672          JsonValue commentaryJsonValueForPlaceholder = placeholderMetadataObject.get("commentary");
673          JsonValue exampleJsonValue = placeholderMetadataObject.get("example");
674          JsonValue allowedValuesJsonValue = placeholderMetadataObject.get("allowedValues");
675          String type = null;
676          String placeholderCommentary = null;
677          String example = null;
678          Set<@NonNull String> allowedValues = new LinkedHashSet<>();
679
680          if (typeJsonValue != null && !typeJsonValue.isNull()) {
681            if (!typeJsonValue.isString())
682              throw new LocalizedStringLoadingException(format("%s: placeholder metadata type must be a string. Placeholder is '%s' for key '%s'",
683                  canonicalPath, placeholderKey, key));
684
685            type = typeJsonValue.asString();
686          }
687
688          if (commentaryJsonValueForPlaceholder != null && !commentaryJsonValueForPlaceholder.isNull()) {
689            if (!commentaryJsonValueForPlaceholder.isString())
690              throw new LocalizedStringLoadingException(format("%s: placeholder metadata commentary must be a string. Placeholder is '%s' for key '%s'",
691                  canonicalPath, placeholderKey, key));
692
693            placeholderCommentary = commentaryJsonValueForPlaceholder.asString();
694          }
695
696          if (exampleJsonValue != null && !exampleJsonValue.isNull()) {
697            if (!exampleJsonValue.isString())
698              throw new LocalizedStringLoadingException(format("%s: placeholder metadata example must be a string. Placeholder is '%s' for key '%s'",
699                  canonicalPath, placeholderKey, key));
700
701            example = exampleJsonValue.asString();
702          }
703
704          if (allowedValuesJsonValue != null && !allowedValuesJsonValue.isNull()) {
705            if (!allowedValuesJsonValue.isArray())
706              throw new LocalizedStringLoadingException(format("%s: placeholder metadata allowedValues must be an array. Placeholder is '%s' for key '%s'",
707                  canonicalPath, placeholderKey, key));
708
709            for (JsonValue allowedValueJsonValue : allowedValuesJsonValue.asArray()) {
710              if (allowedValueJsonValue == null || allowedValueJsonValue.isNull() || !allowedValueJsonValue.isString())
711                throw new LocalizedStringLoadingException(format("%s: placeholder metadata allowedValues entries must be strings. Placeholder is '%s' for key '%s'",
712                    canonicalPath, placeholderKey, key));
713
714              String allowedValue = allowedValueJsonValue.asString();
715
716              if (!allowedValues.add(allowedValue))
717                throw new LocalizedStringLoadingException(format("%s: placeholder metadata allowedValues may not contain duplicates. " +
718                    "Duplicate value '%s' encountered for placeholder '%s' in key '%s'", canonicalPath, allowedValue, placeholderKey, key));
719            }
720          }
721
722          if (type != null) {
723            LanguageFormType languageFormType = SUPPORTED_LANGUAGE_FORM_TYPES_BY_NAME.get(type);
724
725            if (languageFormType != null) {
726              Set<@NonNull String> supportedLanguageFormsForType = SUPPORTED_LANGUAGE_FORM_NAMES_BY_TYPE.get(languageFormType);
727
728              for (String allowedValue : allowedValues) {
729                if (!supportedLanguageFormsForType.contains(allowedValue))
730                  throw new LocalizedStringLoadingException(format("%s: placeholder metadata allowed value '%s' is invalid for type '%s'. " +
731                          "Placeholder is '%s' for key '%s', valid values are [%s]", canonicalPath, allowedValue, type, placeholderKey, key,
732                      supportedLanguageFormsForType.stream().collect(Collectors.joining(", "))));
733              }
734            }
735          }
736
737          if (type == null && placeholderCommentary == null && example == null && allowedValues.isEmpty())
738            throw new LocalizedStringLoadingException(format("%s: placeholder metadata must define at least one field. Placeholder is '%s' for key '%s'",
739                canonicalPath, placeholderKey, key));
740
741          placeholderMetadataByPlaceholder.put(placeholderKey,
742              new PlaceholderMetadata(type, placeholderCommentary, example, allowedValues));
743        }
744      }
745
746      Map<@NonNull String, @NonNull LanguageFormTranslation> languageFormTranslationsByPlaceholder = new LinkedHashMap<>();
747
748      JsonValue placeholdersJsonValue = localizedStringObject.get("placeholders");
749
750      if (placeholdersJsonValue != null && !placeholdersJsonValue.isNull()) {
751        if (!placeholdersJsonValue.isObject())
752          throw new LocalizedStringLoadingException(format("%s: the placeholders value must be an object. Key is '%s'", canonicalPath, key));
753
754        JsonObject placeholdersJsonObject = placeholdersJsonValue.asObject();
755
756        for (Member placeholderMember : placeholdersJsonObject) {
757          String placeholderKey = placeholderMember.getName();
758          JsonValue placeholderJsonValue = placeholderMember.getValue();
759
760          ensureValidPlaceholderName(canonicalPath, key, placeholderKey, "placeholder");
761
762          if (!placeholderJsonValue.isObject())
763            throw new LocalizedStringLoadingException(format("%s: the placeholder value must be an object. Key is '%s'", canonicalPath, key));
764
765          JsonObject placeholderJsonObject = placeholderJsonValue.asObject();
766          LanguageFormTranslation languageFormTranslation = parseLanguageFormTranslation(canonicalPath, key, placeholderKey, placeholderJsonObject);
767          languageFormTranslationsByPlaceholder.put(placeholderKey, languageFormTranslation);
768        }
769      }
770
771      List<@NonNull LocalizedString> alternatives = new ArrayList<>();
772
773      JsonValue alternativesJsonValue = localizedStringObject.get("alternatives");
774
775      if (alternativesJsonValue != null && !alternativesJsonValue.isNull()) {
776        if (!alternativesJsonValue.isArray())
777          throw new LocalizedStringLoadingException(format("%s: alternatives must be an array. Key is '%s'", canonicalPath, key));
778
779        JsonArray alternativesJsonArray = alternativesJsonValue.asArray();
780
781        for (JsonValue alternativeJsonValue : alternativesJsonArray) {
782          if (alternativeJsonValue == null || alternativeJsonValue.isNull())
783            continue;
784
785          if (!alternativeJsonValue.isObject())
786            throw new LocalizedStringLoadingException(format("%s: alternative value must be an object. Key is '%s'", canonicalPath, key));
787
788          JsonObject outerJsonObject = alternativeJsonValue.asObject();
789
790          for (Member member : outerJsonObject) {
791            String alternativeKey = member.getName();
792            JsonValue alternativeValue = member.getValue();
793            List<@NonNull Token> alternativeTokens = parseExpressionTokens(canonicalPath, alternativeKey);
794            alternatives.add(parseLocalizedString(canonicalPath, alternativeKey, alternativeValue, alternativeTokens));
795          }
796        }
797      }
798
799      if (translation == null && alternatives.isEmpty())
800        throw new LocalizedStringLoadingException(format("%s: either a translation or at least one alternative expression is required for key '%s'",
801            canonicalPath, key));
802
803      return localizedStringBuilder.translation(translation)
804          .commentary(commentary)
805          .placeholderMetadataByPlaceholder(placeholderMetadataByPlaceholder)
806          .languageFormTranslationsByPlaceholder(languageFormTranslationsByPlaceholder)
807          .alternatives(alternatives)
808          .build();
809    } else {
810      throw new LocalizedStringLoadingException(format("%s: either a translation string or object value is required for key '%s'",
811          canonicalPath, key));
812    }
813  }
814
815  @NonNull
816  private static List<@NonNull Token> parseExpressionTokens(@NonNull String canonicalPath, @NonNull String expression) {
817    requireNonNull(canonicalPath);
818    requireNonNull(expression);
819
820    try {
821      List<@NonNull Token> tokens = EXPRESSION_EVALUATOR.getExpressionTokenizer().extractTokens(expression);
822      List<@NonNull Token> rpnTokens = EXPRESSION_EVALUATOR.convertTokensToReversePolishNotation(tokens);
823      EXPRESSION_EVALUATOR.validateReversePolishNotationTokens(rpnTokens);
824      return rpnTokens;
825    } catch (ExpressionEvaluationException e) {
826      throw new LocalizedStringLoadingException(
827          format("%s: unable to parse alternative expression '%s'", canonicalPath, expression), e);
828    }
829  }
830
831  @NonNull
832  private static LanguageFormTranslation parseLanguageFormTranslation(@NonNull String canonicalPath, @NonNull String key,
833                                                                      @NonNull String placeholderKey, @NonNull JsonObject placeholderJsonObject) {
834    requireNonNull(canonicalPath);
835    requireNonNull(key);
836    requireNonNull(placeholderKey);
837    requireNonNull(placeholderJsonObject);
838
839    JsonValue valueJsonValue = placeholderJsonObject.get("value");
840    JsonValue rangeJsonValue = placeholderJsonObject.get("range");
841    JsonValue selectorsJsonValue = placeholderJsonObject.get("selectors");
842    JsonValue translationsJsonValue = placeholderJsonObject.get("translations");
843    boolean hasValue = valueJsonValue != null && !valueJsonValue.isNull();
844    boolean hasRangeValue = rangeJsonValue != null && !rangeJsonValue.isNull();
845    boolean hasSelectors = selectorsJsonValue != null && !selectorsJsonValue.isNull();
846
847    if (!hasValue && !hasRangeValue && !hasSelectors)
848      throw new LocalizedStringLoadingException(format("%s: a placeholder translation value, range, or selectors block is required. Key is '%s'",
849          canonicalPath, key));
850
851    if (hasSelectors) {
852      if (hasValue || hasRangeValue)
853        throw new LocalizedStringLoadingException(format("%s: selector-based placeholder translations cannot define value or range. " +
854            "Placeholder is '%s' for key '%s'", canonicalPath, placeholderKey, key));
855
856      return parseSelectorDrivenLanguageFormTranslation(canonicalPath, key, placeholderKey, selectorsJsonValue, translationsJsonValue);
857    }
858
859    if (hasValue && hasRangeValue)
860      throw new LocalizedStringLoadingException(format("%s: a placeholder translation cannot have both a value and a range. Key is '%s'", canonicalPath, key));
861
862    return parseSingleAxisLanguageFormTranslation(canonicalPath, key, placeholderKey, valueJsonValue, rangeJsonValue, translationsJsonValue);
863  }
864
865  @NonNull
866  private static LanguageFormTranslation parseSingleAxisLanguageFormTranslation(@NonNull String canonicalPath, @NonNull String key,
867                                                                                @NonNull String placeholderKey, @Nullable JsonValue valueJsonValue,
868                                                                                @Nullable JsonValue rangeJsonValue,
869                                                                                @Nullable JsonValue translationsJsonValue) {
870    requireNonNull(canonicalPath);
871    requireNonNull(key);
872    requireNonNull(placeholderKey);
873
874    boolean hasValue = valueJsonValue != null && !valueJsonValue.isNull();
875    boolean hasRangeValue = rangeJsonValue != null && !rangeJsonValue.isNull();
876    LanguageFormTranslationRange rangeValue = null;
877    String value = null;
878
879    if (hasRangeValue) {
880      if (!rangeJsonValue.isObject())
881        throw new LocalizedStringLoadingException(format("%s: the placeholder translation range must be an object. Key is '%s'", canonicalPath, key));
882
883      JsonObject rangeJsonObject = rangeJsonValue.asObject();
884      JsonValue rangeValueStartJsonValue = rangeJsonObject.get("start");
885      JsonValue rangeValueEndJsonValue = rangeJsonObject.get("end");
886
887      if (rangeValueStartJsonValue == null || rangeValueStartJsonValue.isNull())
888        throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start is required. Key is '%s'", canonicalPath, key));
889
890      if (rangeValueEndJsonValue == null || rangeValueEndJsonValue.isNull())
891        throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end is required. Key is '%s'", canonicalPath, key));
892
893      if (!rangeValueStartJsonValue.isString())
894        throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start must be a string. Key is '%s'", canonicalPath, key));
895
896      if (!rangeValueEndJsonValue.isString())
897        throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end must be a string. Key is '%s'", canonicalPath, key));
898
899      String rangeStartValue = rangeValueStartJsonValue.asString();
900      String rangeEndValue = rangeValueEndJsonValue.asString();
901
902      ensureValidPlaceholderName(canonicalPath, key, rangeStartValue, "range start");
903      ensureValidPlaceholderName(canonicalPath, key, rangeEndValue, "range end");
904
905      rangeValue = new LanguageFormTranslationRange(rangeStartValue, rangeEndValue);
906    } else {
907      if (!hasValue)
908        throw new LocalizedStringLoadingException(format("%s: a placeholder translation value or range is required. Key is '%s'", canonicalPath, key));
909
910      if (!valueJsonValue.isString())
911        throw new LocalizedStringLoadingException(format("%s: a placeholder translation value must be a string. Key is '%s'", canonicalPath, key));
912
913      value = valueJsonValue.asString();
914      ensureValidPlaceholderName(canonicalPath, key, value, "placeholder value");
915    }
916
917    if (translationsJsonValue == null || translationsJsonValue.isNull())
918      throw new LocalizedStringLoadingException(format("%s: placeholder translations are required. Key is '%s'", canonicalPath, key));
919
920    if (!translationsJsonValue.isObject())
921      throw new LocalizedStringLoadingException(format("%s: the placeholder translations value must be an object. Key is '%s'", canonicalPath, key));
922
923    Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm = new LinkedHashMap<>();
924
925    for (Member translationMember : translationsJsonValue.asObject()) {
926      String languageFormTranslationKey = translationMember.getName();
927      JsonValue languageFormTranslationJsonValue = translationMember.getValue();
928      LanguageForm languageForm = SUPPORTED_LANGUAGE_FORMS_BY_NAME.get(languageFormTranslationKey);
929
930      if (languageForm == null)
931        throw new LocalizedStringLoadingException(format("%s: unexpected placeholder translation language form encountered. Key is '%s'. " +
932                "You provided '%s', valid values are [%s]", canonicalPath, key, languageFormTranslationKey,
933            SUPPORTED_LANGUAGE_FORMS_BY_NAME.keySet().stream().collect(Collectors.joining(", "))));
934
935      if (!languageFormTranslationJsonValue.isString())
936        throw new LocalizedStringLoadingException(format("%s: the placeholder translation value must be a string. Key is '%s'", canonicalPath, key));
937
938      translationsByLanguageForm.put(languageForm, languageFormTranslationJsonValue.asString());
939    }
940
941    if (translationsByLanguageForm.isEmpty())
942      throw new LocalizedStringLoadingException(format("%s: placeholder translations are required. Key is '%s'", canonicalPath, key));
943
944    Set<Class<?>> languageFormTypes = new HashSet<>();
945
946    for (LanguageForm languageForm : translationsByLanguageForm.keySet())
947      languageFormTypes.add(languageForm.getClass());
948
949    if (languageFormTypes.size() > 1)
950      throw new LocalizedStringLoadingException(format("%s: you cannot mix-and-match language forms in placeholder translations. " +
951          "Placeholder is '%s' for key '%s'", canonicalPath, placeholderKey, key));
952
953    if (rangeValue != null) {
954      boolean hasNonCardinality = translationsByLanguageForm.keySet().stream()
955          .anyMatch(languageForm -> !(languageForm instanceof Cardinality));
956
957      if (hasNonCardinality)
958        throw new LocalizedStringLoadingException(format("%s: range-based translations only support %s. Placeholder is '%s' for key '%s'",
959            canonicalPath, Cardinality.class.getSimpleName(), placeholderKey, key));
960    }
961
962    return rangeValue != null
963        ? new LanguageFormTranslation(rangeValue, translationsByLanguageForm)
964        : new LanguageFormTranslation(value, translationsByLanguageForm);
965  }
966
967  @NonNull
968  private static LanguageFormTranslation parseSelectorDrivenLanguageFormTranslation(@NonNull String canonicalPath, @NonNull String key,
969                                                                                    @NonNull String placeholderKey, @NonNull JsonValue selectorsJsonValue,
970                                                                                    @Nullable JsonValue translationsJsonValue) {
971    requireNonNull(canonicalPath);
972    requireNonNull(key);
973    requireNonNull(placeholderKey);
974    requireNonNull(selectorsJsonValue);
975
976    if (!selectorsJsonValue.isArray())
977      throw new LocalizedStringLoadingException(format("%s: selector-based placeholder translations require selectors to be an array. " +
978          "Placeholder is '%s' for key '%s'", canonicalPath, placeholderKey, key));
979
980    List<@NonNull LanguageFormSelector> selectors = new ArrayList<>();
981    Set<@NonNull LanguageFormType> selectorTypes = new LinkedHashSet<>();
982
983    for (JsonValue selectorJsonValue : selectorsJsonValue.asArray()) {
984      if (selectorJsonValue == null || selectorJsonValue.isNull())
985        throw new LocalizedStringLoadingException(format("%s: selector entries cannot be null. Placeholder is '%s' for key '%s'",
986            canonicalPath, placeholderKey, key));
987
988      if (!selectorJsonValue.isObject())
989        throw new LocalizedStringLoadingException(format("%s: selector entries must be objects. Placeholder is '%s' for key '%s'",
990            canonicalPath, placeholderKey, key));
991
992      JsonObject selectorJsonObject = selectorJsonValue.asObject();
993      JsonValue selectorValueJsonValue = selectorJsonObject.get("value");
994      JsonValue selectorFormJsonValue = selectorJsonObject.get("form");
995
996      if (selectorValueJsonValue == null || selectorValueJsonValue.isNull())
997        throw new LocalizedStringLoadingException(format("%s: selector value is required. Placeholder is '%s' for key '%s'",
998            canonicalPath, placeholderKey, key));
999
1000      if (selectorFormJsonValue == null || selectorFormJsonValue.isNull())
1001        throw new LocalizedStringLoadingException(format("%s: selector form is required. Placeholder is '%s' for key '%s'",
1002            canonicalPath, placeholderKey, key));
1003
1004      if (!selectorValueJsonValue.isString())
1005        throw new LocalizedStringLoadingException(format("%s: selector value must be a string. Placeholder is '%s' for key '%s'",
1006            canonicalPath, placeholderKey, key));
1007
1008      if (!selectorFormJsonValue.isString())
1009        throw new LocalizedStringLoadingException(format("%s: selector form must be a string. Placeholder is '%s' for key '%s'",
1010            canonicalPath, placeholderKey, key));
1011
1012      String selectorValue = selectorValueJsonValue.asString();
1013      String selectorFormName = selectorFormJsonValue.asString();
1014
1015      ensureValidPlaceholderName(canonicalPath, key, selectorValue, "selector value");
1016
1017      LanguageFormType languageFormType = SUPPORTED_LANGUAGE_FORM_TYPES_BY_NAME.get(selectorFormName);
1018
1019      if (languageFormType == null)
1020        throw new LocalizedStringLoadingException(format("%s: unexpected selector form encountered. Placeholder is '%s' for key '%s'. " +
1021                "You provided '%s', valid values are [%s]", canonicalPath, placeholderKey, key, selectorFormName,
1022            SUPPORTED_LANGUAGE_FORM_TYPES_BY_NAME.keySet().stream().collect(Collectors.joining(", "))));
1023
1024      if (!selectorTypes.add(languageFormType))
1025        throw new LocalizedStringLoadingException(format("%s: duplicate selector form '%s' encountered. Placeholder is '%s' for key '%s'",
1026            canonicalPath, selectorFormName, placeholderKey, key));
1027
1028      selectors.add(new LanguageFormSelector(selectorValue, languageFormType));
1029    }
1030
1031    if (selectors.isEmpty())
1032      throw new LocalizedStringLoadingException(format("%s: selector-based placeholder translations require at least one selector. Placeholder is '%s' for key '%s'",
1033          canonicalPath, placeholderKey, key));
1034
1035    if (translationsJsonValue == null || translationsJsonValue.isNull())
1036      throw new LocalizedStringLoadingException(format("%s: placeholder translations are required. Key is '%s'", canonicalPath, key));
1037
1038    if (!translationsJsonValue.isArray())
1039      throw new LocalizedStringLoadingException(format("%s: selector-based placeholder translations require translations to be an array. " +
1040          "Placeholder is '%s' for key '%s'", canonicalPath, placeholderKey, key));
1041
1042    List<@NonNull LanguageFormTranslationRule> translationRules = new ArrayList<>();
1043
1044    for (JsonValue translationJsonValue : translationsJsonValue.asArray()) {
1045      if (translationJsonValue == null || translationJsonValue.isNull())
1046        throw new LocalizedStringLoadingException(format("%s: selector-based translation rules cannot be null. Placeholder is '%s' for key '%s'",
1047            canonicalPath, placeholderKey, key));
1048
1049      if (!translationJsonValue.isObject())
1050        throw new LocalizedStringLoadingException(format("%s: selector-based translation rules must be objects. Placeholder is '%s' for key '%s'",
1051            canonicalPath, placeholderKey, key));
1052
1053      JsonObject translationJsonObject = translationJsonValue.asObject();
1054      JsonValue ruleValueJsonValue = translationJsonObject.get("value");
1055      JsonValue whenJsonValue = translationJsonObject.get("when");
1056
1057      if (ruleValueJsonValue == null || ruleValueJsonValue.isNull())
1058        throw new LocalizedStringLoadingException(format("%s: selector-based translation rules require a value. Placeholder is '%s' for key '%s'",
1059            canonicalPath, placeholderKey, key));
1060
1061      if (!ruleValueJsonValue.isString())
1062        throw new LocalizedStringLoadingException(format("%s: selector-based translation rule values must be strings. Placeholder is '%s' for key '%s'",
1063            canonicalPath, placeholderKey, key));
1064
1065      Map<@NonNull LanguageFormType, @NonNull LanguageForm> whenByLanguageFormType = new LinkedHashMap<>();
1066
1067      if (whenJsonValue != null && !whenJsonValue.isNull()) {
1068        if (!whenJsonValue.isObject())
1069          throw new LocalizedStringLoadingException(format("%s: selector-based translation rule conditions must be an object. Placeholder is '%s' for key '%s'",
1070              canonicalPath, placeholderKey, key));
1071
1072        for (Member whenMember : whenJsonValue.asObject()) {
1073          String selectorFormName = whenMember.getName();
1074          JsonValue selectorLanguageFormJsonValue = whenMember.getValue();
1075          LanguageFormType languageFormType = SUPPORTED_LANGUAGE_FORM_TYPES_BY_NAME.get(selectorFormName);
1076
1077          if (languageFormType == null)
1078            throw new LocalizedStringLoadingException(format("%s: unexpected selector condition form encountered. Placeholder is '%s' for key '%s'. " +
1079                    "You provided '%s', valid values are [%s]", canonicalPath, placeholderKey, key, selectorFormName,
1080                SUPPORTED_LANGUAGE_FORM_TYPES_BY_NAME.keySet().stream().collect(Collectors.joining(", "))));
1081
1082          if (!selectorTypes.contains(languageFormType))
1083            throw new LocalizedStringLoadingException(format("%s: selector condition '%s' is not declared in selectors. Placeholder is '%s' for key '%s'",
1084                canonicalPath, selectorFormName, placeholderKey, key));
1085
1086          if (!selectorLanguageFormJsonValue.isString())
1087            throw new LocalizedStringLoadingException(format("%s: selector condition values must be strings. Placeholder is '%s' for key '%s'",
1088                canonicalPath, placeholderKey, key));
1089
1090          String selectorLanguageFormName = selectorLanguageFormJsonValue.asString();
1091          LanguageForm languageForm = SUPPORTED_LANGUAGE_FORMS_BY_NAME.get(selectorLanguageFormName);
1092
1093          if (languageForm == null)
1094            throw new LocalizedStringLoadingException(format("%s: unexpected selector condition language form encountered. Placeholder is '%s' for key '%s'. " +
1095                    "You provided '%s', valid values are [%s]", canonicalPath, placeholderKey, key, selectorLanguageFormName,
1096                SUPPORTED_LANGUAGE_FORM_NAMES_BY_TYPE.get(languageFormType).stream().collect(Collectors.joining(", "))));
1097
1098          if (!languageFormType.equals(LanguageFormType.forLanguageForm(languageForm)))
1099            throw new LocalizedStringLoadingException(format("%s: selector condition '%s' must use one of [%s]. Placeholder is '%s' for key '%s'",
1100                canonicalPath, selectorFormName, SUPPORTED_LANGUAGE_FORM_NAMES_BY_TYPE.get(languageFormType).stream().collect(Collectors.joining(", ")),
1101                placeholderKey, key));
1102
1103          whenByLanguageFormType.put(languageFormType, languageForm);
1104        }
1105      }
1106
1107      translationRules.add(new LanguageFormTranslationRule(whenByLanguageFormType, ruleValueJsonValue.asString()));
1108    }
1109
1110    if (translationRules.isEmpty())
1111      throw new LocalizedStringLoadingException(format("%s: selector-based placeholder translations require at least one rule. Placeholder is '%s' for key '%s'",
1112          canonicalPath, placeholderKey, key));
1113
1114    validateSelectorTranslationRules(canonicalPath, key, placeholderKey, translationRules);
1115
1116    return new LanguageFormTranslation(selectors, translationRules);
1117  }
1118
1119  private static void validateSelectorTranslationRules(@NonNull String canonicalPath, @NonNull String key,
1120                                                       @NonNull String placeholderKey,
1121                                                       @NonNull List<@NonNull LanguageFormTranslationRule> translationRules) {
1122    requireNonNull(canonicalPath);
1123    requireNonNull(key);
1124    requireNonNull(placeholderKey);
1125    requireNonNull(translationRules);
1126
1127    for (int i = 0; i < translationRules.size(); i++) {
1128      LanguageFormTranslationRule leftRule = translationRules.get(i);
1129
1130      for (int j = i + 1; j < translationRules.size(); j++) {
1131        LanguageFormTranslationRule rightRule = translationRules.get(j);
1132
1133        if (leftRule.getWhenByLanguageFormType().size() != rightRule.getWhenByLanguageFormType().size())
1134          continue;
1135
1136        if (!selectorRuleConditionsOverlap(leftRule.getWhenByLanguageFormType(), rightRule.getWhenByLanguageFormType()))
1137          continue;
1138
1139        throw new LocalizedStringLoadingException(format("%s: selector-based translation rules are ambiguous for placeholder '%s' in key '%s'. " +
1140                "Rules %s and %s can both match with the same specificity", canonicalPath, placeholderKey, key, leftRule, rightRule));
1141      }
1142    }
1143  }
1144
1145  private static boolean selectorRuleConditionsOverlap(@NonNull Map<@NonNull LanguageFormType, @NonNull LanguageForm> leftConditions,
1146                                                       @NonNull Map<@NonNull LanguageFormType, @NonNull LanguageForm> rightConditions) {
1147    requireNonNull(leftConditions);
1148    requireNonNull(rightConditions);
1149
1150    for (Map.Entry<@NonNull LanguageFormType, @NonNull LanguageForm> leftCondition : leftConditions.entrySet()) {
1151      LanguageForm rightLanguageForm = rightConditions.get(leftCondition.getKey());
1152
1153      if (rightLanguageForm != null && !rightLanguageForm.equals(leftCondition.getValue()))
1154        return false;
1155    }
1156
1157    return true;
1158  }
1159
1160  private static void ensureValidPlaceholderName(@NonNull String canonicalPath, @NonNull String key,
1161                                                 @NonNull String placeholderName, @NonNull String description) {
1162    requireNonNull(canonicalPath);
1163    requireNonNull(key);
1164    requireNonNull(placeholderName);
1165    requireNonNull(description);
1166
1167    if (!PLACEHOLDER_NAME_PATTERN.matcher(placeholderName).matches())
1168      throw new LocalizedStringLoadingException(format("%s: invalid %s '%s'. Placeholder names must start with a letter or underscore " +
1169          "and contain only letters, digits, underscores, or hyphens. Key is '%s'", canonicalPath, description, placeholderName, key));
1170  }
1171}