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.LanguageFormTranslation;
020import com.lokalized.LocalizedString.LanguageFormTranslationRange;
021import com.lokalized.MinimalJson.Json;
022import com.lokalized.MinimalJson.JsonArray;
023import com.lokalized.MinimalJson.JsonObject;
024import com.lokalized.MinimalJson.JsonObject.Member;
025import com.lokalized.MinimalJson.JsonValue;
026import org.jspecify.annotations.NonNull;
027import org.jspecify.annotations.Nullable;
028
029import javax.annotation.concurrent.ThreadSafe;
030import java.io.File;
031import java.io.IOException;
032import java.io.InputStream;
033import java.net.JarURLConnection;
034import java.net.URISyntaxException;
035import java.net.URL;
036import java.nio.file.Files;
037import java.nio.file.Path;
038import java.nio.file.Paths;
039import java.util.ArrayList;
040import java.util.Arrays;
041import java.util.Collections;
042import java.util.Enumeration;
043import java.util.HashSet;
044import java.util.LinkedHashMap;
045import java.util.LinkedHashSet;
046import java.util.List;
047import java.util.Locale;
048import java.util.Map;
049import java.util.Set;
050import java.util.TreeMap;
051import java.util.jar.JarEntry;
052import java.util.jar.JarFile;
053import java.util.logging.Logger;
054import java.util.regex.Pattern;
055import java.util.stream.Collectors;
056
057import static java.lang.String.format;
058import static java.nio.charset.StandardCharsets.UTF_8;
059import static java.util.Objects.requireNonNull;
060
061/**
062 * Utility methods for loading localized strings files.
063 *
064 * @author <a href="https://revetkn.com">Mark Allen</a>
065 */
066@ThreadSafe
067public final class LocalizedStringLoader {
068  @NonNull
069  private static final Map<@NonNull String, @NonNull LanguageForm> SUPPORTED_LANGUAGE_FORMS_BY_NAME;
070  @NonNull
071  private static final Logger LOGGER;
072  @NonNull
073  private static final ExpressionEvaluator EXPRESSION_EVALUATOR;
074  @NonNull
075  private static final Pattern PLACEHOLDER_NAME_PATTERN;
076  @NonNull
077  private static final Pattern LANGUAGE_TAG_PATTERN;
078  @NonNull
079  private static final String JSON_EXTENSION;
080
081  static {
082    LOGGER = Logger.getLogger(LoggerType.LOCALIZED_STRING_LOADER.getLoggerName());
083    EXPRESSION_EVALUATOR = new ExpressionEvaluator();
084
085    Set<@NonNull LanguageForm> supportedLanguageForms = new LinkedHashSet<>();
086    supportedLanguageForms.addAll(Arrays.asList(Gender.values()));
087    supportedLanguageForms.addAll(Arrays.asList(Formality.values()));
088    supportedLanguageForms.addAll(Arrays.asList(Clusivity.values()));
089    supportedLanguageForms.addAll(Arrays.asList(Animacy.values()));
090    supportedLanguageForms.addAll(Arrays.asList(Cardinality.values()));
091    supportedLanguageForms.addAll(Arrays.asList(Ordinality.values()));
092    supportedLanguageForms.addAll(Arrays.asList(Phonetic.values()));
093
094    Map<@NonNull String, @NonNull LanguageForm> supportedLanguageFormsByName = new LinkedHashMap<>();
095
096    for (LanguageForm languageForm : supportedLanguageForms) {
097      if (!languageForm.getClass().isEnum())
098        throw new IllegalArgumentException(format("The %s interface must be implemented by enum types. %s is not an enum",
099            LanguageForm.class.getSimpleName(), languageForm.getClass().getSimpleName()));
100
101      String languageFormName = ((Enum<?>) languageForm).name();
102      LanguageForm existingLanguageForm = supportedLanguageFormsByName.get(languageFormName);
103
104      if (existingLanguageForm != null)
105        throw new IllegalArgumentException(format("There is already a language form %s.%s whose name collides with %s.%s. " +
106                "Language form names must be unique", existingLanguageForm.getClass().getSimpleName(), languageFormName,
107            languageForm.getClass().getSimpleName(), languageFormName));
108
109      // Massage Cardinality to match file format, e.g. "ONE" -> "CARDINALITY_ONE"
110      if (languageForm instanceof Cardinality)
111        languageFormName = LocalizedStringUtils.localizedStringNameForCardinalityName(languageFormName);
112
113      // Massage Ordinality to match file format, e.g. "ONE" -> "ORDINALITY_ONE"
114      if (languageForm instanceof Ordinality)
115        languageFormName = LocalizedStringUtils.localizedStringNameForOrdinalityName(languageFormName);
116
117      // Massage Gender to match file format, e.g. "MASCULINE" -> "GENDER_MASCULINE"
118      if (languageForm instanceof Gender)
119        languageFormName = LocalizedStringUtils.localizedStringNameForGenderName(languageFormName);
120
121      // Massage Formality to match file format, e.g. "FORMAL" -> "FORMALITY_FORMAL"
122      if (languageForm instanceof Formality)
123        languageFormName = LocalizedStringUtils.localizedStringNameForFormalityName(languageFormName);
124
125      // Massage Clusivity to match file format, e.g. "INCLUSIVE" -> "CLUSIVITY_INCLUSIVE"
126      if (languageForm instanceof Clusivity)
127        languageFormName = LocalizedStringUtils.localizedStringNameForClusivityName(languageFormName);
128
129      // Massage Animacy to match file format, e.g. "ANIMATE" -> "ANIMACY_ANIMATE"
130      if (languageForm instanceof Animacy)
131        languageFormName = LocalizedStringUtils.localizedStringNameForAnimacyName(languageFormName);
132
133      // Massage Phonetic to match file format, e.g. "VOWEL" -> "PHONETIC_VOWEL"
134      if (languageForm instanceof Phonetic)
135        languageFormName = LocalizedStringUtils.localizedStringNameForPhoneticName(languageFormName);
136
137      supportedLanguageFormsByName.put(languageFormName, languageForm);
138    }
139
140    SUPPORTED_LANGUAGE_FORMS_BY_NAME = Collections.unmodifiableMap(supportedLanguageFormsByName);
141    PLACEHOLDER_NAME_PATTERN = Pattern.compile("^[\\p{Alpha}_][\\p{Alnum}_-]*$");
142    LANGUAGE_TAG_PATTERN = Pattern.compile("^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*$");
143    JSON_EXTENSION = ".json";
144  }
145
146  private LocalizedStringLoader() {
147    // Non-instantiable
148  }
149
150  /**
151   * Loads all localized string files present in the specified package on the classpath.
152   * <p>
153   * Filenames must correspond to the IETF BCP 47 language tag format, optionally suffixed with {@code .json}.
154   * <p>
155   * Example filenames:
156   * <ul>
157   * <li>{@code en}</li>
158   * <li>{@code en.json}</li>
159   * <li>{@code es-MX}</li>
160   * <li>{@code es-MX.json}</li>
161   * <li>{@code nan-Hant-TW}</li>
162   * </ul>
163   * <p>
164   * Like any classpath reference, packages are separated using the {@code /} character.
165   * <p>
166   * Example package names:
167   * <ul>
168   * <li>{@code strings}
169   * <li>{@code com/lokalized/strings}
170   * </ul>
171   * <p>
172   * Note: this implementation only scans the specified package, it does not descend into child packages.
173   *
174   * @param classpathPackage location of a package on the classpath, not null
175   * @return per-locale sets of localized strings, not null
176   * @throws LocalizedStringLoadingException if an error occurs while loading localized string files
177   */
178  @NonNull
179  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(@NonNull String classpathPackage) {
180    return loadFromClasspath(LocalizedStringLoader.class.getClassLoader(), classpathPackage);
181  }
182
183  @NonNull
184  static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(@NonNull ClassLoader classLoader,
185                                                             @NonNull String classpathPackage) {
186    requireNonNull(classpathPackage);
187    requireNonNull(classLoader);
188
189    Enumeration<URL> urls;
190
191    try {
192      urls = classLoader.getResources(classpathPackage);
193    } catch (IOException e) {
194      throw new LocalizedStringLoadingException(format("Unable to search classpath for '%s'", classpathPackage), e);
195    }
196
197    if (!urls.hasMoreElements())
198      throw new LocalizedStringLoadingException(format("Unable to find package '%s' on the classpath", classpathPackage));
199
200    Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> mergedByLocale = createLocaleKeyMap();
201
202    while (urls.hasMoreElements()) {
203      URL url = urls.nextElement();
204      Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = loadFromUrl(url, classpathPackage);
205      mergeLocalizedStrings(mergedByLocale, localizedStringsByLocale);
206    }
207
208    return toLocalizedStringsByLocale(mergedByLocale);
209  }
210
211  /**
212   * Loads all localized string files present in the specified directory.
213   * <p>
214   * Filenames must correspond to the IETF BCP 47 language tag format, optionally suffixed with {@code .json}.
215   * <p>
216   * Example filenames:
217   * <ul>
218   * <li>{@code en}</li>
219   * <li>{@code en.json}</li>
220   * <li>{@code es-MX}</li>
221   * <li>{@code es-MX.json}</li>
222   * <li>{@code nan-Hant-TW}</li>
223   * </ul>
224   * <p>
225   * Note: this implementation only scans the specified directory, it does not descend into child directories.
226   *
227   * @param directory directory in which to search for localized string files, not null
228   * @return per-locale sets of localized strings, not null
229   * @throws LocalizedStringLoadingException if an error occurs while loading localized string files
230   */
231  @NonNull
232  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromFilesystem(@NonNull Path directory) {
233    requireNonNull(directory);
234    return loadFromDirectory(directory.toFile());
235  }
236
237  // TODO: should we expose methods for loading a single file?
238
239  /**
240   * Loads all localized string files present in the specified directory.
241   *
242   * @param directory directory in which to search for localized string files, not null
243   * @return per-locale sets of localized strings, not null
244   * @throws LocalizedStringLoadingException if an error occurs while loading localized string files
245   */
246  @NonNull
247  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromDirectory(@NonNull File directory) {
248    requireNonNull(directory);
249
250    if (!directory.exists())
251      throw new LocalizedStringLoadingException(format("Location '%s' does not exist",
252          directory));
253
254    if (!directory.isDirectory())
255      throw new LocalizedStringLoadingException(format("Location '%s' exists but is not a directory",
256          directory));
257
258    Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap();
259
260    File[] files = directory.listFiles();
261
262    if (files == null)
263      throw new LocalizedStringLoadingException(format("Unable to list files in directory '%s'", directory));
264
265    if (files != null) {
266      for (File file : files) {
267        if (file.isDirectory())
268          continue;
269
270        String fileName = file.getName();
271        String languageTag = languageTagForFileName(fileName);
272
273        if (languageTag != null) {
274          LOGGER.fine(format("Loading localized strings file '%s'...", fileName));
275          Locale locale = Locale.forLanguageTag(languageTag);
276
277          if (localizedStringsByLocale.containsKey(locale))
278            throw new LocalizedStringLoadingException(format("Duplicate localized strings file for locale '%s' found at '%s'",
279                locale.toLanguageTag(), file.getPath()));
280
281          localizedStringsByLocale.put(locale, parseLocalizedStringsFile(file));
282        } else {
283          LOGGER.fine(format("File '%s' does not correspond to a known language tag, skipping...", fileName));
284        }
285      }
286    }
287
288    return Collections.unmodifiableMap(localizedStringsByLocale);
289  }
290
291  @NonNull
292  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromUrl(@NonNull URL url, @NonNull String classpathPackage) {
293    requireNonNull(url);
294    requireNonNull(classpathPackage);
295
296    String protocol = url.getProtocol();
297
298    if ("file".equals(protocol)) {
299      try {
300        return loadFromDirectory(Paths.get(url.toURI()).toFile());
301      } catch (URISyntaxException e) {
302        throw new LocalizedStringLoadingException(format("Unable to resolve classpath location '%s'", url), e);
303      }
304    }
305
306    if ("jar".equals(protocol))
307      return loadFromJar(url, classpathPackage);
308
309    throw new LocalizedStringLoadingException(format("Unsupported classpath protocol '%s' for location '%s'",
310        protocol, url));
311  }
312
313  @NonNull
314  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromJar(@NonNull URL jarUrl,
315                                                               @NonNull String classpathPackage) {
316    requireNonNull(jarUrl);
317    requireNonNull(classpathPackage);
318
319    Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap();
320
321    try {
322      JarURLConnection connection = (JarURLConnection) jarUrl.openConnection();
323      connection.setUseCaches(false);
324
325      try (JarFile jarFile = connection.getJarFile()) {
326        String packagePath = connection.getEntryName();
327
328        if (packagePath == null || packagePath.isEmpty())
329          packagePath = classpathPackage;
330
331        if (!packagePath.endsWith("/"))
332          packagePath = packagePath + "/";
333
334        Enumeration<JarEntry> entries = jarFile.entries();
335
336        while (entries.hasMoreElements()) {
337          JarEntry entry = entries.nextElement();
338
339          if (entry.isDirectory())
340            continue;
341
342          String entryName = entry.getName();
343
344          if (!entryName.startsWith(packagePath))
345            continue;
346
347          String relativeName = entryName.substring(packagePath.length());
348
349          if ("".equals(relativeName) || relativeName.contains("/"))
350            continue;
351
352          String languageTag = languageTagForFileName(relativeName);
353
354          if (languageTag != null) {
355            LOGGER.fine(format("Loading localized strings file '%s' from %s...", relativeName, jarFile.getName()));
356            Locale locale = Locale.forLanguageTag(languageTag);
357
358            if (localizedStringsByLocale.containsKey(locale))
359              throw new LocalizedStringLoadingException(format("Duplicate localized strings file for locale '%s' found in %s",
360                  locale.toLanguageTag(), jarFile.getName()));
361
362            try (InputStream inputStream = jarFile.getInputStream(entry)) {
363              String contents = new String(inputStream.readAllBytes(), UTF_8).trim();
364              String canonicalPath = format("jar:%s!/%s", jarFile.getName(), entryName);
365              localizedStringsByLocale.put(locale, parseLocalizedStrings(canonicalPath, contents));
366            }
367          } else {
368            LOGGER.fine(format("File '%s' does not correspond to a known language tag, skipping...", relativeName));
369          }
370        }
371      }
372    } catch (IOException e) {
373      throw new LocalizedStringLoadingException(format("Unable to load localized strings from '%s'", jarUrl), e);
374    }
375
376    return Collections.unmodifiableMap(localizedStringsByLocale);
377  }
378
379  @NonNull
380  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> createLocaleMap() {
381    return new TreeMap<>((locale1, locale2) -> locale1.toLanguageTag().compareTo(locale2.toLanguageTag()));
382  }
383
384  @NonNull
385  private static Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> createLocaleKeyMap() {
386    return new TreeMap<>((locale1, locale2) -> locale1.toLanguageTag().compareTo(locale2.toLanguageTag()));
387  }
388
389  private static void mergeLocalizedStrings(
390      @NonNull Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> target,
391      @NonNull Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> source) {
392    requireNonNull(target);
393    requireNonNull(source);
394
395    for (Map.Entry<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> entry : source.entrySet()) {
396      Locale locale = entry.getKey();
397      Map<@NonNull String, @NonNull LocalizedString> localizedStringsByKey = target.get(locale);
398
399      if (localizedStringsByKey == null) {
400        localizedStringsByKey = new LinkedHashMap<>();
401        target.put(locale, localizedStringsByKey);
402      }
403
404      for (LocalizedString localizedString : entry.getValue()) {
405        String key = localizedString.getKey();
406        LocalizedString existing = localizedStringsByKey.get(key);
407
408        if (existing != null)
409          throw new LocalizedStringLoadingException(format("Duplicate localized string key '%s' found for locale '%s' while merging classpath resources",
410              key, locale.toLanguageTag()));
411
412        localizedStringsByKey.put(key, localizedString);
413      }
414    }
415  }
416
417  @NonNull
418  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> toLocalizedStringsByLocale(
419      @NonNull Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> localizedStringsByKeyByLocale) {
420    requireNonNull(localizedStringsByKeyByLocale);
421
422    Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap();
423
424    for (Map.Entry<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> entry : localizedStringsByKeyByLocale.entrySet()) {
425      localizedStringsByLocale.put(entry.getKey(),
426          Collections.unmodifiableSet(new LinkedHashSet<>(entry.getValue().values())));
427    }
428
429    return Collections.unmodifiableMap(localizedStringsByLocale);
430  }
431
432  private static boolean isLanguageTag(@NonNull String languageTag) {
433    requireNonNull(languageTag);
434
435    if (!LANGUAGE_TAG_PATTERN.matcher(languageTag).matches())
436      return false;
437
438    Locale locale = Locale.forLanguageTag(languageTag);
439    if (!"".equals(locale.getLanguage()))
440      return true;
441
442    return languageTag.toLowerCase(Locale.ROOT).startsWith("x-");
443  }
444
445  @Nullable
446  private static String languageTagForFileName(@NonNull String fileName) {
447    requireNonNull(fileName);
448
449    String languageTag = fileName;
450
451    if (fileName.toLowerCase(Locale.ROOT).endsWith(JSON_EXTENSION))
452      languageTag = fileName.substring(0, fileName.length() - JSON_EXTENSION.length());
453
454    return isLanguageTag(languageTag) ? languageTag : null;
455  }
456
457  /**
458   * Parses out a set of localized strings from the given file.
459   *
460   * @param file the file to parse, not null
461   * @return the set of localized strings contained in the file, not null
462   * @throws LocalizedStringLoadingException if an error occurs while parsing the localized string file
463   */
464  @NonNull
465  private static Set<@NonNull LocalizedString> parseLocalizedStringsFile(@NonNull File file) {
466    requireNonNull(file);
467
468    String canonicalPath;
469
470    try {
471      canonicalPath = file.getCanonicalPath();
472    } catch (IOException e) {
473      throw new LocalizedStringLoadingException(
474          format("Unable to determine canonical path for localized strings file %s", file), e);
475    }
476
477    if (!Files.isRegularFile(file.toPath()))
478      throw new LocalizedStringLoadingException(format("%s is not a regular file", canonicalPath));
479
480    String localizedStringsFileContents;
481
482    try {
483      localizedStringsFileContents = new String(Files.readAllBytes(file.toPath()), UTF_8).trim();
484    } catch (IOException e) {
485      throw new LocalizedStringLoadingException(format("Unable to load localized strings file contents for %s",
486          canonicalPath), e);
487    }
488
489    return parseLocalizedStrings(canonicalPath, localizedStringsFileContents);
490  }
491
492  @NonNull
493  private static Set<@NonNull LocalizedString> parseLocalizedStrings(@NonNull String canonicalPath,
494                                                            @NonNull String localizedStringsFileContents) {
495    requireNonNull(canonicalPath);
496    requireNonNull(localizedStringsFileContents);
497
498    if ("".equals(localizedStringsFileContents))
499      return Collections.emptySet();
500
501    Set<@NonNull LocalizedString> localizedStrings = new HashSet<>();
502    JsonValue outerJsonValue;
503
504    try {
505      outerJsonValue = Json.parse(localizedStringsFileContents);
506    } catch (MinimalJson.ParseException e) {
507      throw new LocalizedStringLoadingException(
508          format("%s: unable to parse localized strings file", canonicalPath), e);
509    }
510
511    if (!outerJsonValue.isObject())
512      throw new LocalizedStringLoadingException(format("%s: a localized strings file must be comprised of a single JSON object", canonicalPath));
513
514    JsonObject outerJsonObject = outerJsonValue.asObject();
515    Set<String> keys = new HashSet<>();
516
517    for (Member member : outerJsonObject) {
518      String key = member.getName();
519
520      if (!keys.add(key))
521        throw new LocalizedStringLoadingException(format("%s: duplicate localized string key '%s' encountered", canonicalPath, key));
522
523      JsonValue value = member.getValue();
524      localizedStrings.add(parseLocalizedString(canonicalPath, key, value, null));
525    }
526
527    return Collections.unmodifiableSet(localizedStrings);
528  }
529
530  /**
531   * Parses "toplevel" localized string data.
532   * <p>
533   * Operates recursively if alternatives are encountered.
534   *
535   * @param canonicalPath the unique path to the file (or URL) being parsed, used for error reporting. not null
536   * @param key           the toplevel translation key, not null
537   * @param jsonValue     the toplevel translation value - might be a simple string, might be a complex object. not null
538   * @return a localized string instance, not null
539   * @throws LocalizedStringLoadingException if an error occurs while parsing the localized string file
540   */
541  @NonNull
542  private static LocalizedString parseLocalizedString(@NonNull String canonicalPath, @NonNull String key, @NonNull JsonValue jsonValue,
543                                                      @Nullable List<@NonNull Token> expressionTokens) {
544    requireNonNull(canonicalPath);
545    requireNonNull(key);
546    requireNonNull(jsonValue);
547
548    LocalizedString.Builder localizedStringBuilder = new LocalizedString.Builder(key).expressionTokens(expressionTokens);
549
550    if (jsonValue.isString()) {
551      // Simple case - just a key and a value, no translation rules
552      //
553      // Example format:
554      //
555      // {
556      //   "Hello, world!" : "Приветствую, мир"
557      // }
558
559      String translation = jsonValue.asString();
560
561      if (translation == null)
562        throw new LocalizedStringLoadingException(format("%s: a translation is required for key '%s'", canonicalPath, key));
563
564      return localizedStringBuilder.translation(translation).build();
565    } else if (jsonValue.isObject()) {
566      // More complex case, there can be placeholders and alternatives.
567      //
568      // Example format:
569      //
570      // {
571      //   "I read {{bookCount}} books" : {
572      //     "translation" : "I read {{bookCount}} {{books}}",
573      //     "commentary" : "Message shown when user achieves her book-reading goal for the month",
574      //     "placeholders" : {
575      //       "books" : {
576      //         "value" : "bookCount",
577      //         "translations" : {
578      //           "ONE" : "book",
579      //           "OTHER" : "books"
580      //         }
581      //       }
582      //     },
583      //     "alternatives" : [
584      //       {
585      //         "bookCount == 0" : {
586      //           "translation" : "I haven't read any books"
587      //         }
588      //       }
589      //     ]
590      //   }
591      // }
592
593      JsonObject localizedStringObject = jsonValue.asObject();
594
595      String translation = null;
596
597      JsonValue translationJsonValue = localizedStringObject.get("translation");
598
599      if (translationJsonValue != null && !translationJsonValue.isNull()) {
600        if (!translationJsonValue.isString())
601          throw new LocalizedStringLoadingException(format("%s: translation must be a string for key '%s'", canonicalPath, key));
602
603        translation = translationJsonValue.asString();
604      }
605
606      String commentary = null;
607
608      JsonValue commentaryJsonValue = localizedStringObject.get("commentary");
609
610      if (commentaryJsonValue != null && !commentaryJsonValue.isNull()) {
611        if (!commentaryJsonValue.isString())
612          throw new LocalizedStringLoadingException(format("%s: commentary must be a string for key '%s'", canonicalPath, key));
613
614        commentary = commentaryJsonValue.asString();
615      }
616
617      Map<@NonNull String, @NonNull LanguageFormTranslation> languageFormTranslationsByPlaceholder = new LinkedHashMap<>();
618
619      JsonValue placeholdersJsonValue = localizedStringObject.get("placeholders");
620
621      if (placeholdersJsonValue != null && !placeholdersJsonValue.isNull()) {
622        if (!placeholdersJsonValue.isObject())
623          throw new LocalizedStringLoadingException(format("%s: the placeholders value must be an object. Key is '%s'", canonicalPath, key));
624
625        JsonObject placeholdersJsonObject = placeholdersJsonValue.asObject();
626
627        for (Member placeholderMember : placeholdersJsonObject) {
628          String placeholderKey = placeholderMember.getName();
629          JsonValue placeholderJsonValue = placeholderMember.getValue();
630          String value = null;
631          LanguageFormTranslationRange rangeValue = null;
632
633          ensureValidPlaceholderName(canonicalPath, key, placeholderKey, "placeholder");
634
635          if (!placeholderJsonValue.isObject())
636            throw new LocalizedStringLoadingException(format("%s: the placeholder value must be an object. Key is '%s'", canonicalPath, key));
637
638          JsonObject placeholderJsonObject = placeholderJsonValue.asObject();
639
640          JsonValue valueJsonValue = placeholderJsonObject.get("value");
641          JsonValue rangeJsonValue = placeholderJsonObject.get("range");
642          boolean hasValue = valueJsonValue != null && !valueJsonValue.isNull();
643          boolean hasRangeValue = rangeJsonValue != null && !rangeJsonValue.isNull();
644
645          if (!hasValue && !hasRangeValue)
646            throw new LocalizedStringLoadingException(format("%s: a placeholder translation value or range is required. Key is '%s'", canonicalPath, key));
647
648          if (hasValue && hasRangeValue)
649            throw new LocalizedStringLoadingException(format("%s: a placeholder translation cannot have both a value and a range. Key is '%s'", canonicalPath, key));
650
651          if (hasRangeValue) {
652            if (!rangeJsonValue.isObject())
653              throw new LocalizedStringLoadingException(format("%s: the placeholder translation range must be an object. Key is '%s'", canonicalPath, key));
654
655            JsonObject rangeJsonObject = rangeJsonValue.asObject();
656            JsonValue rangeValueStartJsonValue = rangeJsonObject.get("start");
657            JsonValue rangeValueEndJsonValue = rangeJsonObject.get("end");
658
659            if (rangeValueStartJsonValue == null || rangeValueStartJsonValue.isNull())
660              throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start is required. Key is '%s'", canonicalPath, key));
661
662            if (rangeValueEndJsonValue == null || rangeValueEndJsonValue.isNull())
663              throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end is required. Key is '%s'", canonicalPath, key));
664
665            if (!rangeValueStartJsonValue.isString())
666              throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start must be a string. Key is '%s'", canonicalPath, key));
667
668            if (!rangeValueEndJsonValue.isString())
669              throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end must be a string. Key is '%s'", canonicalPath, key));
670
671            String rangeStartValue = rangeValueStartJsonValue.asString();
672            String rangeEndValue = rangeValueEndJsonValue.asString();
673
674            ensureValidPlaceholderName(canonicalPath, key, rangeStartValue, "range start");
675            ensureValidPlaceholderName(canonicalPath, key, rangeEndValue, "range end");
676
677            rangeValue = new LanguageFormTranslationRange(rangeStartValue, rangeEndValue);
678          } else {
679            if (!valueJsonValue.isString())
680              throw new LocalizedStringLoadingException(format("%s: a placeholder translation value must be a string. Key is '%s'", canonicalPath, key));
681
682            value = valueJsonValue.asString();
683            ensureValidPlaceholderName(canonicalPath, key, value, "placeholder value");
684          }
685
686          JsonValue translationsJsonValue = placeholderJsonObject.get("translations");
687
688          if (translationsJsonValue == null || translationsJsonValue.isNull())
689            throw new LocalizedStringLoadingException(format("%s: placeholder translations are required. Key is '%s'", canonicalPath, key));
690
691          if (!translationsJsonValue.isObject())
692            throw new LocalizedStringLoadingException(format("%s: the placeholder translations value must be an object. Key is '%s'", canonicalPath, key));
693
694          Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm = new LinkedHashMap<>();
695
696          JsonObject translationsJsonObject = translationsJsonValue.asObject();
697
698          for (Member translationMember : translationsJsonObject) {
699            String languageFormTranslationKey = translationMember.getName();
700            JsonValue languageFormTranslationJsonValue = translationMember.getValue();
701            LanguageForm languageForm = SUPPORTED_LANGUAGE_FORMS_BY_NAME.get(languageFormTranslationKey);
702
703            if (languageForm == null)
704              throw new LocalizedStringLoadingException(format("%s: unexpected placeholder translation language form encountered. Key is '%s'. " +
705                      "You provided '%s', valid values are [%s]", canonicalPath, key, languageFormTranslationKey,
706                  SUPPORTED_LANGUAGE_FORMS_BY_NAME.keySet().stream().collect(Collectors.joining(", "))));
707
708            if (!languageFormTranslationJsonValue.isString())
709              throw new LocalizedStringLoadingException(format("%s: the placeholder translation value must be a string. Key is '%s'", canonicalPath, key));
710
711            translationsByLanguageForm.put(languageForm, languageFormTranslationJsonValue.asString());
712          }
713
714          if (translationsByLanguageForm.isEmpty())
715            throw new LocalizedStringLoadingException(format("%s: placeholder translations are required. Key is '%s'", canonicalPath, key));
716
717          Set<Class<?>> languageFormTypes = new HashSet<>();
718
719          for (LanguageForm languageForm : translationsByLanguageForm.keySet())
720            languageFormTypes.add(languageForm.getClass());
721
722          if (languageFormTypes.size() > 1)
723            throw new LocalizedStringLoadingException(format("%s: you cannot mix-and-match language forms in placeholder translations. " +
724                "Placeholder is '%s' for key '%s'", canonicalPath, placeholderKey, key));
725
726          if (rangeValue != null) {
727            boolean hasNonCardinality = translationsByLanguageForm.keySet().stream()
728                .anyMatch(languageForm -> !(languageForm instanceof Cardinality));
729
730            if (hasNonCardinality)
731              throw new LocalizedStringLoadingException(format("%s: range-based translations only support %s. Placeholder is '%s' for key '%s'",
732                  canonicalPath, Cardinality.class.getSimpleName(), placeholderKey, key));
733          }
734
735          LanguageFormTranslation languageFormTranslation = rangeValue != null
736              ? new LanguageFormTranslation(rangeValue, translationsByLanguageForm)
737              : new LanguageFormTranslation(value, translationsByLanguageForm);
738
739          languageFormTranslationsByPlaceholder.put(placeholderKey, languageFormTranslation);
740        }
741      }
742
743      List<@NonNull LocalizedString> alternatives = new ArrayList<>();
744
745      JsonValue alternativesJsonValue = localizedStringObject.get("alternatives");
746
747      if (alternativesJsonValue != null && !alternativesJsonValue.isNull()) {
748        if (!alternativesJsonValue.isArray())
749          throw new LocalizedStringLoadingException(format("%s: alternatives must be an array. Key is '%s'", canonicalPath, key));
750
751        JsonArray alternativesJsonArray = alternativesJsonValue.asArray();
752
753        for (JsonValue alternativeJsonValue : alternativesJsonArray) {
754          if (alternativeJsonValue == null || alternativeJsonValue.isNull())
755            continue;
756
757          if (!alternativeJsonValue.isObject())
758            throw new LocalizedStringLoadingException(format("%s: alternative value must be an object. Key is '%s'", canonicalPath, key));
759
760          JsonObject outerJsonObject = alternativeJsonValue.asObject();
761
762          for (Member member : outerJsonObject) {
763            String alternativeKey = member.getName();
764            JsonValue alternativeValue = member.getValue();
765            List<@NonNull Token> alternativeTokens = parseExpressionTokens(canonicalPath, alternativeKey);
766            alternatives.add(parseLocalizedString(canonicalPath, alternativeKey, alternativeValue, alternativeTokens));
767          }
768        }
769      }
770
771      if (translation == null && alternatives.isEmpty())
772        throw new LocalizedStringLoadingException(format("%s: either a translation or at least one alternative expression is required for key '%s'",
773            canonicalPath, key));
774
775      return localizedStringBuilder.translation(translation)
776          .commentary(commentary)
777          .languageFormTranslationsByPlaceholder(languageFormTranslationsByPlaceholder)
778          .alternatives(alternatives)
779          .build();
780    } else {
781      throw new LocalizedStringLoadingException(format("%s: either a translation string or object value is required for key '%s'",
782          canonicalPath, key));
783    }
784  }
785
786  @NonNull
787  private static List<@NonNull Token> parseExpressionTokens(@NonNull String canonicalPath, @NonNull String expression) {
788    requireNonNull(canonicalPath);
789    requireNonNull(expression);
790
791    try {
792      List<@NonNull Token> tokens = EXPRESSION_EVALUATOR.getExpressionTokenizer().extractTokens(expression);
793      List<@NonNull Token> rpnTokens = EXPRESSION_EVALUATOR.convertTokensToReversePolishNotation(tokens);
794      EXPRESSION_EVALUATOR.validateReversePolishNotationTokens(rpnTokens);
795      return rpnTokens;
796    } catch (ExpressionEvaluationException e) {
797      throw new LocalizedStringLoadingException(
798          format("%s: unable to parse alternative expression '%s'", canonicalPath, expression), e);
799    }
800  }
801
802  private static void ensureValidPlaceholderName(@NonNull String canonicalPath, @NonNull String key,
803                                                 @NonNull String placeholderName, @NonNull String description) {
804    requireNonNull(canonicalPath);
805    requireNonNull(key);
806    requireNonNull(placeholderName);
807    requireNonNull(description);
808
809    if (!PLACEHOLDER_NAME_PATTERN.matcher(placeholderName).matches())
810      throw new LocalizedStringLoadingException(format("%s: invalid %s '%s'. Placeholder names must start with a letter or underscore " +
811          "and contain only letters, digits, underscores, or hyphens. Key is '%s'", canonicalPath, description, placeholderName, key));
812  }
813}